Valid HTML 4.01!

Asembler w kodzie Linuksa

Spis treści:

I. Wstęp
1. Asembler - ogólne informacje
2. Asembler w Linuksie
II. Składnia
1. Asembler AT&T kontra składnia Intela
2. Asembler w kodzie C
III. Asembler w kodzie jądra Linuksa
1. Operacje atomowe
2. Operacje atomowe na SPARC-u
3. Spinlock
4. Start systemu
5. Specyficzne możliwości procesorów
6. Przełączanie kontekstu
IV. Literatura


I. Wstęp

Co to jest asembler?

Niskopoziomowy język programowania pozwalający na:

Bardziej niskopoziomowego języka już się nie używa. (Najniższym jest ręczne-kodowanie w kodach binarnych instrukcji).

Korzyści asemblera

Asembler jest mocno-niskopoziomowym językiem:

Wady używania asemblera

Asembler jest mocno-niskopoziomowym językiem, to znaczy:

Jak uzyskać kod asemblera?

Standardową metodą uzyskania kodu asemblera jest wywołanie twojego kompilatora z flagą -s. działa to dla większości unix-owych kompilatorów, włączając w to gnu c compiler (gcc). Dla gcc, bardziej zrozumiały kod asemblera będzie wyprodukowany po użyciu opcji -fverbose-asm. Oczywiście, jeśli chcesz dostać dobry kod asemblera, nie zapomnij użyć opcji optymalizacji.

Jak używać asemblera?

Zasady uzyskania efektywnego kodu:

Języki takie jak objectivecaml, sml, commonlisp, scheme, ada, pascal, c, c++, wśród innych, wszystkie mają wolne zoptymalizowane kompilatory, które zoptymalizują masę twoich programów. Zwykle będą lepsze niż ręczny kod asemblera.
Wniosek: pisz (o ile potrzebujesz) małe sekcje kodu w asemblerze

Ogólne zasady przyśpieszania twojego kodu

W celu przyspieszenia aplikacji powinieneś robić zrobić to tylko dla fragmentów kodu będących wąskim gardłem programu.

Stąd, jeśli określisz fragmenty kodu jako zbyt wolne, powinieneś

Kompilator

Dobrze znany kompilator gnu c/c++ (gcc), zoptymalizowany 32-bitowy kompilator będący sercem projektu gnu, wspiera całkiem dobrze architekturę x86, włączając w to zdolność wstawiania kodu asemblera w programach w c, w sposób gdzie zarządzanie rejestrami może być wyspecyfikowane lub pozostawione gcc. Kompilator gcc działa na większości dostępnych platform, godnych uwagi: linux-a, *bsd, vst, os/2, *dos-a, win*, itd.

windows:

http://www.delorie.com/djgpp/

linux:

ftp://prep.ai.mit.edu/pub/gnu/ razem ze wszystkimi wersjami aplikacji z projektu gnu. dokumentacja:

http://www.leo.org/pub/comp/os/os2/gnu/emx+gcc/

kompilacja:


        gcc -02 -fomit-frame-pointer -w -wallpp

-02 jest dobrym poziomem optymalizacji w większości przypadków.

|początek | |następny | |spis treści|

Asembler w jądrze linuksa

Zależność od sprzętu

Linux próbuje utrzymać rozgraniczenie między częścią kodu žródłowego zależną od sprzętu i niezależną. Katalogi arch i include zawierają podkatalogi odpowiadające dostępnym platformom sprzętowym. standardowe nazwy platform są następujące:

Po co nam asembler w jądrze linuksa?

|początek | |następny | |spis treści|

II. Składnia

1. Asembler AT&T kontra składnia Intela

Składnia asemblera AT&T różni się znacząco od składni Intela, znanej z dokumentacji procesorów tej firmy. W Linuksie używana jest właśnie składnia AT&T. Poniżej znajdują się najważniejsze różnice między tymi dwoma składniami.


2. Asembler w kodzie C (inline asm)

Oprócz pisania kodu w czystym asemblerze, przydatne okazuje się wstawianie fragmentów programu, zapisanych w asemblerze, do źródła programu, pisanego w języku wysokiego poziomu, np. C. GCC umożliwia kompilowanie plików źródłowych języka C ze wstawkami w asemblerze (ze składnią AT&T). Ogólna forma wstawek asemblerowych jest wspólna dla różnych platform, ale więzy argumentów maja różne znaczenie dla różnych platform sprzętowych. Tutaj omawiany będzie wariant w architekturze x86 Intela.

Wstawki asemblerowe można pisać w sposób zupełnie bezpośredni, po prostu pisząc kod jaki chcemy wykonać:


__asm__ (" movl $1, %eax
movl $0, %ebx
int $0x80");

Jeżeli użycie asemblera sprowadza się do takiego wywołania jak wyżej to taka forma może wystarczać. Ale asembler w GCC pozwala zapisać skrótowo pewne operacje, które zostaną automatycznie rozwinięte do odpowiednich instrukcji asemblerowych.
Jest możliwe określenie danych, które zostaną użyte jako wejście i wyjście instrukcji asemblerowych oraz rejestry, które zostaną zmodyfikowane. Każdy argument może być opisany przez typ (więzy) oraz wyrażenie ze świata języka C w nawiasach. Wyrażenia w części output muszą być "l-wartościami" (kompilator może to sprawdzać), bo na te wartości będzie dokonywany zapis.
Manual do GCC twierdzi, że instrukcje asemblerowe wewnątrz szablonu nie są w żaden sposób parsowane i sprawdzane przez kompilator zatem nie będzie sprawdzone czy instrukcja jest poprawną instrukcją dla asemblera danego procesora, czy ma poprawną liczbę argumentów itd. Natomiast praktyka wykazuje coś zupełnie przeciwnego, czyli nie dość, że instrukcje są sprawdzane to również sprawdzana jest poprawność argumentów.
Najczęstszym sposobem dostawania się do zdefiniowanych w szablonie argumentów jest używanie ich numeracji w kolejności, tj. %0 dla pierwszego argumentu, %1 dla drugiego itd.
Przykład:

int a;
int b = 10;
int c = 20;
__asm__ (" mov %2, %0"
: "=a"(a)
: "0" (b), "c" (c)
);

Powyższy przykład wymaga kilku wyjaśnień. Jak zostało wspomniane wyżej, po instrukcji występuje opis wyjścia, wejścia i modyfikowanych rejestrów. Części te oddzielane są dwukropkami, zatem instrukcja jest postaci: __asm__ ("instrukcje" : output : input : modify);. Jeżeli chcemy pominąć sekcję output, to należy pozostawić puste miejsce po pierwszym dwukropku (dobrze jest zaznaczyć komentarzem, że celowo nic tam nie zostało umieszczone). Modify również jest opcjonalne. Argumenty do zapisu muszą być poprzedzone znakiem "=".
Typy (więzy) argumentów, o których wspomniane jest wyżej są określane literami w cudzysłowie. Tutaj dochodzimy do części związanej ze sprzętem. Część typów jest wspólna dla wszystkich platform, natomiast wiele z nich jest specyficznych dla konkretnych architektur, a wynika to chociażby z tego, że na różnych architekturach nazewnictwo i liczba rejestrów jest zupełnie inne. Do wspólnych więzów należą:

m dostęp do pamięci
0, 1, ... oznaczenie argumentu, który już występuje na liście argumentów
i bezpośrednia wartość
r jakiś rejestr ogólny

Podstawowe więzy specyficzne dla architektury Intela:

a, b, c, d, D, S odpowiednio rejestry eax, ebx, ecx, edx, edi, esi
q jeden z rejestrów eax, ebx, ecx, edx

Wszystkich więzów jest znacznie więcej i do ciekawszych zastosowań warto zapoznać się z pełną listą zawartą w manualu do GCC.
Zamiast numerów wejść/wyjść można podawać bezpośrednio nazwę rejestru, którego chce się używać, ale wtedy należy go poprzedzić dwoma znakami "%", na przykład:

__asm__ ("xorl %%ebx, %%ebx", : : "b" (b));

Warto obejrzeć do czego rzeczywiście rozwijane są nasze przykładowe szablony. Znany już program:

int a;
int b = 10;
int c = 20;
__asm__ (" mov %2, %0"
: "=a" (a)
: "0" (b), "c" (c)
);

Kompiluje się do czegoś zbliżonego:


1. movl -8(%ebp),%eax
2. movl -12(%ebp),%ecx
3. movl %ecx, %eax
4. movl %eax,%edx
5. movl %edx,-4(%ebp)

1., 2. - wartości zmiennych zdeklarowanych wyżej są wrzucane do rejestrów eax i ecx, co wynika z wiersza : "0" (b), "c" (c), który żąda zapisania do rejestru ecx ("c") wartości zmiennej c, natomiast zapis "0" (b) żąda zapisania wartości zmiennej b do rejestru wskazywanego przez typ podany jako pierwszy czyli rejestr eax ("=a").
3. - instrukcja mov %2, %0 z podstawionymi odpowiednimi parametrami, zatem na pierwszy parametr podstawiony jest typ podany jako trzeci ("c") czyli %eax, a na drugi - typ podany jako pierwszy ("=a") czyli eax.
W wierszu : "=a" (a) zażądaliśmy zapisania, po wykonaniu kodu, zawartości rejestru eax do zmienej a, co realizują instrukcje 4.-5.

Warto również wiedzieć, że kompilator sprawdza rozmiar typu podanego w nawiasach i na podstawie tej informacji generuje odpowiedni rejestr, czyli gdyby zamiast int w programie zapisać zmienne typu char i zamiast instrukcji movl zapisać instrukcję movb to kompilator zapisałby program jako:

movb -2(%ebp),%al
movb -3(%ebp),%cl
movb %cl, %al
movb %al,%dl
movb %dl,-1(%ebp)


Widać, że zostały użyte rejestry rozmiaru jednego bajtu.
Na koniec jeszcze krótki przykład użycia typu "m" czyli odnoszącego się do pamięci. Jasne jest, że instrukcja:

__asm__ ("movl %0, %1" : : "a"(b), "b"(c));

rozwinie się do:

movl -8(%ebp),%eax
movl -12(%ebp),%ebx
movl %eax, %ebx


Czyli wartości zmiennych zostaną zapisane do rejestrów eax i ebx, na których następnie zostanie wykonana operacja mov.
Do czego natomiast rozwinie się kod jeżeli zażądamy korzystania bezpośrednio ze zmiennej w pamięci? Następujący kod:

__asm__ ("movl %0, %1" : : "a"(b), "m"(c));

rozwinie się do:

movl -8(%ebp),%eax
movl %eax, -12(%ebp)


Czyli zostanie wykonana bezpośrednia operacja na pamięci. Pozornie drugi zapis wygląda na lepszy, bo zapis jest krótszy, ale często nie da się wykonać operacji bezpośrednio na komórkach pamięci (chociażby w powyższy przykład nie skompiluje się, jeżeli również w pierwszym typie podalibyśmy "m"), a także ze względów wydajnościowych takie rozwiazanie może okazać się gorsze (nieraz instrukcje działają na komórkach pamięci znacznie wolniej niż na rejestrach).


III. Asembler w kodzie jądra Linuksa

1. Operacje atomowe

Omawiany kod źródłowy znajduje się w kodzie źródłowym jądra w include/asm-i386/atomic.h

Pisząc kod w C nigdy nie ma gwarancji, że wykonywana operacja (np. a=a+1) wykona się w sposób niepodzielny (a++ też nie;). Łatwo może dojść do sytuacji, w której jeden proces odczyta wartość a aby dodać do niej 1, ale zanim doda, to drugi proces również odczyta a i doda do niej 1, następnie jeden zapisze swoją wartość i drugi zapisze. Efektem będzie to, że skutek odniesie tylko jedna z operacji a oba procesy będa przekonane, ze ich operacja wykonała się prawidłowo. Wydaje się, że w trybie jądra nie jest to problem, bo proces nie może zostać wywłaszczony. Ale okazuje się, że problem robi się poważny w architekturze wieloprocesorowej, gdzie wiele procesów może jednocześnie znajdować się w trybie jądra oraz oczywiście w trybie użytkownika, gdzie procesy wywłaszczane są bardzo często.
Pożądanym byłoby posiadanie operacji realizujących, podobne do powyższej, instrukcje w sposób atomowy. O pewnych instrukcjach asemblera z góry wiemy, że są atomowe. Instrukcje asemblera dla procesorów 80x86 są niepodzielne gdy:

Instrukcje zaczynające się od rep nie są niepodzielne, procesor przed każdym powtórzeniem instrukcji po rep sprawdza czy nie czekają przerwania do obsłużenia.
Jądro Linuksa dostarcza zestawu operacji pozwalających wykonać operacje niepodzielne na atomowym typie danych:

typedef struct { volatile int counter; } atomic_t;

Operacje inicjalizacji, odczytu i zapisu wartości są operacjami pobierającymi coś z pamięci 0 lub 1 razy zatem będą atomowe bez dodatkowej pomocy.
W przypadku operacji dodawania do licznika mamy do czynienia z operacją sięgającą do pamięci po starą wartość i następnie zapisującą nową, wiec istnieje tutaj problem opisany wyżej na przykładzie a=a+1. Operacja jest realizowana poprzez następujący kod:


static __inline__ void atomic_add(int i, atomic_t *v)
{
	__asm__ __volatile__(
		LOCK "addl %1,%0"
		:"=m" (v->counter)
		:"ir" (i), "m" (v->counter));
}

Procedura wykonuje zwykłe asemblerowe dodawanie i dla systemu jednoprocesorowego działa to dobrze. Działa to również dobrze dla systemu wieloprocesorowego dzięki makru LOCK, jakie poprzedza instrukcję addl, a które zdefiniowane jest na początku pliku:


#ifdef CONFIG_SMP
#define LOCK "lock ; "
#else
#define LOCK ""
#endif

Jeżeli jądro zostało skompilowane dla systemu wieloprocesorowego (Symmetrical multiprocessing), to makro LOCK definiowane jest jako lock ; i dodawane jest do instrukcji addl. Instrukcja lock jak zostało opisane wyżej, blokuje szynę danych, dzięki czemu addl wykonuje się niepodzielnie również na systemach wieloprocesorowych.
Analogiczne operacje są dostarczone do inkrementacji (incl) i zmniejszania wartości (decl) oraz odejmowania (subl) od licznika.
Dostarczone są również operacje pozwalające zmienić wartość i dokonać sprawdzenia czy osiągnięto zero po tej operacji:


static __inline__ int atomic_dec_and_test(atomic_t *v)
{
        unsigned char c;
        __asm__ __volatile__(
                LOCK "decl %0; sete %1"
                :"=m" (v->counter), "=qm" (c)
                :"m" (v->counter) : "memory");
	        return c != 0;
}	

Jedyną różnicą w stosunku do poprzedniego kodu jest instrukcja sete która sprawdza stan flagi ZF po wykonaniu instrukcji decl. Jeżeli osiągnięto zero po odjęciu, to sete zapisuje 1 do c, w przeciwnym wypadku w c ląduje 0. Istotne jest to, że nie następuje w drugiej instrukcji pobranie wartości tylko sprawdzenie flagi (której inny proces nie może zmienić), która została ustawiona w atomowej operacji.
Pozostałe operacje atomowej zmiany wartości i sprawdzenia nowej wartości wyglądają analogicznie.
Powyższe operacje zostały zapisane w asemblerze, aby mieć pewność (wynikającą z możliwości architektury), że dzięki wykorzystaniu dobrze określonych instrukcji asemblerowych, otrzymamy operacje niepodzielne. W samym języku C nie dałoby się tego wyrazić, bo nie mam żadnej wiedzy na temat tego czy dodawanie jest realizowane właśnie jako pojedyncze add oraz, że w przypadku wieloprocesorowym zostanie użyta instrukcja lock.


2. Operacje atomowe na SPARC-u

Na procesorach Intela, dzięki wsparciu sprzętowemu (instrukcja lock) kod atomowego dodawania i wszystkich innych atomowych operacji jest bardzo prosty i przejrzysty. Ale nie wszędzie sytuacja wygląda tak dobrze. Dla przykładu na maszynach SPARC nie ma wsparcia w postaci instrukcji analogicznej do Intelowego lock. Również sam asembler ma inną składnię, inne rejestry i oczywiście inny zestaw instrukcji niż wersja dla procesorów Intela. W związku z brakiem mechanizmu blokowania szyny danych, problem trzeba rozwiązać w inny sposób. Kod instrukcji atomic_add w include/asm-sparc/atomic.h wygląda następująco:


static __inline__ int __atomic_add(int i, atomic_t *v)
{
        register volatile int *ptr asm("g1");
        register int increment asm("g2");
	ptr = &v->counter;
        increment = i;
        __asm__ __volatile__(
        "mov    %%o7, %%g4\n\t"
        "call   ___atomic_add\n\t"
        " add   %%o7, 8, %%o7\n"
        : "=&r" (increment)
        : "0" (increment), "r" (ptr)
        : "g3", "g4", "g7", "memory", "cc");
        return increment;
}

To już wygląda mniej ciekawie niż wersja Intelowa, wymaga większej liczby rejestrów globalnych ogólnego przeznaczenia (g3, g4, g7) i co gorsza, woła jeszcze jakąś zewnętrzną funkcję ___atomic_add, którą można odnaleźć w arch/sparc/lib/atomic.S. Jako, że nie mamy do dyspozycji Intelowego locka, zatem wyłączane są przerwania i dodawanie do wartości atomowej obłożone jest spinlockiem. Fragment programu (dotyczący systemu wieloprocesorowego), realizujący procedurę ___atomic_add (w całości pisana w asemblerze) wygląda dość koszmarnie, jeżeli nie jest się przyzwyczajonym do składni tego asemblera:


	or      %g3, PSR_PIL, %g7       ! wyłączenie przerwań
	[...]	
#ifdef CONFIG_SMP
1:      ldstub  [%g1 + 3], %g7          ! załadowanie slowa z blokadą
					! (test-and-set)
	orcc    %g7, 0x0, %g0           ! czy udało się przejąć blokadę
	bne     1b                      ! jeżeli nie to skok do 1:
	ld      [%g1], %g7              ! załadowanie słowa atomowego
	add     %g7, %g2, %g2           ! dodanie wartości do słowa
	st      %g7, [%g1]              ! zapisanie bajtu - zwolnienie blokady
	[...]

Patrząc na kod Intelowy i SPARC-owy raczej ciężko dojść do wniosku, że realizują tę samą teoretycznie prostą czynność. Podobne warianty procedur atomowych są stworzone dla wszystkich innych architektur, które wspiera Linux.

Dla wszystkich architektur jest również dostępny podobny zestaw operacji atomowych działający na bitach wartości atomowej, szczegóły w pliku bitops.h.


3. Spinlock

Omawiany kod źródłowy znajduje się w kodzie źródłowym jądra w include/asm-i386/spinlock.h

Realizacja ochrony sekcji krytycznej w systemie wieloprocesorowym, gdzie nie wystarcza już zwykłe wyłączenie przerwań, może być w Linuksie realizowana poprzez mechanizm spinlock'ów. Zasadniczo dostępne są dwa rodzaje spinlock'ów - zwykłe, blokujące sekcję krytyczną dla jednego procesu i read-write, umożliwiające odczyt wielu procesom i zapis tylko dla jednego (jest jeszcze trzeci rodzaj, ale jego użycie jest bardzo ograniczone). W dalszej części opisana zostanie realizacja podstawowych spinlock'ów.
Jeżeli może wystąpić sytuacja wyścigu z procedurami obsługi przerwań, wtedy należy używać spinlock'a, wzbogaconego o wyłączanie przerwań na czas oczekiwania na blokadzie.
W pliku include/linux/spinlock.h zdefiniowane są operacje na spinlock'u, które w miarę potrzeb są zmieniane w plikach specyficznych dla danej architektury. W tym pliku można znaleźć definicję makra spin_lock_irqsave:

#define spin_lock_irqsave(lock, flags) do { local_irq_save(flags); spin_lock(lock); } while (0)

Definicję makra local_irq_save odnaleźć można w pliku include/asm-i386/system.h:

#define local_irq_save(x) __save_and_cli(x)

które z kolei jest zdefiniowane następująco:

#define __save_and_cli(x) do { __save_flags(x); __cli(); } while(0);

a te makra w końcu rozwijane są do konkretnego kodu:

#define __save_flags(x) __asm__ __volatile__("pushfl ; popl %0":"=g" (x): /* no input */)
#define __cli() __asm__ __volatile__("cli": : :"memory")

Instrukcja pushfl wysyła 32-bitowe słowo, zawierające flagi na stos, a następnie słowo to jest pobierane ze stosu i zapisywane na zmienną. Instrukcja cli powoduje wyłączenie obsługi przerwań.
Po zapisaniu flag i wyłączeniu przerwań można przejść do zasadniczej części, czyli do blokady spinlock:


static inline void spin_lock(spinlock_t *lock)
{
	[...]
        __asm__ __volatile__(
                spin_lock_string
                :"=m" (lock->lock) : : "memory");
}

spinlock_t to struktura zawierająca licznik:


typedef struct {
	volatile unsigned int lock;
	[...]
} spinlock_t;

I ostatecznie rozwijane jest makro spin_lock_string czyli zasadnicza część spinlock'a:


1. #define spin_lock_string \
2.        "\n1:\t" \
3.        "lock ; decb %0\n\t" \
4.        "js 2f\n" \
5.        LOCK_SECTION_START("") \
6.        "2:\t" \
7.        "cmpb $0,%0\n\t" \
8.        "rep;nop\n\t" \
9.        "jle 2b\n\t" \
10.       "jmp 1b\n" \
11.       LOCK_SECTION_END

Makra LOCK_SECTION_START i LOCK_SECTION_END tworzą oznaczenia i nazwy sekcji.
W linii 3. w sposób atomowy zmniejszana jest wartość licznika w strukturze spinlock_t. Jeżeli znak jest ujemny (4.) to wykonywany jest skok do przodu (2f - forward) do etykiety 2: (6.).
Jeżeli jesteśmy w 6. to znaczy, że nie udało się przejąć blokady. W instrukcji 7. porównywana jest aktualna wartość licznika spinlock'a z zerem. Jeżeli wartość nie jest dodatnia to następuje skok w tył do etykiety 2:. Jeżeli natomiast wartość licznika jest dodatnia (a dokładnie równa 1), to znaczy, że pojawiła się szansa na przejęcie blokady i następuje skok w tył do etykiety 1:, gdzie nasz proces ponownie stoczy bitwę o spinlock'a.

Oddanie spinlock'a jest znacznie prostsze. Jeżeli zapamiętane zostały flagi i wyłączone przerwania, to teraz należy to odkręcić:

#define spin_unlock_irqrestore(lock, flags) do { spin_unlock(lock); local_irq_restore(flags); } while (0)

Wywołanie spin_unlock jak poprzednio zawiera asemblerowe makro:


static inline void spin_unlock(spinlock_t *lock)
{
	[...]
	__asm__ __volatile__(
                spin_unlock_string
        );
}

#define spin_unlock_string \
        "movb $1,%0" \
		:"=m" (lock->lock) : : "memory"	

Jedyną czynnością jest zapisanie 1 do wartości licznika spinlock'a, dzięki czemu inny proces może przejąć blokadę.
local_irq_restore(flags) rozwijane jest do funkcji __global_restore_flags(flags), która w zależności od stanu flag podanych w parametrze wykonuje cli lub sti, w zależności od tego jak flaga IF była ustawiona przed naszymi operacjami na spinlock'u.
Ponownie asembler musiał być użyty w implementacji spinlock'a głównie z powodu braku możliwości wyrażenia specyficznych operacji sprzętowych w C, takich jak atomowy zapis i odczyt spinlock'a (w przeciwnym przypadku do sekcji krytycznej mogłoby wejść wiele procesorów).


4. Start systemu

Start systemu rozpoczyna się od BIOS'u. BIOS wybiera urządzenie, z którego uruchomiony zostanie system oraz ładuje pierwszy sektor z urządzenia (bootsector rozmiaru 512b) do pamięci (pod adres 0x7c00). Dalej kontrolę przejmuje program z bootsectora, który ładuje kolejne części programu uruchamiającego (setup), procedury do dekompresji oraz skompresowany obraz jądra Linuksa, który jest dekompresowany już w trybie chronionym. Początek całego procesu jest realizowany przez kod asemblerowy, dalsze części inicjalizacji są zapisane w C.
Zaprezentowane niżej początkowe fragmenty inicjalizacji pochodzą z: arch/i386/boot/bootsect.S
Bootsector ładowany przez BIOS może być bootsector'em Linuxa (np. ładowanie z dyskietki), LILO, można też ładować system przez loadlin. Przyjrzymy się trochę bliżej pierwszemu wariantowi (z ładowaniem z dyskietki dla uproszczenia).
Ważne rzeczy dzieją się już na samym początku kodu załadowanego do pamięci przez BIOS:


        movw    $BOOTSEG, %ax
        movw    %ax, %ds                
        movw    $INITSEG, %ax
        movw    %ax, %es            
        movw    $256, %cx
        subw    %si, %si
        subw    %di, %di
        cld
        rep
        movsw
        ljmp    $INITSEG, $go

$BOOTSEG to oryginalny adres, pod którym załadowany jest program z bootsectora (0x7c00). Pierwszą rzeczą, którą program chce wykonać, to przeniesienie siebie w inne miejsce w pamięci (do $INITSEG = 0x90000) aby móc zapisać swój kod, kod dalszej części uruchamiania systemu (setup) oraz programów pomocniczych w spójnym fragmencie pamięci. W tym celu rejestr ds, będący źródłem dla instrukcji movs jest ustawiany na początek programu startowego w pamięci, a rejestr es jako cel kopiowania ustawiany jest na początek obszaru, do którego chcemy program załadować. Kopiowanie przenosi za jednym razem 2 bajty, a łącznie trzeba przekopiować 512 bajtów (rozmiar bootsectora), zatem trzeba skopiować 256 dwubajtowych słów, co ostatecznie wykonuje rep movsw po czym następuje daleki skok do offsetu $go w segmencie $INITSEG, czyli kolejne instrukcje pobierane są już z obszaru, do którego skopiowany został program z bootsectora od etykiety go:.
Działamy w trybie rzeczywistym, zatem do poprawnego działania potrzebny jest stos trybu rzeczywistego, który trzeba ręcznie ustawić.


go:     movw    $0x4000-12, %di         
	movw    %ax, %ds  
	movw    %ax, %ss
	movw    %di, %sp   

Wartość 0x4000-12 jest dobrana tak, żeby to wszystko grało, tj. żeby była większa od sumy długości bootsectora, dalszego programu uruchamiającego (setup) i żeby zostało trochę miejsca na stos.
Warto zwrócić uwagę, że różnego rodzaju przypisania są optymalizowane aż do bólu, czyli kosztem wysokiej nieczytelności kodu są wycinane zbędne przypisania, np. w kodzie powyżej w ax jest wartość $INITSEG, wstawiona tam w poprzedniej części kodu.
W dalszej części kodu program próbuje za pomocą funkcji BIOS'u 0x13 odczytać pierwszy sektor i ustalić liczbę sektorów:


	movw    $disksizes, %si   
probe_loop:
        lodsb
	cbtw   
        movw    %ax, sectors
        cmpw    $disksizes+4, %si
	jae     got_sectors  

	xchgw   %cx, %ax	
	xorw    %dx, %dx  	# dysk i głowica równe 0
	movw    $0x0200, %bx    # 512 bajtów
	movw    $0x0201, %ax    # 1 sektor, funkcja 2 przerwania 0x13
	int     $0x13		
	jc      probe_loop        

Po załadowaniu pierwszego sektora wypada poinformować użytkownika, że coś się już wydarzyło i będzie się działo dalej, zatem wypisywany jest komunikat "Loading" używając przerwania $0x10.


got_sectors:
	movb    $0x03, %ah    
        xorb    %bh, %bh
        int     $0x10
        movw    $9, %cx
	movb    $0x07, %bl  
        movw    $msg1, %bp
	movw    $0x1301, %ax      
	int     $0x10 

	[...]
msg1:           .byte 13, 10 		# zdefiniowane na końcu pliku
                .ascii "Loading"		

Dalszym etapem jest załadowanie dalszej części programu uruchamiającego system (czyli setup) od razu za programem z bootsectora. Program z bootsectora umiesczony był pod adresem 0x90000 i mial 512 bajtów, zatem dalszą część programu należy umieścić w pamięci, rozpoczynając od adresu 0x90200 oraz ładowany jest skompresowany obraz jądra do odpowiedniego miejsca w pamięci (zależnie od rozmiaru może być umieszczony pod jednym z dwóch adresów). Kod realizujący tę część zawiera sporo zawiłości technicznych, np. żeby uwzględnić liczbę sektorów, jaką ustaliliśmy przed chwilą i sporo innych i nie będziemy go tutaj dokładniej omawiać.
W zależności od liczby sektorów na dyskietce ustalane jest główne urządzenie z obrazem jądra:


        movw    root_dev, %ax
        orw     %ax, %ax
        jne     root_defined

        movw    sectors, %bx
        movw    $0x0208, %ax            # /dev/ps0 - 1.2Mb
        cmpw    $15, %bx
        je      root_defined

        movb    $0x1c, %al              # /dev/PS0 - 1.44Mb
        cmpw    $18, %bx
        je      root_defined

        movb    $0x20, %al              # /dev/fd0H2880 - 2.88Mb
        cmpw    $36, %bx
        je      root_defined

	movb    $0, %al                 # /dev/fd0 - autodetect
root_defined:
        movw    %ax, root_dev

Ustalony typ urządzenia zapisywany jest w root_dev.
W tym momencie mamy w pamięci załadowany kod dalszego programu uruchamiającego system (setup), skompresowany obraz jądra w ustalonym miejscu i znane jest urządzenie główne. Zatem można przejść do wykonywania kodu setup:


        ljmp    $SETUPSEG, $0

Dalszy kod startujący system można znaleźć w pliku arch/i386/boot/setup.S.
W części setup system próbuje odczytać (z BIOS'u i stosując przeróżne sztuczki) dane o systemie - pamięć, informacje o dyskach, ustawia opóźnienie klawiatury, inicjuje kartę graficzną, szuka myszy, sprawdza czy BIOS obsługuje APM itd. Na końcu następuje przejście do trybu chronionego.
W dalszej części wywoływana jest funkcja startup_32() z pliku arch/i386/boot/compressed/head.S, której zadaniem jest m.in. ustawienie stosu oraz zdekompresowanie obrazu jądra.
Żeby sprawa wyglądała jeszcze bardziej przejrzyście, to w pliku o tej samej nazwie ale w innym katalogu arch/i386/kernel/head.S istnieje kolejna funkcja startup_32(), która z kolei przygotowuje środowisko dla pierwszego procesu Linuksa.
W ostatniej fazie wywoływana jest funkcja start_kernel(), która już jest pisana w języku C, a która rozpoczyna się wypisaniem komunikatu "Linux version 2.4.22..." i wykonywana jest dalsza inicjalizacja systemu.
Startowanie systemu jest klasycznym przykładem kodu systemu operacyjnego, którego nie da się zapisać w języku wysokiego poziomu i korzystanie z asemblera jest koniecznością, żeby móc wykonać takie rzeczy jak przeniesienie się do innego miejsca w pamieci, skoki sterowania programu do konkretnych miejsc w pamięci czy w końcu przełączenie z trybu rzeczywistego w tryb chroniony, który zupełnie zmienia sposób dostępu do pamięci.


5. Specyficzne możliwości procesorów

Jednym z możliwych pól zastosowania asemblera, jest korzystanie ze specyficznych właściwości sprzętu, np. kart graficznych lub procesorów. W Linuksie można również znaleźć przykłady takiego zastosowania asemblera, aczkolwiek nie są to bardzo często spotykane fragmenty.
Kilka pomocniczych funkcji zostało zapisanych z użyciem specyficznych dla pewnych procesorów instrukcji 3DNow, przykładem może być funkcja zastępująca w pewnych przypadkach standardowe memcpy. W include/asm-i386/page.h odnaleźć można następujące definicje:


#ifdef CONFIG_X86_USE_3DNOW

#define copy_page(to,from)      mmx_copy_page(to,from)

#else

#define copy_page(to,from)      memcpy((void *)(to), (void *)(from),
PAGE_SIZE)

#endif

#define copy_user_page(to, from, vaddr) copy_page(to, from)

Makro copy_user_page jest ostatecznie rozwijane albo do standardowego memcpy albo do mmx_code_page jeżeli pozwala na to konfiguracja systemu.
Właściwą funkcję mmx_copy_page (a właściwie jedną z dwóch - znów w zależności od typu procesora są dwie funkcje do wyboru) można oszukać w arch/i386/lib/mmx.c:


void mmx_copy_page(void *to, void *from)
{
        if(in_interrupt())
                slow_copy_page(to, from);
        else
                fast_copy_page(to, from);
}

Funkcja slow_copy_page jest mało interesująca bo nie zawiera instrukcji specyficznych dla maszyn z 3DNow. Natomiast w fast_copy_page już można zobaczyć zupełnie nowe instrukcje:


static void fast_copy_page(void *to, void *from)
{
        int i;
        kernel_fpu_begin();
        __asm__ __volatile__ (
                "1: prefetch (%0)\n"
                "   prefetch 64(%0)\n"
                "   prefetch 128(%0)\n"
                "   prefetch 192(%0)\n"
                "   prefetch 256(%0)\n"
                "2:  \n"
                ".section .fixup, \"ax\"\n"
                "3: movw $0x1AEB, 1b\n" /* jmp on 26 bytes */
                "   jmp 2b\n"
                ".previous\n"
                ".section __ex_table,\"a\"\n"
                "       .align 4\n"
                "       .long 1b, 3b\n"
                ".previous"
                : : "r" (from) );

	for(i=0; i<4096/64; i++)
        {
                __asm__ __volatile__ (
                "1: prefetch 320(%0)\n"
                "2: movq (%0), %%mm0\n"
                "   movq 8(%0), %%mm1\n"
                "   movq 16(%0), %%mm2\n"
                "   movq 24(%0), %%mm3\n"
                "   movq %%mm0, (%1)\n"
                "   movq %%mm1, 8(%1)\n"
                "   movq %%mm2, 16(%1)\n"
                "   movq %%mm3, 24(%1)\n"
                "   movq 32(%0), %%mm0\n"
                "   movq 40(%0), %%mm1\n"
                "   movq 48(%0), %%mm2\n"
                "   movq 56(%0), %%mm3\n"
                "   movq %%mm0, 32(%1)\n"
                "   movq %%mm1, 40(%1)\n"
                "   movq %%mm2, 48(%1)\n"
                "   movq %%mm3, 56(%1)\n"
                ".section .fixup, \"ax\"\n"
                "3: movw $0x05EB, 1b\n" /* jmp on 5 bytes */
                "   jmp 2b\n"
                ".previous\n"
                ".section __ex_table,\"a\"\n"
                "       .align 4\n"
                "       .long 1b, 3b\n"
                ".previous"
                : : "r" (from), "r" (to) : "memory");
                from+=64;
                to+=64;
        }
        kernel_fpu_end();
}

Bez zbytniego zagłębiania się w kod można zauważyć pojawienie się instrukcji prefetch, która ściąga z wyprzedzeniem obszary pamięci do cache'a, żeby przyspieszyć proces kopiowania pamięci. Dalej w pętli procedura wykonuje szereg instrukcji movq, które za jeden z argumentów przyjmują 64-bitowe rejestry mmx, tzw. quadword. W jednym obrocie pętli kopiowane są od razu 64 bajty, co znacznie przyspiesza standardowe kopiowanie obszarów pamięci.
Zatem w Linuksie nie dość, że jest konieczność zapisywania pewnych fragmentów kodu w asemblerze, aby kod mógł się wykonać na konkretnych architekturach, to również zdarzają się przypadki wykorzystywania właściwości konkretnych procesorów z danej architektury.

6. Przełączanie kontekstu

Task State Segment (TSS) w procesorach intel

Architektura intel 80x86 zawiera specjalny segment tss (task state segment). Przechowuje on kontekst obecnie działającego procesu. Każdy proces ma swój własny segment tss o minimalnej wielkości 104 bajtów. Dodatkowe bajty są potrzebne systemowi operacyjnemu do przechowywania rejestrów, nie zachowanych automatycznie przez sprzęt i do przechowywania dodatkowych informacji(mapy bitowej uprawnień i/o).
Segment tss ma postać:

tss
offset górne słowo dolne słowo
00h zarezerwowane link
04h esp0
08h zarezerwowane ss0
0ch esp1
10h zarezerwowane ss1
14h esp2
18h zarezerwowane ss2
1ch cr3
20h eip
24h eflags
28h eax
2ch ecx
30h edx
34h ebx
38h esp
3ch ebp
40h esi
44h edi
48h zarezerwowane es
4ch zarezerwowane cs
50h zarezerwowane ss
54h zarezerwowane ds
58h zarezerwowane fs
5ch zarezerwowane gs
60h zarezerwowane ldtr
64h offset mapy i/o (iobp) zarezerwowane
68h opcjonalne dane systemu
iopb-20h opcjonalna mapa przekierowania przerwań
iopb opcjonalna mapa dostępnych portów i/o

Specjalny rejestr procesora tr (task register) przechowuje selektor do aktywnego segmentu tss. Na jego podstawie procesor dokonuje uaktualnienia danych w tss.

Do załadowania nowego selektora do rejestru tr służy instrukcja ltr. Jej parametrem może być rejestr albo komórka pamięci.


Dla symetrii oprócz instrukcji ltr istnieje instrukcja str. Umożliwia ona odczytanie selektora z rejestru tr.


Makrodefinicja switch_to

Makro switch_to wykonuje przełączanie procesów. Przede wszystkim używa dwóch parametrów oznaczanych jako prev i next: pierwszy z nich jest wskažnikiem do deskryptorów procesu, który ma być uśpiony, a drugi jest wskažnikiem do deskryptora procesu, który ma być wykonywany przez procesor. Makro to jest wywoływane przez funkcję schedule().

Makro switch_to() jest jedną z najbardziej zależnych od procesora procedur jądra. oto opis czynności wykonywanych w przypadku procesorów intel.

Funkcja __switch_to operuje na parametrach prev i next, które oznaczają poprzedni i nowy proces. Funkcja __switch_to jest zdefiniowana w pliku nagłowkowym include/asm-i386/system.h. Funkcja ta dopełnia działanie rozpoczęte przez makro switch_to(). Zawiera włączony do kodu c kod asemblera, który może być nieczytelny.

Struktury danych i kod switch_to()

W pliku include/linux/sched.h znajduje się pole odpowiadające strukturze, która to zawiera informacje przechowywane w tss


struct task_struct {
...
/* CPU-specific state of this task */
	struct thread_struct thread;
}

W pliku include/asm-i386/processor.h znajduje się definicja struct thread_struct


struct thread_struct {
	unsigned long	esp0;
	unsigned long	eip;
	unsigned long	esp;
	unsigned long	fs;
	unsigned long	gs;
/* Hardware debugging registers */
	unsigned long	debugreg[8];  /* %%db0-7 debug registers */
...
/* floating point info */
	union i387_union	i387;
/* virtual 86 mode info */
	struct vm86_struct	* vm86_info;
	unsigned long		screen_bitmap;
	unsigned long		v86flags, v86mask, v86mode, saved_esp0;
/* IO permissions */
	int		ioperm;
	unsigned long	io_bitmap[IO_BITMAP_SIZE+1];
};

|ostatni | |następny | |początek|

Kod makra switch_to():



|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|


|poprzedni | |następny | |początek|
Wykonuje funkcje __switch_to():


|poprzedni | |następny | |początek|

		     "1:\t"						\
		     "popl %%ebp\n\t"					\
		     "popl %%edi\n\t"					\
		     "popl %%esi\n\t"					\


|poprzedni | |następny | |początek|
I gotowe. Zakończyliśmy przełączanie kontekstu.

IV. Literatura

1. Źródła jądra Linuksa, a w szczególności: 2. Understanding the Linux Kernel 2.4
3. Linux Kernel 2.4 Internals
4. http://www.linuxassembly.org
5. Using Assembly Language in Linux
6. Manuale do procesorów Intela
7. Manual do GCC

Krzysiek Szatyński | Andrzej Kurach