Asembler w kodzie Linux'a

Autorzy: Spis treści:
  1. Co to właściwie jest asembler?
    1. Definicja.
    2. Zastosowanie.
    3. Wady i zalety kodu w asemblerze.
  2. Składnia asemblera Intel vs. AT&T.
  3. Asembler w Linux'ie.
    1. Wstęp.
    2. GCC.
    3. Tryby pracy.
    4. Jak pisać programy.
    5. Asembler w jądrze.
  4. Prezentacja architektur.
    1. Wstęp.
    2. Niektóre obsługiwane architektury.
  5. Ciekawe zastosowania.
    1. Wstęp
    2. copy_to_user/copy_from_user (dla i386)
    3. copy_user (dla ia64)
  6. Bibliografia


  1. Co to właściwie jest asembler?
    1. Definicja.
    2. Niskopoziomowy język programowania, dający możliwość bezpośrednich odwołań do urządzeń, pamięci i procesora, podobnie jak język maszynowy, na bazie którego powstał. Polecenie napisane kodem binarnym lub szesnastkowym jest prawie niezrozumiałe dlatego zastąpiono je mnemonikami.
      Pisanie w asemblerze pozwala programiście na bezpośrednią kontrolę zasobów. Programy asemblerowe są w związku z tym bardziej wydajne, jednak kodowanie ich jest wciąż bardzo uciążliwe.
      Przykładowe mnemoniki (podzielone ze względu na zastosowania):
      1. operacje arytmetyczne:
        • add- dodawanie obu argumentów do siebie i zapisanie wyniku na jednym z nich
        • inc- zwiększenie o jeden wartości argumentu
      2. operacje logiczne:
        • xor- exclusive or, najczęściej służy do zerowania rejestru
      3. przenoszenie danych:
        • mov- przepisanie wartosci jednego argumentu na drugi
        • pop/push- zdjęcie/wrzucenie wartości argumentu na stos
      4. skoki
        • call- przejście do przejście do podanego adresu z wrzucemniem na stos adresu powrotu
        • ret- powrot do miejsca gdzie zostało wywołane call, adres pobiera ze stosu
        • jmp- skok do podanego adresu (są też skoki warunkowe)

    3. Zastosowanie.
    4. Asemblerze wykorzystywany jest głównie w jądrach systemów operacyjnych, a także w kodach niektórych kompilatorów języków wysokopoziomowych. Polecenia innych języków są przepisywane na zestaw poleceń kodu maszynowego, który mamy możliwość deasemblować, czyli przetłumaczyć na asemblera. Dlatego ma on znaczną przewagę nad tymi językami, zarówno w wielkości pisanych programów, jak i w wydajności kodu.
      Jednak jest zupełnie nieprzenaszalny, ponieważ asemblery dla różnych architektur róznią się między sobą między innymi składnią i kompilatorami. Najpopularniejsze to składnie AT&T i Intel kompilowane odpowiednio przez gcc i NASM (dokładniejsze porównanie składni w rozdziale 2, a kompilatorów w rozdziale 3).

    5. Wady i zalety kodu w asemblerze.
    6. Zalety:
      + bezpośredni dostęp do zasobów komputera i możliwość panowania nad nimi
      + efektywny program wynikowy, na ogół szybszy i zajmujący mniej pamięci niż równoważny program zapisany w języku wysokiego poziomu
      + możliwość swobodnego wyboru formatu danych i precyzji obliczeń (samodzielne definiowanie wielobajtowych danych o praktycznie dowolnej dokładności)
      + możliwość dopasowania algorytmu i optymalizacji do indywidualnych cech architektury
      Wady:
      - kodowanie żmudne i zajmujące zdecydowanie wiecej czasu niż przy użyciu języków wysokiego poziomu, program jest więc droższy
      - mała cztytelność programu, przez co większa trudność wprowadzania modyfikacji
      - duża podatność na błędy, które później trudno zlokalizować
      - mała przenaszalność programów

  2. Składnia asemblera - Intel vs. AT&T.
  3.   składnia Intel'a składnia AT&T
    przykładowy program global main
    extern printf

    section .data

    beer db "%d bottles of bear on the wall."
         db 0x0a
         db "Take one down and pass it around."
         db 0x0a
         db 0

    main:
      mov ecx, 100

    _loop:
      dec ecx
      push ecx
      push ecx
      inc ecx
      push ecx
      push ecx
      push beer
      call printf
      add esp,16
      pop ecx
      or ecx, ecx
      jne _loop
      xor eax,eax
      ret
    .section .rodata

    .beer:
       .ascii "%d bottles of beer on the wall.\n"
       .asciz "Take one down and pass it around %d.\n"

    .text
    .global main
    main:

    mov $100, %ecx

    loop:
      dec %ecx
      push %ecx
      push %ecx
      inc %ecx
      push %ecx
      push %ecx
      pushl $.beer
      call printf
      add $16,%esp
      pop %ecx
      or %ecx, %ecx
      jne loop
      xorl %eax,%eax
      ret
    pisownia rejestrów
    sama nazwa: eax
    nazwa z prefiksem %: %eax
    pisownia liczb
    binarne:10b
    heksadecymalne: 1Eh
    binarne poprzedzone $: $10
    szesnastkowe poprzedzone 0x: 0x1E
    menmoniki * kolejność argumentów: cel, źródło
    * dyrektywy odwołań do pamięci mówiące o przekazywanej wielkości
      mov eax, 1b
      mov al,bl
      mov ax,bx
      mov eax,ebx
      mov eax, dword ptr [ebx]
    * kolejność argumrentów: żródło, cel
    * sufiksy operatorów oznaczające wielkość przekazwaną
      movl $1, eax
      movb %bl,%al
      movw %bx,%ax
      movl %ebx,%eax
      movl (%ebx),%eax
    odwołania do pamięci podawane w '[' i ']'
      mov eax,[ebx+10h]
      add eax,[ebx+ecx*2h]
      lea eax,[ebx+ecx]
      sub eax,[ebx+ecx*2h-10h]
    odwołanie w '(' i ')'
      movl 0x10(%ebx),%eax
      addl (%ebx,%ecx,0x2),%eax
      leal (%ebx,%ecx),%eax
      subl -0x10(%ebx,%ecx,0x2),%eax

      wymyślna forma zapisu operacji kompleksowych!!!

  4. Asembler w Linux'ie.
    1. Wstęp
    2. W Linux'ie do kodowania w asemblerze wykorzystuje się składnię AT&T. Zostało to odziedziczone z Unix'ów. Taką też składnię obsługuje GCC. Oczywiście jeśli ktoś zna składnię Intelowską (np. pisał w niej pod DOS'em) i nie ma ochoty uczyć się od nowa, zawsze może skorzystać z NASM'a. Z mojego punktu widzenia składnia Intelowska jest wygodniejsza i bardziej czytelna (poniżej przykład zapisania w rejestrze eax zawartości komórki pamięci wskazywanej przez adres ebx powiększony dwa razy o ecx i o 3):

      IntelAT&T
      mov eax, [ebx + ecx*2 + 3] movl 3(%ebx, %ecx, 2), %eax

      Należy pamiętać, że NASM tylko asembluje kod, więc trzeba go później jeszcze zlinkować. Do tego polecam prosty linker ld.

    3. GCC
    4. Wróćmy do GCC. Jeśli chcemy napisać coś w asemblerze możemy zrobić to na dwa spoosby. Pisząc cały program lub robiąc tzw. "wstawki". Pierwsze podejście jest co najmniej niewygodne, poza tym mamy niewielkie szanse na uzyskanie większej wydajności niż daje nam napisanie tego samego w języku wyższego poziomu (chociażby C), gdyż dzisiejsze kompilatory są w stanie bardzo dobrze optymalizować kod (jednak im mniej lini kodu, tym większe szanse mamy na napisanie go wydajniej w asemblerze niż w C). Z tego powodu jedyną na prawdę sensowną sytuacją kiedy chcemy korzystać z asemblera jest pisanie "wstawek".
      "Wstawki" w asemblerze to nic innego jak fragment dodany do programu napisanego w języku wyższego poziomu. Dzięki temu możemy zwiększyć wydajność naszego programu w krytycznych operacjach. Takie właśnie podejście zaprezentowali twórcy Linux'a. W kodzie jądra asembler jest wykorzystywany w postaci "wstawek" w momencie, gdy zaistniała potrzeba zwiększenia wydajności w prostych i często powtarzających się sytuacjach (obsługa pamięci, obsługa operacji na urządzeniach).

    5. Tryby pracy
    6. Tutaj właśnie doszliśmy do sedna pisania w asemblerze w Linux'ie. W przeciwieństwie do DOS'a nie mamy dostępu do bezpośredniego dostępu do urządzeń. Począwszy od architktury 80386 procesor przewiduje dwa tryby pracy: chroniony i rzeczywisty. Na czym polega różnica? To proste. W trybie rzeczywistym mamy dostęp do wszystkiego w co wyposażony jest komputer. Możemy zrobić praktycznie wszystko, łącznie z dostępem do dowolnych fragmentów pamięci operacyjnej czy diod na klawiaturze. W trybie chronionym nasze możliwości są ograniczone do podstawowcyh operacji. Linux wykorzystuje te cechy architektury, dając użytkownikowi tylko i wyłącznie możliwość pracy w trybie chronionym. Tryb rzeczywisty zarezerwowany jest dla jądra.

    7. Jak pisać programy
    8. Pisanie samych programów (działających w trybie chronionym) jest wbrew pozorom prostsze niż w DOS'ie. Nie musimy zajmować się obsługą konkretnych urządzeń i zgłębianiem ich tajników. Zrobili juz to za nas programiści Linux'a bądź dodawanych do niego modułów. Zamiast odwoływać się do urządzeń korzystamy z zestawu funkcji systemowych (ich listę możemy znaleźć w pliku unistd.h w źródłach systemu.
      Kosztem tego jest brak możliwości optymalizacji na poziomie dostępu do zasobów systemowych. Jedyna co możemy ulepszyć w naszym programie dzięki skorzystaniu z asemblera, to obsługa pętli i innych krótkich instrukcji.

    9. Asembler w jądrze
    10. Jak widać pisanie programów w asemblerze pod Linux'em nie jest specjalnie przydatne i niewiele możemy na tym polu zdziałać. Jednak dla tych, którzy postanowili jednak kodować w tym języku jest nadal spore pole do popisu: kodowanie na poziomie jądra systemu. To właśnie tam możemy pracować w trybie rzeczywistym i korzystać w pełni z możliwości jakie daje nam asembler. Przeglądając katalog arch w źródłach systemu natrafimy na liczne wystąpienia wstawek asemblerowych i to dla różnych architektur. Tam właśnie możemy obejrzeć jak korzystać z niskopoziomowej obsługi urządzeń (obsługa pamięci, zaawansowne funkcje procesora itp.). Jeśli będzimy potrzebowali kiedyś obsłużyć jakieś urządzenie, do którego nie istnieją sterowniki (nietypowa karta graficzna, ekspres do kawy czy samochód), to możemy napisać je sami wykorzystując głównie asembler.

  5. Prezentacja architektur.
    1. Wstęp
    2. Linux'a można zainstalować i uruchomić na wielu różnych architekturach (niektóre z nich zostały wymienione i opisane poniżej). Dla pojawiających się nowych architektur (np. 64-bitowe procesory firm AMD i Intel) pojawiają się kolejne wersje systemu. Można też bez wiekszych problemów uruchamiać ten system na masznach wieloprocesorowych. Zawdzięczamy to wykorzystaniu asemblera w kluczowych miejscach, dzięki czemu specyficzne dla architektury operacje zmieniane są w ściśle określonym miejscu (podkatalogi katalogu arch w źródłach systemu). Jeśli jakaś architektura nie została jeszcze zaimplementowana, to nic nie stoi na przeszkodzie żeby się tym zająć ;)

    3. Niektóre obsługiwane architektury
      • Alpha - 64-bitowa architektura RISC Digitala. Projekt Alpha został rozpoczęty w połowie 1989 roku, jego celem było stworzenie wysokowydajnej alternatywy dla użytkowników VAX'a. Nie była to pierwsza architektura RISC zaprojektowana przez Digitala, ale jako pierwsza osiągnęła sukces rynkowy. Kiedy Digital ogłosił wyprodukowanie Alphy w marcu 1992, zdecydował się wejść na rynek półprzewodników, sprzedając mikroprocesory Alpha.
      • ARM - procesor zaprojektowano w 1987 roku jako Acorn RISC Machine - jednostkę centralną komputerów osobistych Acorn Archimedes. Dominacja PC na rynku komputerów osobistych uniemożliwiła karierę Archimedesa, natomiast sam procesor, przechrzczony na Advanced RISC Machine, stał się na długie lata standardem wśród procesorów przeznaczonych do wbudowania w urządzenia, a także wzorcem architektury i modelu programowego procesorów RISC. Procesory ARM i ich pochodne można spotkać niemal wszędzie - w routerach i switchach sieciowych, w notepadach i telewizyjnych set-top boxach, a nawet w samochodach.
      • i386 - rodzina 32-bitowych procesorów CISC (początkowo firmy Intel). Pierwszy procecor 80386 powstał w 1985 roku. Wprowadził wiele nowych rozkazów w porównaniu ze swoim poprzednikiem (16-bitowy 80286). Posiada dwa różne tryby pracy procesora (chroniony i rzeczywisty), które można zmieniać programowo. Przyczynił się do upowszechnienie komputerów klasy PC, które są wyposażone w kolejne jego wersje i ich klony.
      • PPC - rodzina procesorów RISC powstała w 1993. Produkowanych dzięki współpracy IBM, Motorola i Apple'a. Obecnie wykorzystywany głównie w komputerach Apple'a.
      • SPARC - architektura opracowana przez firmę Sun. Procesory z tej rodziny wykorzystywane są głównie w wieloprocesorowaych stacjach roboczych.
      • ia64 - rodzina 64-bitowych procesorów firmy Intel. Wzbogacona (w porównaniu z i386) o nowe rozkazy i rozwiązania technologiczne usprawniające wielowątkowość.

  6. Ciekawe zastosowania.
    1. Wstęp
    2. Dzięki "wstawkom" asemblerowym udało się w Linux'ie uzyskać możliwość wygodnego i wydajnego implementowania funkcji dostępnych dla konkretnych architektur. Poniżej przedstawione są fragmenty kodu źródłowego wraz z krótkim ómówieniem ich znaczenia.

    3. copy_to_user/copy_from_user (dla i386)
    4. Powyższe makra są wykorzystywane przy przekazywaniu danych z pamięci jądra do pamięci programu wywołującego mechanizmy jądra. W związku z implementacją obsługi urządzeń przez jądro systemu, a nie bezpośrednio przez każdy program, wykonywane są one często i jest dość istotną kwestią robienie tego szybko i wydajnie. Ponieważ oba makra zdefiniowane są niemal identycznie, ich działanie przedstawię na jednym z nich - copy_to_user Interesujące nas pliki:
      Poniżej znajduje się definicja makra copy_to_user:
       
      makro copy_to_user z pliku aucces.h
      #define copy_to_user(to,from,n)                         \
      580         (__builtin_constant_p(n) ?                      \
      581          __constant_copy_to_user((to),(from),(n)) :     \
      582          __generic_copy_to_user>((to),(from),(n)))
      

      W momencie jego wywołania jądro rozstrzyga czy wywołać jedno z dwóch makr - constant lub generic. Oba sprawdzają uprawnienia do wykonania operacji a następnie wywołują odpowiednio makra __constant_copy_user i __generic_copy_user. Definicję pierwszego z nich mozemy znaleźć w pliku uaccess.h, oto jej fragment:
       
      makro __constant_copy_user z pliku aucces.h
      
      #define __constant_copy_user(to, from, size)                    \
      325 do {                                                            \
      326         int __d0, __d1;                                         \
      327         switch (size & 3) {                                     \
      328         default:                                                \
      329                 __asm__ __volatile__(                           \
      330                         "0:     rep; movsl\n"                   \
      331                         "1:\n"                                  \
      332                         ".section .fixup,\"ax\"\n"              \
      333                         "2:     shl $2,%0\n"                    \
      334                         "       jmp 1b\n"                       \
      335                         ".previous\n"                           \
      336                         ".section __ex_table,\"a\"\n"           \
      337                         "       .align 4\n"                     \
      338                         "       .long 0b,2b\n"                  \
      339                         ".previous"                             \
      340                         : "=c"(size), "=&S" (__d0), "=&D" (__d1)\
      341                         : "1"(from), "2"(to), ""(size/4)       \
      342                         : "memory");                            \
      343                 break;                                          \
      344         case 1:                                                 \
      345                 __asm__ __volatile__(                           \
      346                         "0:     rep; movsl\n"                   \
      347                         "1:     movsb\n"                        \
      348                         "2:\n"                                  \
      349                         ".section .fixup,\"ax\"\n"              \
      350                         "3:     shl $2,%0\n"                    \
      351                         "4:     incl %0\n"                      \
      352                         "       jmp 2b\n"                       \
      353                         ".previous\n"                           \
      354                         ".section __ex_table,\"a\"\n"           \
      355                         "       .align 4\n"                     \
      356                         "       .long 0b,3b\n"                  \
      357                         "       .long 1b,4b\n"                  \
      358                         ".previous"                             \
      359                         : "=c"(size), "=&S" (__d0), "=&D" (__d1)\
      360                         : "1"(from), "2"(to), ""(size/4)       \
      361                         : "memory");                            \
      362                 break;                                          \
      
      (...)
      404 } \ 405 } while (0)

      Jak widać makro to składa się w większości z kodu w asemblerze. Jednak nie jest to jeszcze rozwiązanie optymalne. O wiele ciekawszym przypadkiem jest makro __generic_copy_user, które zdefiniowane zostało w pliku usercopy.c:
       
      makro __generic_copy_user z pliku usercopy.c
      
      12 #ifdef CONFIG_X86_USE_3DNOW_AND_WORKS
      13
      14 unsigned long
      15 __generic_copy_to_user(void *to, const void *from, unsigned long n)
      16 {
      17         if (access_ok(VERIFY_WRITE, to, n))
      18         {
      19                 if(n<512)
      20                         __copy_user(to,from,n);
      21                 else
      22                         mmx_copy_user(to,from,n);
      23         }
      24         return n;
      25 }
      
      (...)
      #else 43 44 unsigned long 45 __generic_copy_to_user(void *to, const void *from, unsigned long n) 46 { 47 prefetch(from); 48 if (access_ok(VERIFY_WRITE, to, n)) 49 __copy_user(to,from,n); 50 return n; 51 }

      Powyższe makro jest dość ciekawym przypadkiem: w zależności od tego czy zdefiniowano CONFIG_X86_USE_3DNOW_AND_WORKS wywołuje się albo wersja makra __generic_copy_user dająca ew. możliwość wykorzystania rozkazów MMX procesora. Albo wersja druga, która korzysta ze zdefiniowanego w pliku uaccess.h makra (__copy_user):
       
      makro __copy_user z pliku uaccess.h
      
      254 /* Generic arbitrary sized copy.  */
      255 #define __copy_user(to,from,size)                                       \
      256 do {                                                                    \
      257         int __d0, __d1;                                                 \
      258         __asm__ __volatile__(                                           \
      259                 "0:     rep; movsl\n"                                   \
      260                 "       movl %3,%0\n"                                   \
      261                 "1:     rep; movsb\n"                                   \
      262                 "2:\n"                                                  \
      263                 ".section .fixup,\"ax\"\n"                              \
      264                 "3:     lea 0(%3,%0,4),%0\n"                            \
      265                 "       jmp 2b\n"                                       \
      266                 ".previous\n"                                           \
      267                 ".section __ex_table,\"a\"\n"                           \
      268                 "       .align 4\n"                                     \
      269                 "       .long 0b,3b\n"                                  \
      270                 "       .long 1b,2b\n"                                  \
      271                 ".previous"                                             \
      272                 : "=&c"(size), "=&D" (__d0), "=&S" (__d1)               \
      273                 : "r"(size & 3), ""(size / 4), "1"(to), "2"(from)      \
      274                 : "memory");                                            \
      275 } while (0)
      

      Jak się okazuje warunek zdefniowania CONFIG_X86_USE_3DNOW_AND_WORKS nigdy nie zostaje spełniony! Innymi słowy makro takie nie zostało dotąd zaimplementowane. Nic nie stoi na przeszkodzie, by czytelnik zrobił to samodzielnie.
      Można to zrobić na przykładzie innych makr, które zostały zdefiniowane w pliku mmx.c. Oto jedno z takich makr, pozwalające na szybsze czyszczenie stron pamięci:
       
      makro fast_clear_page z pliku mmx.c
      
      /*
      257  *      Generic MMX implementation without K7 specific streaming
      258  */
      259
      260 static void fast_clear_page(void *page)
      261 {
      262         int i;
      263
      264         kernel_fpu_begin();
      265
      266         __asm__ __volatile__ (
      267                 "  pxor %%mm0, %%mm0\n" : :
      268         );
      269
      270         for(i=0;i<4096/128;i++)
      271         {
      272                 __asm__ __volatile__ (
      273                 "  movq %%mm0, (%0)\n"
      274                 "  movq %%mm0, 8(%0)\n"
      275                 "  movq %%mm0, 16(%0)\n"
      276                 "  movq %%mm0, 24(%0)\n"
      277                 "  movq %%mm0, 32(%0)\n"
      278                 "  movq %%mm0, 40(%0)\n"
      279                 "  movq %%mm0, 48(%0)\n"
      280                 "  movq %%mm0, 56(%0)\n"
      281                 "  movq %%mm0, 64(%0)\n"
      282                 "  movq %%mm0, 72(%0)\n"
      283                 "  movq %%mm0, 80(%0)\n"
      284                 "  movq %%mm0, 88(%0)\n"
      285                 "  movq %%mm0, 96(%0)\n"
      286                 "  movq %%mm0, 104(%0)\n"
      287                 "  movq %%mm0, 112(%0)\n"
      288                 "  movq %%mm0, 120(%0)\n"
      289                 : : "r" (page) : "memory");
      290                 page+=128;
      291         }
      292
      293         kernel_fpu_end();
      294 }
      

      W tym makrze wykorzystywane są możliwości szybkiego czyszczenia więszych (8B)fragmentów strony pamięci. Strona jest dzielona na 128B fragmenty, do których następnie jest kopiowana 16 razy zawartość wyzerowanego wcześniej rejestru mm0. Rejestr ten jest dodatkowym rejestrem występującym w procesorach z obsługą MMX. Warto zwrócić tu uwagę na to, że zwiększenie rozmiaru kodu pozwala na zmniejszenie ilości poleceń zwiększenia licznika co ma znaczenie przy tak często wywoływanej operacji.

    5. copy_user (dla ia64)
    6. W pliku arch/ia64/lib/copy_user.S znajduje się z definicja makra omawianego powyżej z przeznaczeniem dla architektury najnowszego procesora firmy Intel. Sama definicja jest zbyt długa by ją tutaj wklejać, jednak zainteresowanym polecam jej obejrzenie - wykorzystane są w niej specyficzne dla tej 64-bitowej architektury rozkazy, mające na celu przespieszenie wykonywania tej operacji.

  7. Bibliografia