Asembler w linuksie

prezentacja z Systemów Operacyjnych

Autorzy: Karolina Taborek, Grzegorz Olędzki, Michał Wesołowski, Kuba Żytka
Zgodne z HTML 4.01


Składnia

W Linuksie używana jest składnia asemblerowa AT&T. Przedstawimy ją poprzez porównanie z bardziej znaną składnią opracowaną przez firmę Intel. Wszystko, co jest opisywane poniżej odnosi się do tylko do architektury i386.

Podstawowe instrukcje zapisane w składni Intela

Różnice między składnią AT&T a składnią Intela

Funkcje systemowe

  1. Liczba argumentów jest mniejsza od 6, wówczas numer funkcji systemowej ustawiany jest w rejestrze %eax, a argumenty kolejno w %ebx, %ecx, %edx, %esi, %edi. Wynik funkcji systemowej zwracany jest do %eax.
  2. Liczba argumentów jest większa od 5, w tym przypadku numer funkcji również znajduje się w %eax, natomiast argumenty są umieszczane w pamięci, a wskaźnik do pierwszego z nich znajduje się w %ebx. Jeśli używamy stosu, to kładziemy na niego argumenty w odwrotnej kolejności, po czym wskaźnik stosu kopiujemy do %ebx. W przeciwnym przypadku, argumenty kopiujemy do zaalokowanej pamięci i adres pierwszego z nich zapisujemy w %ebx.
  3. Wywołanie funkcji systemowej Po odpowiednim ustawieniu rejestrów, funkcję wywyołuje się poprzez przerwanie:
    	int $0x80
    
    Pełną listę numerów funkcji systemowych można znaleźć pod adresem: www.linuxassembly.org/syscall.html

Asembler wbudowany w GCC

Asembler wbudowany w (inline assembler) GCC umożliwia używanie asemblera w połączeniu z kodem napisanym w C. Deklaracja wstawki asemblerowej ma następującą postać:

	__asm__(lista rozkazów : zmienne wyjściowe : zmienne wejściowe : modyfikowane wartości);

Zilustrujmy to prostym przykładem, obliczenie sumy trzech składników:

	int asm_suma(int składnik1, int składnik2, int składnik3)
	{
	int suma;

	__asm__("addl %%ebx,%%eax\n\t"
	        "addl %%ecx,%%eax"

	: "=a" (suma)

	: "a" (składnik1), "b" (składnik2), "c" (składnik3)

	: "ax","bx", "cx");

	return suma;

}

W powyższym przykładzie argumenty składnik1, składnik 2 i składnik3 są wczytywane odpowiednio do rejestrów eax, ebx, ecx, które są symbliozowane przez "a", "b", "c". Wynik otrzymany w rejestrze eax ma być przypisany na zmienną suma. "=a" (suma) oznacza suma=a. W modify podano "ax", "bx", "cx", ponieważ rejestry ax, bx, cx ulegają zmianie.

Do zmiennych możemy odwoływać się za pomocą %i. Przy równoczesnym użyciu zmiennych wejściowych i wyjściowych, obowiązuje następująca reguła ich numerowania:

	od %0 do %K - to są zmienne wyjściowe
	od %K+1 do %N - to są zmienne wejściowe.

Jeśli używamy zmiennych wejściowych, wyjściowych lub modyfikujemy rejestry, to nazwy rejestrów na liście rozkazów muszą być poprzedzone dodatkowym znakiem %. Dzieje się tak dlatego, iż w przeciwnym przypadku, gcc rozpoznający %0, %1, itd, napotykając na przykład %edx zinterpretowałby to jako parametr %e, co prowadziłoby do błędu.

Istnieje możliwość użycia rejestru jednocześnie jako wejściowego i wyjściowego. Zilustrujmy to na przykładzie:

	#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
	type name (type1 arg1, type2 arg2, type3 arg3, type4 arg4) \
	{ \
	long __res; \
	__asm__ volatile ("int $0x80" \
	        : "=a" (__res) \
	        : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
		 "d" ((long)(arg3)),"S" ((long)(arg4))); \
	__syscall_return(type,__res); \
	}

Użycie oznaczenia "=a" w zmiennych wyjściowych powoduje, że wartość powrotna funkcji systemowej, która jest przechowywana w %eax, jest wkładana do zmiennej __res. Dzięki użyciu oznaczenia "0" jako pierwszego w sekcji danych wejściowych, numer funkcji systemowej _NR_##name jest wkładany do %eax, skąd pobierany jest przy przerwaniu. W ten sposób %eax służy tutaj zarówno jako rejestr wejściowy i wyjściowy. Nie są do tego wykorzystywane żadne oddzielne rejestry. Zauważmy, że wejściowy _NR_##name jest użyty zanim wyprodukowana jest wartość zwracana.

Do zabezpieczenia kodu zawartego we wstawce asemblerowej przed zmianami (zmiany takie mogą mieć miejsce przy włączonej opcji optymalizowania kodu) służy słowo kluczowe __volatile__, jakiego należy użyć po __asm__.


Przykłady

Po omówieniu składni asemblera, przedstawimy teraz kilka fragmentów kodu jądra wykorzystujących tego asemblera w praktyce. Postaramy się też do każdego fragmentu podać powody, dla których kod nie został napisany w C, tylko w asemblerze.

makro current

Kod tego makra wygląda następująco:

	1 static inline struct task_struct * get_current(void)
	2 {
	3        struct task_struct *current;
	4        __asm__("andl %%esp,%0; ":"=r" (current) : "" (~8191UL));
	5        return current;
	6 }
	8
	9 #define current get_current()

Makro to służy do szybkiego dostawania się do deskryptora aktualnego procesu. Korzysta ono z tego, że w trakcie wykonywania się jakiegoś procesu w trybie jądra, wskaźnik stosu, który jest trzymany w rejestrze esp, wskazuje na wierzchołek stosu trybu jądra aktulnego procesu.

Zauważmy, że stos trybu jądra i struktura task_struct przechowywane są w 8KB strukturze task_union, przy czym struktura task_struct znajduje się na początku tego 8KB obszaru. Widać więc, że aby dostać się do task_struct aktulanego procesu, wystarczy zamaskować ostatnich 13 bitów w rejestrze esp.

Dokonujemy tego poprzez wykonanie instrukcji andl dla rejestru esp i pierwszego argumentu, którym jest ~8191UL. Liczba 8191 w systemie dwójkowym składa się z 13 jedynek, zatem ~8191UL na trzynastu najmniej znaczących bitach ma zera, a na pozostałych jedynki.

current

W powyższym przykładzie asembler użyty jest po to, aby jak najbardziej zoptymalizować czas dostępu do deskryptora aktulnego procesu, co jest tym bardziej istotne, że ten fragment kodu wywoływany jest dosyć często. Inną zaletą tego sposobu dostawania sie do deskrytora, jest brak konieczności pamiętania dodatkowego wskaźnika.

funkcja kernel_thread

Mimo tego, że Linux jest monolitycznym systemem opercyjnym, istnieje kilka "wątków jądra" np: keventd czy kswapd.

Wątki te tworzone są za pomocą funkcji kernel_thread:

	0   int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
	1   {
	2           long retval, d0;
	3
	4          __asm__ __volatile__(
	5                   "movl %%esp,%%esi\n\t"
	6                   "int $0x80\n\t"         /* Linux/i386 system call */
	7                   "cmpl %%esp,%%esi\n\t"  /* child or parent? */
	8                   "je 1f\n\t"             /* parent - jump */
	9                   /* Load the argument into eax, and push it.  That way, it does
	10                   * not matter whether the called function is compiled with
	11                   * -mregparm or not.  */
	12                  "movl %4,%%eax\n\t"
	13                  "pushl %%eax\n\t"
	14                  "call *%5\n\t"          /* call fn */
	15                  "movl %3,%0\n\t"        /* exit */
	16                  "int $0x80\n"
	17                  "1:\t"
	18                  :"=&a" (retval), "=&S" (d0)
	19                  :"" (__NR_clone), "i" (__NR_exit),
	20                   "r" (arg), "r" (fn),
	21                   "b" (flags | CLONE_VM)
	22                  : "memory");
	23         return retval;
	24  }

W linii 4 powyżego kodu pojawiło się słowo kluczowe __volatile__. Zabezpiecza ono kod zawarty we wstawce assemblerowej przed zmianami. Przy włączonej opcji optymalizowania kodu, kompilator mógłby sprawić, że kod nie pojawiłby się dokładnie w miejscu wyznaczonym przez programistę, a gdy używa się wstawki assemblerowej w C lub C++, to na ogół nie jest dobrze, jeśli kompilator coś poprzestawia.

W linii 5: "movl %%esp,%%esi\n\t", w rejestrze esi zapamiętywana jest wartość z wierzchołka stosu.

Następnie przerwanie w linii 6 powoduje wywołanie funkcji systemowej. Jest to funkcja clone(), ponieważ w rejestrze eax znajduje się _NR_clone. Załadowanie tego numeru do eax odbywa się w linii 15: "" (__NR_clone). Zauważmy też, że w ebx znajdują się argumenty dla tego wywołania czyli CLONE_VM | flags. CLONE_VM oznacza, że rodzic i potomek współdzielą deskryptor pamięci i wszystkie tablice stron.

Po powrocie z funkcji clone(), możemy znajdować się w w rodzicu lub w potomku i żeby to sprawdzić wykorzystamy skopiowaną do esi wartość z wierzchołka stosu sprzed wywołania funkcji systemowej.

W linii 7 wykonywane jest więc porównanie między wartościami w esi i edi. Jeżeli wartości te są równe, to znajdujemy się w rodzicu, a gdy są różne to w potomku.

Jeżeli znajdujemy się w rodzicu, to nic już nie trzeba robić, zatem wykonujemy jest skok w przód do etykiety 1 i działanie wstawki asemblerowej kończy się:

	7       "cmpl %%esp,%%esi\n\t"  /* child or parent? */
	8       "je 1f\n\t"             /* parent - jump */
	...
	17      "1:\t"
	18      : ...

Ciekawiej jest gdy wracamy z funkcji clone() jako potomek. Ponieważ chcemy aby nasze wątki jądra wykonywały jakąś konkretną pracę, trzeba jeszcze wywołać funkcję fn przekazaną jako parametr wejściowy. Realizowane jest to w liniach 12-14, gdzie na rejestr eax wstawiamy wskaźnik do argumentów funkcji fn, po czym odkładamy go na stos. Jest to związane z tym, że nie wiemy w jaki sposób była kompilowana funkcja fn, którą właśnie w linii 14 wywołujemy.

Po zakończeniu wykonywania funkcji fn, wywoływana jest funkcja exit(). Jest to realizowane analogicznie jak poprzednie wywołanie funkcji systemowej, a mianowicie do rejestru eax ładujemy czwartą zmienną czyli _NR_exit i zgłaszamy przerwanie:

	15      "movl %3,%0\n\t"        /* exit */
	16      "int $0x80\n"

W linii 18, w sekcji modify podano "memory", co informuje gcc o tym, że nie powinien wprowadzać optymalizacji polegającej na trzymaniu zmiennych w rejestrach, bo zawartość pamięci może ulec zmianie i zrobi się bałagan.

Funkcja kernel_thread została napisana w asemblerze ponieważ w Linuksie wywołanie systemowe musi być zgłoszone poprzez wykonanie instrukcji asemblera:

	int $0x80

Instrukcja ta zgłasza przerwanie systemowe o wektorze 128, za pomocą którego są realizowane wywołania systemowe w Linuxie.

atomowe operacje na bitach

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

Jak widać w powyższym fragmencie kodu preprocesor zamienia wszystkie wystąpienia LOCK_PREFIX na "lock ;" bądz pusty string.

Zamiana na "lock ;" odbywa się w przypadku architektury SMP, czyli wielu procesorów dzielących miedzy sobą zasoby. Jest to związane z synchronizacją procesorów. Instrukcja lock sprawia, że nastepująca po niej instrukcja (z tym, że nie każda, bo lock działa tylko dla niektórych) jest atomowa, a więc zasoby na których działa są chronione przed modyfikacją przez inne procesory (robione jest to poprzez zablokowanie szyny)..

Poniższe przykłady bedą używały makra LOCK_PREFIX w celu zapewnienia atomowści ich działania. Będą to funkcje wykorzystywane do synchronizacji (sekcje krytyczne, blokady i itp).

Zacznijmy od funkcji set_bit:

	1  static __inline__ void set_bit(int nr, volatile void * addr)
	2  {
	3          __asm__ __volatile__( LOCK_PREFIX
	4                  "btsl %1,%0"
	5                  :"=m" (ADDR)
	6                  :"Ir" (nr));
	7  }

Funkcja ta wykonuje właściwie tylko jedną instrukcję:

       btsl %2, %1

Jest to polecenie kopiowania bitu o numerze podanym w pierwszym operandzie z adresu z drugiego operandu do znacznika Carry, a następnie ustawienia bitu w miejscu z którego przed chwilą odbywalo się kopiowanie. Dzięki zastosowaniu LOCK_PREFIX jest to operacja niepodzielna. Przyjrzyjmy się teraz nieznacznie bardziej skomplikowanej funkcji test_and_set_bit:

	1  static __inline__ int test_and_set_bit(int nr, volatile void * addr)
	2  {
	3           int oldbit;
	4
	5           __asm__ __volatile__( LOCK_PREFIX
	6                   "btsl %2,%1\n\tsbbl %0,%0"
	7                  :"=r" (oldbit),"=m" (ADDR)
	8                  :"Ir" (nr) : "memory");
	9          return oldbit;
	10 }

Ta funkcja wykonuje już dwie instrukcje. Pierwsza jest identyczna do tej z funkcji set_bit, druga natomiast jest postaci:

       sbbl %0, %0

Jest to polecenie odejmowania z przeniesieniem czyli odejmuje od wartości z %0 sumę wartośći z %0 oraz znacznika Carry, a wynik zapisuje na %0. Tak więc w tym wypadku na %0 zostaje przypisane -1 gdy znacznik Carry był ustawiony na 1, albo 0 gdy był ustawiony na 0. Możliwość wykonania atomowej operacji btsl, w odniesieniu do całej funkcji zapewnia, że inne procesory nie mogą nam zepsuć obliczeń, ponieważ operację sbbl wykonujemy już na własnych rejestrach (%0 w sekcji wejściowej jest przypisywany do któregoś z rejesetrów: eax, ebx, ecx lub edx).

W pliku include/asm/i-386/bitops.h znajdują się definicje funkcji analogicznych do wyżej przedstawionych tzn. clear_bit, test_and_clear_bit, change_bit, test_and_change_bit. Powodem zastosowania asemblera przy ich implementacji jest oprócz wydajności, to że muszą one być atomowe.

semafory

Czasami potrzeba jest zagwarantowania, że pewne instrukcje wykonają się niepodzielnie. Dobrym przykladem wykorzystania niepodzielnych instrukcji są semafory.

Na PW czasem zakładaliśmy, że niektóre operacje na semaforach są niepodzielne (albo atomowe) - tzn. nic nie przerwie w środku ich wykonania.

Popatrzmy w kod <asm/semaphore.h>.

Niepodzielność zapisywana jest przy pomocy makr Gdy instrukcja jest prosta:

LOCK "<instrukcja>"

A w przypadku złożonych:

LOCK_SECTION_START(..)
<instrukcje>
LOCK_SECTION_END

Wracając do semaforów. I tak, np. instrukcja podniesienia semafora (up):

Gdyby operacja zwalniania nie była realizowana atomowo, to mogłoby się zdarzyć (tzn. mógłby się zdarzyć taki przelot), że zwolnimy więcej niż jednego czekającego. My potrzebujemy zwolnić jednego i tylko jednego - czyli między zwolnieniem czekającego a opuszczeniem ponownym semafora nie może się zdarzyć nic innego związanego z semaforem.

Jak jest zapewniana atomowość? Trzeba zobaczyć &lt;asm/atomic.h&gt;, który definiuje operacje atomowe. Widać jak zdefiniowane jest makro LOCK jako dodanie prefiksu lock dodawanego do instrukcji, który informuje proces, że na czas realizacji instrukcji ma zamknąć szynę. (więcej o prefiksach można znaleźć tutaj). Po takim zdefiniowaniu makra LOCK, pozostaje tylko zdefiniować operacje takie operacje atomowe jak:

Oczywiście można by nie definiować funkcji atomic_set, ale są one wygodnym skrótem dla pisania wywołania makra LOCK "addl %1,%0" czy podobnych... (poza taką instrukcją jeszcze następuje obsługa parametru i wyniku).

kopiowanie bloku danych

Czasami stosuje się asembler, kiedy wiadomo, że pewne instrukcje będą wykonywane bardzo częste i dlatego zależy nam na szybkości ich wykonania.

Dobrym przykładem jest tutaj plik kopiowanie bloków pamięci. (np. wykorzystywana w trzecim programie zaliczeniowym - moduł - funkcja copy_from_user czy copy_to_user). Można oczywiście ręcznie kopiować przy pomocji pętli

 for (i=start, i&lt;koniec; i++)
   pamiecUZYT[offU+i]=pamiecJADRA[offK+i].

Ale funkcje napisane w asemblerze (takie jak copy_to_user czy memcpy) zrobią to znacznie szybciej, dzięki bezpośredniemu dostępowi do sprzętu.

I tak np. wywołanie funkcji memcpy, w architekturze wspierającej MMX, jest tłumaczone na specjalną instrukcję MMX, która kopiuje blok pamięci.
patrz: &lt;asm/string.h&gt; (linie 199, 290)
oraz: &lt;asm/mmx.h&gt;.

Teraz porównajmy działanie dwóch programów.

p1.c:

#define rozmiar    10000000

typedef int tablica;

int main()
{
 char *pam, *pam2;
 int i;

 pam=(char *)malloc(rozmiar);		 pam2=(char *)malloc(rozmiar);
 
 for (i=0; i&lt;rozmiar; i++) 
   pam[i]=pam2[i];

 free(pam); free(pam2);	return 0;
}

p2.c:

#define rozmiar    10000000

typedef int tablica;

int main()
{
 char *pam, *pam2;
 int i;

 pam=(char *)malloc(rozmiar);		 pam2=(char *)malloc(rozmiar);

 memcpy(pam2,pam,rozmiar); 
 
 free(pam); free(pam2);	 return 0;
}

Co porównując (u mnie na komputerze) daje takie rezultaty:

[mccartney@130-moc-6 proba]$ time ./p1
0.06user 0.05system 0:00.12elapsed 88%CPU (0avgtext+0avgdata 0maxresident)k
0inputs+0outputs (74major+4895minor)pagefaults 0swaps
[mccartney@130-moc-6 proba]$ time ./p2
0.02user 0.02system 0:00.04elapsed 95%CPU (0avgtext+0avgdata 0maxresident)k
0inputs+0outputs (75major+4895minor)pagefaults 0swaps

Przełączanie kontekstu (switch_to)

W procesorach x86 istnieje sprzętowy mechanizm przełączania kontekstu. Nie jest on jednak wykorzystywany, jako że sprawia problemy przy przełączaniu się do innego zadania, gdy zapisany stan zadania nie jest poprawny (np. nieaktualna jest zawartość rejestrów segmentowych). Dlatego w linuksie przełączanie zadań jest realizowane programowo. Za przełączanie kontekstu odpowiedzialna jest funkcja switch_to (tak naprawdę wywołuje ona tylko funkcję __switch_to - i to tą drugą funkcją się zajmiemy). Oto diagram tej funkcji:
switch_to
Funkcja ta wygląda jak napisana w czystym c, lecz w rzeczywistości korzysta z wielu makr asemblerowych. Co ciekawe, owe makra przekładają się na kod w dziwny sposób. Dobrym przykładem jest tu makro loadsegment. W źródłach zajmuje 15 linii kodu, z których większośc jest niezrozumiała i nie pojawia się w żaden sposób w kodzie po skompilowaniu:

84 #define loadsegment(seg,value)                  \
85         asm volatile("\n"                       \
86                 "1:\t"                          \
87                 "movl %0,%%" #seg "\n"          \
88                 "2:\n"                          \
89                 ".section .fixup,\"ax\"\n"      \
90                 "3:\t"                          \
91                 "pushl $0\n\t"                  \
92                 "popl %%" #seg "\n\t"           \
93                 "jmp 2b\n"                      \
94                 ".previous\n"                   \
95                 ".section __ex_table,\"a\"\n\t" \
96                 ".align 4\n\t"                  \
97                 ".long 1b,3b\n"                 \
98                 ".previous"                     \
99                 : :"m" (*(unsigned int *)&(value)))

W skompilowanym kodzie widoczne jest tylko przypisanie z linii 87, czyli:

mov fs, [ebx+0Ch]
mov gs, [ebx+10h]  ; wywolanie makra loadsegment po raz drugi.

Instrukcje z linii 91 i 92 maja ten sam efekt, co instrukcja z linii 87, to znaczy do danego rejestru wprowadzą żądaną wartość. To że znikają po skompilowaniu jest bardzo dziwne, bo teoretycznie kompilator nie ma prawa ich usunąć (dyrektywa volatile). Zwróćmy jeszcze uwagę na deklarację parametru tego makra: (*(unsigned int *)&(value))). Jest to wskaznik do elementu value, zrzutowany na wskaźnik do inta bez znaku, a następnie wzięta wartość tegoż inta. Jest to troche niezrozumiałe, bo przecież można zrzutować value bezpośrednio na unsigned int. Ponadto rzutowanie nie jest konieczne, bo argumenty tego makra są typu unsgined int. Być może jest to jakieś obejście buga w gcc, a być może wymuszenie pewnego sposobu optymalizacji - nie wiadomo.

Wywołanie funkcji systemowej, czyli sys_call:

Kod odpowiedzialny za wywoływanie funkcji systemowej znajduje się w pliku arch/i386/kernel/entry.S. Kod ten jest funkcją obsługi przerwania 0x80, czyli własnie wywołania funkcji systemowej. Ze względu na użycie w funkcji kilku makr, oraz żeby pokazać ciekawą technikę obchodzenia się z kodem przedstawię teraz rozwiniętą (bez makr) wersję kodu, którą uzyskałem disasemblując skompilowany kernel, dodatkowo rozrysowany jako schemat blokowy, po spojrzeniu na który można łatwo zorientować się, co tak naprawdę robi nasza funkcja:
syscall

Po pierwsze, zapamiętujemy na stosie stan rejestrów. Po drugie, do ebx ładujemy wskaźnik do task_structa zadania. Następnie, sprawdzamy pole [ebx+18], czyli task_struct->ptrace. Jeśli pole to jest ustawione, to przed wywołaniem żądanej funkcji wołamy funkcję systemową trace. Oczywiście po drodze jest dokonywane sprawdzenie, czy żądana funkcja systemowa istnieje (cmp eax, 100). Następnie blokujemy przerwania sprzętowe, i jeśli trzeba (flaga task_struct->need_resched ustawiona) to wołamy schedulera. Jeśli nie mamy do obsłużenia żadnego sygnału, to kończymy procedure obsługi przerwania - zdjemujemy co nasze ze stosu, iret i do domu.

Gdy na obsługę czeka jakiś sygnał (flaga task_struct->sigpending jest ustawiona) to przedy zakończeniem pracy wykonujemy jeszcze procedure obsługi tegoż sygnału. Dla porównania - kod źródłowy:

87 #define SAVE_ALL \
88         cld; \
89         pushl %es; \
90         pushl %ds; \
91         pushl %eax; \
92         pushl %ebp; \
93         pushl %edi; \
94         pushl %esi; \
95         pushl %edx; \
96         pushl %ecx; \
97         pushl %ebx; \
98         movl $(__KERNEL_DS),%edx; \
99         movl %edx,%ds; \
100         movl %edx,%es;
101
102 #define RESTORE_ALL     \
103         popl %ebx;      \
104         popl %ecx;      \
105         popl %edx;      \
106         popl %esi;      \
107         popl %edi;      \
108         popl %ebp;      \
109         popl %eax;      \
110 1:      popl %ds;       \
111 2:      popl %es;       \
112         addl $4,%esp;   \
113 3:      iret;           \
...
...
202 ENTRY(system_call)
203         pushl %eax                      # save orig_eax
204         SAVE_ALL
205         GET_CURRENT(%ebx)
206         testb $0x02,tsk_ptrace(%ebx)    # PT_TRACESYS
207         jne tracesys
208         cmpl $(NR_syscalls),%eax
209         jae badsys
210         call *SYMBOL_NAME(sys_call_table)(,%eax,4)
211         movl %eax,EAX(%esp)             # save the return value
212 ENTRY(ret_from_sys_call)
213         cli                             # need_resched and signals atomic test
214         cmpl $0,need_resched(%ebx)
215         jne reschedule
216         cmpl $0,sigpending(%ebx)
217         jne signal_return
218 restore_all:
219         RESTORE_ALL
220
221         ALIGN
222 signal_return:
223         sti                             # we can get here from an interrupt handler
224         testl $(VM_MASK),EFLAGS(%esp)
225         movl %esp,%eax
226         jne v86_signal_return
227         xorl %edx,%edx
228         call SYMBOL_NAME(do_signal)
229         jmp restore_all
230 
231         ALIGN
232 v86_signal_return:
233         call SYMBOL_NAME(save_v86_state)
234         movl %eax,%esp
235         xorl %edx,%edx
236         call SYMBOL_NAME(do_signal)
237         jmp restore_all
238 
239         ALIGN
240 tracesys:
241         movl $-ENOSYS,EAX(%esp)
242         call SYMBOL_NAME(syscall_trace)
243         movl ORIG_EAX(%esp),%eax
244         cmpl $(NR_syscalls),%eax
245         jae tracesys_exit
246         call *SYMBOL_NAME(sys_call_table)(,%eax,4)
247         movl %eax,EAX(%esp)             # save the return value
248 tracesys_exit:
249         call SYMBOL_NAME(syscall_trace)
250         jmp ret_from_sys_call
251 badsys:
252         movl $-ENOSYS,EAX(%esp)
253         jmp ret_from_sys_call
254 
255         ALIGN
256 ENTRY(ret_from_intr)
257         GET_CURRENT(%ebx)
258 ret_from_exception:
259         movl EFLAGS(%esp),%eax          # mix EFLAGS and CS
260         movb CS(%esp),%al
261         testl $(VM_MASK | 3),%eax       # return to VM86 mode or non-supervisor?
262         jne ret_from_sys_call
263         jmp restore_all
264 
265         ALIGN
266 reschedule:
267         call SYMBOL_NAME(schedule)    # test
268         jmp ret_from_sys_call

Wejście w tryb chroniony, czyli asembler to za mało!

Jak wiemy, komputer po uruchomieniu pracuje w trybie rzeczywistym. Dlatego w momencie startu systemu, po wczytaniu do pamięci i rozpakowaniu kernela niezbęne jest przejście procesora do trybu chronionego i rozpoczęcie pracy kernela. Kod za to odpowiedzialny znajduje się w pliku /arch/i386/boot/setup.S. Nas interesują instrukcje począwszy od etykiety a20_done. Na początku załadowane zostają gdt i ldt, potem maskujemy przerwania i dochodzimy do najbardziej interesującej części kodu:

785 # Well, now's the time to actually move into protected mode. To make
786 # things as simple as possible, we do no register set-up or anything,
787 # we let the gnu-compiled 32-bit programs do that. We just jump to
788 # absolute address 0x1000 (or the loader supplied one),
789 # in 32-bit protected mode.
790 #
791 # Note that the short jump isn't strictly needed, although there are
792 # reasons why it might be a good idea. It won't hurt in any case.
793         movw    $1, %ax                         # protected mode (PE) bit
794         lmsw    %ax                             # This is it!
795         jmp     flush_instr
796 
797 flush_instr:
798         xorw    %bx, %bx                        # Flag to indicate a boot
799         xorl    %esi, %esi                      # Pointer to real-mode code
800         movw    %cs, %si
801         subw    $DELTA_INITSEG, %si
802         shll    $4, %esi                        # Convert to 32-bit pointer
803 # NOTE: For high loaded big kernels we need a
804 #       jmpi    0x100000,__KERNEL_CS
805 #
806 #       but we yet haven't reloaded the CS register, so the default size
807 #       of the target offset still is 16 bit.
808 #       However, using an operand prefix (0x66), the CPU will properly
809 #       take our 48 bit far pointer. (INTeL 80386 Programmer's Reference
810 #       Manual, Mixing 16-bit and 32-bit code, page 16-6)
811 
812         .byte 0x66, 0xea                        # prefix + jmpi-opcode
813 code32: .long   0x1000                          # will be set to 0x100000
814                                                 # for big kernels
815         .word   __KERNEL_CS

Zwróćmy uwagę na instrukcję lmsw z linii 794 - instrukcja ta, zgodnie z materiałami firmy Intel, instnieje jedynie w celu zachowania zgodnosci z procesorami 286. W procesorach 386 i nowszych załadowanie słowa stanu powinno być realizowane instrukcją mov cr0, ...

instrukcja skoku w linii 795, powoduje prawdopodobnie wyczyszczenie potoku wewnętrznie pobranych przez procesor instrukcji, co jest konieczne, ponieważ po przejściu w tryb chroniony adresy sa wyliczane w inny sposób (selektor:offset zamiast segment:offset) i mogą być nieprawidłowe. W nowszych procesorach chyba nie ma takiej konieczności, procesor sam to robi. (To dziwne, że wcześniej tego nie robił ;)

Ale naprawde tajemnicze rzeczy zaczynają dziać się dopiero w linii 812. Nagle ni stąd ni z owąd w kodzie pojawia się definicja kilku bajtów danych. Czym one są? Otóż jest to chwyt, który pozwala wykonać instrukcję 32 bitową w 16 bitowym segmencie kodu. Chwyt ten jest niezbędny, ponieważ adres początku kernela jest 48mio bitowy. Jak widać, czasami asembler to też za mało... :)