Jak pisać programy w języku asembler?

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:

	{ DOS/Windows }

	program pas1;

	begin
 		asm mov eax,4
 	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ącymi 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ę wyrażeniem asm { a kończą klamrą zamykającą } (ale NIE w gcc, o tym później). Przykład:

	asm {
		mov eax, 1
	}

Wszystkie nowe kompilatory produkują programy 32- lub 64-bitowe, przypominam więc, aby we wstawkach NIE używać przerwań ( DOS-a i BIOS-u w Windows).

W C i C++ można, podobnie jak w Pascalu, deklarować zmienne reprezentujące rejestry procesora. Plik nagłówkowy BIOS.H 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. Kompilatory języka C dla DOS dekorują nazwy, dodając znak podkreślenia z przodu.
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ą na stosie 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 (także od prawej do lewej):

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, aby wykonał za nas łączenie (nie musimy uruchamiać linkera).

No to krótki 32-bitowy przykładzik (użyję NASMa i Borland C++ Builder):

	; NASM casm1.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

		mov	eax, a
		add	eax, b

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

oraz plik casm.c:

	#include <stdio.h>

	extern int _suma (int a, int b); /* deklaracja funkcji zewnętrznej */

	int suma (int a, int b);	 /* prototyp funkcji */

	int c=1, d=2;

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

Kompilacja odbywa się tak:

	nasm -o casm1.obj -f obj casm1.asm
	bcc32 casm.c casm1.obj

Uwaga: w kompilatorach GNU: DJGPP, Dev-C++, MinGW, CygWin format wyjściowy NASMa powinien być ustawiony na COFF. Możliwe, że format COFF trzeba będzie wybrać także w innych.

W wyniku otrzymujemy programik, który na ekranie elegancko wyświetla wynik równy 3.

DOS nie uruchomi programów 64-bitowych, ale dla kompletności przedstawiam powyższy 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 long int suma (long long int a, long long int b);

	static long long int c=1, d=2;

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

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 (pamiętając o znaku podkreślenia), 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.obj -f obj casm2.asm
	bcc32 casm2.obj

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

	use64
	section .text

	global main

	extern printf

	main:
		; printf("Liczba jeden to: %lld\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: %lld", 10, 0

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.

Kompilator GNU gcc wymaga osobnego wytłumaczenia. Składnia wstawek asemblerowych różni się od powyższej dość znacznie, a jej opisy możecie znaleźć w podręczniku GCC (sekcje: 5.34 i 5.35), na stronach DJGPP oraz (w języku polskim) na stronie pana Danileckiego.

Jak zauważycie, różni się nawet sam wygląd instrukcji, gdyż domyślnie gcc używa składni AT&T języka asembler. U siebie mam krótkie porównanie tych składni.



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 77 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).
Dodatkowo, kompilatory języka Fortran 77 dla DOS dekorują nazwy, dodając znak podkreślenia z przodu (podobnie, jak w przypadku języka C).
Tak więc, pod DOS, funkcja główna nazywa się _MAIN__.

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_	; podkreślenie z przodu dla DOS, z tyłu - dla Fortrana

(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 pod Linuksem z kompilatorem F77/G77).

Podam teraz przykład łączenia Fortrana 77 i asemblera. W oryginale użyłem narzędzi Linuksowych: NASMa i F77, ale po minimalnych przeróbkach powinno to też działać pod Windows. Oto pliki:

	; NASM - asm1fl.asm
	section .text use32
	global	_suma_

	_suma_:		; podkreślenie z przodu dla DOS, z tyłu - dla Fortrana

	; 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 (ewentualnie zmieniając opcję -f u NASMa):

	nasm -f obj -o asm1fl.obj asm1fl.asm
	f77 -o asmfl.exe asmfl.f asm1fl.obj

i uruchomieniu na ekranie powinna ponownie pojawić się cyfra 3.

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.

Ponownie, dla kompletności, przedstawiam powyższy program w wersji 64-bitowej:

	; 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



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++.