Jak pisać programy w języku asembler pod Linuksem?

Część 10 - Nie jesteśmy sami, czyli jak łączyć asemblera z innymi językami

Jak wiemy, w asemblerze można napisać wszystko. Jednak nie zawsze wszystko trzeba pisać w tym języku. W tej części pokażę, jak asemblera łączyć z innymi językami. Są na to 2 sposoby:

Postaram się z grubsza omówić te dwa sposoby na przykładzie języków Pascal, C i Fortran 77. Uprzedzam jednak, że moja znajomość języka Pascal i narzędzi związanych z tym językiem jest słaba.



Pascal


(przeskocz Pascala)

Wstawki asemblerowe realizuje się używając słowa asm. Oto przykład:

	{ Linux używa składni AT&T do asemblera - jak zauważycie,
	 argumenty instrukcji są odwrócone. }

	program pas1;

	begin
 	 asm movl $4,%eax
 	 end;
	end.

Można też stosować nieco inny sposób - deklarowanie zmiennej reprezentującej rejestry procesora. Poniższy wycinek kodu prezentuje to właśnie podejście (wywołuje przerwanie 13h z AH=48h, DL=80h, DS:DX wskazującym na obiekt a):

	uses crt,dos;

	Var
	   regs: Registers;

	BEGIN
	   clrscr();
	   With regs DO
	     Begin
	       Ah:=$48;
	       DL:=$80;
	       DS:=seg(a);
	       DX:=ofs(a);
	     End;

	       Intr($13,regs);

Teraz zajmiemy się bardziej skomplikowaną sprawą - łączenie modułów napisanych w Pascalu i asemblerze. Pascal dekoruje nazwy zmiennych i procedur, dorabiając znak podkreślenia z przodu. Jakby tego było mało, do nazwy procedury dopisywana jest informacja o jej parametrach. Tak więc z kodu

	var
 	 c:integer;
 	 d:char;

	procedure aaa(a:integer;b:char);

otrzymujemy symbole: _C, _D oraz _AAA$INTEGER$CHAR.

Oprócz tego, zwykle w Pascalu argumenty na stos szły od lewej do prawej, ale z tego co widzę teraz, to Free Pascal Compiler działa odwrotnie - argumenty idą na stos wspak. W naszym przykładzie najpierw na stos pójdzie zmienna typu char, a potem typu integer (obie rozszerzone do rozmiaru DWORDa).

Jedno jest pewne: jeżeli Twoja procedura jest uruchamiana z programu napisanego w Pascalu, to Ty sprzątasz po sobie stos - należy przy wyjściu z procedury wykonać   RET liczba, gdzie liczba to rozmiar wszystkich parametrów włożonych na stos (wszystkie parametry są rozmiaru co najmniej DWORD).
Jeśli to Ty uruchamiasz procedury napisane w Pascalu, to nie musisz się martwić o zdejmowanie parametrów ze stosu.

Samo dołączanie modułów odbywa się na linii poleceń, najlepiej w tym celu użyć linkera (po uprzednim skompilowaniu innych modułów na pliki obiektowe).




C i C++


(przeskocz C i C++)

Wstawki asemblerowe zaczynają się słowami __asm ( a kończą nawiasem zamykającym ). W Linuksie wyglądają one nieco dziwnie i to nie tylko ze względu na odwrotną składnię

AT&T:
        __asm ("movl $4,%eax\n"
                "movb $0xff,%bl\n");

Jak widać, po każdej instrukcji trzeba dać znak przejścia do nowej linii (w jednej linii może być tylko 1 instrukcja asemblera). Można dorzucić też znak tabulacji \t.

Wygląd bloków __asm jest złożony. Po szczegóły odsyłam do stron przeznaczonych temu zagadnieniu. W szczególności, możecie poczytać podręcznik GCC (sekcje: 5.34 i 5.35), strony DJGPP oraz (w języku polskim) stronę pana Danileckiego.

U siebie też mam krótkie porównanie tych składni.

W C i C++ można, podobnie jak w Pascalu, deklarować zmienne reprezentujące rejestry procesora. Plik nagłówkowy BIOS.H (niestety tylko w Windows) oferuje nam kilka możliwości. Oto przykład:

	#include <bios.h>
	...

	REGS rejestry;
	...
		rejestry.x.ax = 0x13;
		rejestry.h.bl = 0xFF;
		int86 (0x10, rejestry, rejestry);

Łączenie modułów jest prostsze niż w Pascalu. Język zwykle C dekoruje nazwy, dodając znak podkreślenia z przodu, ale nie w Linuksie, gdzie po prostu nic nie jest dorabiane.
W Linuksie deklaracja funkcji zewnętrznej wygląda po prostu tak:

extern void naszafunkcja (int parametr, char* parametr2);

UWAGA - w języku C++ sprawy są trudniejsze nawet niż w Pascalu. Dlatego, jeśli chcemy, aby nazwa naszej funkcji była niezmieniona (poza tym, że ewentualnie dodamy podkreślenie z przodu) i jednocześnie działała w C++, zawsze przy deklaracji funkcji w pliku nagłówkowym, należy dodać  extern "C", na przykład

	#ifdef __cplusplus
	extern "C" {
	#endif

	extern void naszafunkcja (int parametr, char* a);

	#ifdef  __cplusplus
	}
	#endif

W systemach 32-bitowych parametry przekazywane są OD PRAWEJ DO LEWEJ, czyli pierwszy parametr (u nas powyżej: int) będzie włożony na stos jako ostatni, czyli będzie najpłycej, a ostatni (u nas: char*) będzie najgłębiej.

W systemach 64-bitowych sprawa wygląda trudniej: parametry, w zależności od klasy, są przekazywane (od LEWEJ do PRAWEJ):

Dodatkowo, dla funkcji typu stdarg (inaczej: vararg), czyli dla takich z wielokropkiem w deklaracji, jak np. printf, rejestr AL zawiera liczbę rejestrów SSE zużytych na parametry.

W C/C++ to funkcja uruchamiająca zdejmuje włożone parametry ze stosu, a NIE funkcja uruchamiana.

Na systemach 32-bitowych parametry całkowitoliczbowe do 32 bitów zwracane są w rejestrze EAX (lub jego częściach: AL, AX, w zależności od rozmiaru), 64-bitowe w EDX:EAX, zmiennoprzecinkowe w ST0. Wskaźniki w 32-bitowych kompilatorach są 32-bitowe i są zwracane w EAX (w 16-bitowych zapewne w AX).
Struktury są wkładane na stos od ostatnich pól, a jeśli funkcja zwraca strukturę przez wartość, na przykład
struct xxx f ( struct xxx a )
to tak naprawdę jest traktowana jak taka funkcja:
void f ( struct xxx *tu_bedzie_wynik, struct xxx a )
czyli jako ostatni na stos wkładany jest adres struktury, do której ta funkcja ma włożyć strukturę wynikową.

Na systemach 64-bitowych sprawa ponownie wygląda inaczej. Tu także klasyfikuje się typ zwracanych danych, które są wtedy przekazywane:

Polecam do przeczytania x64 ABI (na przykład dokument x64-abi.pdf, do znalezienia w Internecie).

Dołączanie modułów (te napisane w asemblerze muszą być uprzednio skompilowane) odbywa się na linii poleceń, z tym że tym razem możemy użyć samego kompilatora (GCC), aby wykonał za nas łączenie (nie musimy uruchamiać linkera LD).

Teraz krótki 32-bitowy przykładzik (użyję NASMa i GCC):

	; NASM - casm1l.asm

	; use32 nie jest potrzebne w Linuksie, ale też nie zaszkodzi
	section .text use32

	global	suma

	suma:

	; po wykonaniu push ebp i mov ebp, esp:
	; w [ebp]    znajduje się stary EBP
	; w [ebp+4]  znajduje się adres powrotny z procedury
	; w [ebp+8]  znajduje się pierwszy parametr,
	; w [ebp+12] znajduje się drugi parametr
	; itd.

	%idefine	a	[ebp+8]
	%idefine	b	[ebp+12]

		push	ebp
		mov	ebp, esp

		mov	eax, a
		add	eax, b

	; LEAVE = mov esp, ebp / pop ebp
		leave
		ret

I jeszcze plik casml.c:

	#include <stdio.h>

	extern int suma (int a, int b);

	int c=1, d=2;

	int main()
	{
		printf("%d\n", suma(c,d));
		return 0;
	}

Kompilacja wygląda tak:

	nasm -f elf casm1l.asm
	gcc -o casm casml.c casm1l.o

Po uruchomieniu programu na ekranie pojawia się oczekiwana cyfra 3.

Jeśli NASM wyświetla ostrzeżenie odnośnie atrybutu use32 (np. Unknown section attribute 'use32' ignored on declaration of section `.text'), można go usunąć lub przenieść na górę pliku:

	use32
	section .text

Ten sam kod w wersji 64-bitowej (też dla NASMa):

	; NASM - casm1l.asm
	use64

	section .text

	global	suma

	suma:

	; po wykonaniu push rbp i mov rbp, rsp:
	; w [rbp]    znajduje się stary RBP
	; w [rbp+8]  znajduje się adres powrotny z procedury
	; w rdi  znajduje się pierwszy parametr całkowitoliczbowy,
	; w rsi  znajduje się drugi parametr całkowitoliczbowy,
	; w rdx  znajduje się trzeci parametr całkowitoliczbowy,
	; w rcx  znajduje się czwarty parametr całkowitoliczbowy,
	; w r8   znajduje się piąty parametr całkowitoliczbowy,
	; w r9   znajduje się szósty parametr całkowitoliczbowy,
	; w [rbp+16]  znajduje się pierwszy parametr wymagający stosu,
	; w [rbp+24] znajduje się drugi parametr wymagający stosu
	; itd.

	%idefine	a	rdi
	%idefine	b	rsi

		push	rbp
		mov	rbp, rsp

		mov	rax, a
		add	rax, b

	; LEAVE = mov rsp, rbp / pop rbp
		leave
		ret

I jeszcze plik casm1l.c:

	#include <stdio.h>

	extern long int suma (long int a, long int b);

	static long int c=1, d=2;

	int main()
	{
		printf("%ld\n", suma(c,d));
		return 0;
	}

Kompilacja wygląda tak:

	nasm -f elf64 casm1l.asm
	gcc -o casm casm1l.c casm1l.o

Po uruchomieniu programu na ekranie pojawia się oczekiwana cyfra 3.

Może się zdarzyć też, że chcemy tylko korzystać z funkcji języka C, ale główną część programu chcemy napisać w asemblerze. Nic trudnego: używane funkcje deklarujemy jako zewnętrzne, ale uwaga - swoją funkcję główną musimy nazwać main. Jest tak dlatego, że teraz punkt startu programu nie jest w naszym kodzie, lecz w samej bibliotece języka C. Program zaczyna się między innymi ustawieniem tablic argumentów listy poleceń i zmiennych środowiska. Dopiero po tych operacjach biblioteka C uruchamia funkcję main instrukcją CALL.

Inną ważną sprawą jest to, że naszą funkcję główną powinniśmy zakończyć instrukcją RET (zamiast normalnych instrukcji wyjścia z programu), która pozwoli przekazać kontrolę z powrotem do biblioteki C, umożliwiając posprzątanie (na przykład wyrzucenie buforów z wyświetlonymi informacjami w końcu na ekran).
Krótki (także 32-bitowy) przykładzik:

	section .text

	global main

	extern printf

	main:

		; printf("Liczba jeden to: %d\n", 1);
		push	dword 1		; drugi argument
		push	dword napis	; pierwszy argument
		call	printf		; uruchomienie funkcji
		add	esp, 2*4	; posprzątanie stosu

		; return 0;
		xor	eax, eax
		ret			; wyjście z programu

	section .data

	napis: db "Liczba jeden to: %d", 10, 0

Kompilacja powinna odbyć się tak:

	nasm -o casm2.o -f elf casm2.asm
	gcc -o casm2 casm2.o

64-bitowy przykładzik (też NASM):

	use64
	section .text

	global main

	extern printf

	main:
		; printf("Liczba jeden to: %ld\n", 1);
		xor	al, al		; liczba argumentów wymagających SSE
					; w funkcjach varargs
		mov	rsi, 1		; drugi argument
		mov	rdi, napis	; pierwszy argument

		call	printf		; uruchomienie funkcji
			; sprzątanie stosu niepotrzebne
			; add	rsp, 2*8

		; return 0;
		xor	rax, rax
		ret			; wyjście z programu

	section .data

	napis: db "Liczba jeden to: %ld", 10, 0

Kompilacja powinna odbyć się tak:

	nasm -o casm2l.o -f elf casm2l.asm
	gcc -o casm2l casm2l.o

Jedna uwaga: funkcje biblioteki C mogą zamazać nam zawartość wszystkich rejestrów (poza EBX, EBP, ESI, EDI w systemach 32-bitowych, i RBX, RBP, R12, R13, R14, R15 na systemach 64-bitowych), więc nie wolno nam polegać na zawartości rejestrów po uruchomieniu jakiejkolwiek funkcji C.




Fortran 77


(przeskocz Fortrana 77)

W tym języku nie wiem nic o wstawkach asemblerowych, więc przejdziemy od razu do łączenia modułów.

Fortran dekoruje nazwy, stawiając znak podkreślenia PO nazwie funkcji lub zmiennej (wyjątkiem jest funkcja główna - blok PROGRAM - która nazywa się MAIN__, z dwoma podkreśleniami).

Nie musimy pisać externów, ale jest kilka reguł przekazywania parametrów:

Na przykład, następujący kod:

        REAL FUNCTION aaa (a, b, c, i)

                CHARACTER a*(*)
                CHARACTER b*(*)
                REAL c
                INTEGER i

                aaa = c
        END

[...]
                CHARACTER x*8
                CHARACTER y*5
                REAL z,t
                INTEGER u

                t=aaa (x, y, z, u)
[...]

będzie przetłumaczony na asemblera tak (samo uruchomienie funkcji):

	push	5
	push	8
	push	u_	; adres, czyli offset zmiennej "u"
	push	z_
	push	y_
	push	x_

	call	aaa_

(to niekoniecznie musi wyglądać tak ładnie, gdyż zmienne x, y, u i z są lokalne w funkcji MAIN__, czyli są na stosie, więc ich adresy mogą wyglądać jak [ebp-28h] lub podobnie).

Funkcja uruchamiająca sprząta stos po uruchomieniu (podobnie jak w C).

Dołączać moduły można bezpośrednio z linii poleceń (w każdym razie kompilatorem F77/G77).

Podam teraz przykład łączenia Fortrana 77 i asemblera (użyję NASMa i F77):

	; NASM - asm1fl.asm

	section .text use32

	global	suma_

	suma_:

	; po wykonaniu push ebp i mov ebp, esp:
	; w [ebp]    znajduje się stary EBP
	; w [ebp+4]  znajduje się adres powrotny z procedury
	; w [ebp+8]  znajduje się pierwszy parametr,
	; w [ebp+12] znajduje się drugi parametr
	; itd.

	%idefine	a	[ebp+8]
	%idefine	b	[ebp+12]

		push	ebp
		mov	ebp, esp

	; przypominam, że nasze parametry są w rzeczywistości
	; wskaźnikami do prawdziwych parametrów

		mov	edx, a		; EDX = adres pierwszego parametru
		mov	eax, [edx]	; EAX = pierwszy parametr
		mov	edx, b
		add	eax, [edx]

	; LEAVE = mov esp, ebp / pop ebp
		leave
		ret

I teraz plik asmfl.f:

	PROGRAM funkcja_zewnetrzna

	INTEGER a,b,suma

	a=1
	b=2

	WRITE (*,*) suma(a,b)

	END

Po skompilowaniu:

	nasm -f elf asm1fl.asm
	g77 -o asmfl asmfl.f asm1fl.o

i uruchomieniu, na ekranie ponownie pojawia się cyfra 3.

I ponownie, jeśli NASM wyświetla ostrzeżenie odnośnie atrybutu use32, można go usunąć lub przenieść do osobnej linii na górę pliku:

	use32
	section .text

W wersji 64-bitowej obowiązują powyższe reguły przekazywania parametrów, a pozostałe reguły są takie same, jak dla języka C.

Powyższy program w wersji 64-bitowej wyglądałby to tak:

	; NASM - asm2fl.asm

	use64
	section .text

	global	suma_

	suma_:

	; po wykonaniu push rbp i mov rbp, rsp:
	; w [rbp]    znajduje się stary RBP
	; w [rbp+8]  znajduje się adres powrotny z procedury
	; w rdi  znajduje się pierwszy parametr całkowitoliczbowy,
	; w rsi  znajduje się drugi parametr całkowitoliczbowy,
	; w rdx  znajduje się trzeci parametr całkowitoliczbowy,
	; w rcx  znajduje się czwarty parametr całkowitoliczbowy,
	; w r8   znajduje się piąty parametr całkowitoliczbowy,
	; w r9   znajduje się szósty parametr całkowitoliczbowy,
	; w [rbp+16]  znajduje się pierwszy parametr wymagający stosu,
	; w [rbp+24] znajduje się drugi parametr wymagający stosu
	; itd.

	%idefine	a	rdi
	%idefine	b	rsi

		push	rbp
		mov	rbp, rsp

	; przypominam, że nasze parametry są w rzeczywistości
	; wskaźnikami do prawdziwych parametrów

		mov	rdx, a		; EDX = adres pierwszego parametru
		mov	rax, [rdx]	; EAX = pierwszy parametr
		mov	rdx, b
		add	rax, [rdx]

	; LEAVE = mov rsp, rbp / pop rbp
		leave
		ret

I teraz plik asm2fl.f:

	PROGRAM funkcja_zewnetrzna

	INTEGER a,b,suma

	a=1
	b=2

	WRITE (*,*) suma(a,b)

	END

Po skompilowaniu (NASM i GNU Fortran):

	nasm -f elf64 asm2fl.asm
	gfortran -o asm2fl asm2fl.f asm2fl.o

i uruchomieniu, na ekranie też pojawia się cyfra 3.




Inne języki

Co do innych języków, jeśli kompilator posiada taką opcję, można spróbować wygenerować kod asemblerowy i z niego dowiedzieć się, jaka jest umowa (konwencja) przekazywania parametrów, np. dla kompilatora GNU C:

	gcc -S plik.c

Można też poszukać takich informacji w Internecie.



Informacji podanych w tym dokumencie NIE należy traktować jako uniwersalnych, jedynie słusznych reguł działających w każdej sytuacji. Aby uzyskać kompletne informacje, należy zapoznać się z dokumentacją posiadanego kompilatora.



Poprzednia część kursu (klawisz dostępu 3)
Kolejna część kursu (klawisz dostępu 4)
Spis treści off-line (klawisz dostępu 1)
Spis treści on-line (klawisz dostępu 2)
Ułatwienia dla niepełnosprawnych (klawisz dostępu 0)



Ćwiczenia

  1. Napisz plik asemblera, zawierający funkcję obliczania reszty z dzielenia dwóch liczb całkowitych. Następnie, połącz ten plik z programem napisanym w dowolnym innym języku (najlepiej w C/C++, gdyż jest najpopularniejszy) w taki sposób, by Twoją funkcję można było uruchamiać z tamtego programu. Jeśli planujesz łączyć asemblera z C, upewnij się że Twoja funkcja działa również z programami napisanymi w C++.