Pisanie własnych bibliotek w języku asembler pod Linuksem

Pewnie zdarzyło się już wam usłyszeć o kimś innym:
Ależ on(a) jest świetnym(ą) programistą(ką)! Nawet pisze własne biblioteki!
Pokażę teraz, że nie jest to trudne, nie ważne jak przerażającym się to może wydawać. Osoby, które przeczytają ten artykuł i zdobędą troszkę wprawy będą mogły mówić:
Phi, a co to za filozofia pisać własne biblioteki!

Zacznijmy więc od pytania: co powinno się znaleźć w takiej bibliotece?
Mogą to być:

Co to zaś jest to owa biblioteka?
Jest to plik na który składa się skompilowany kod, a więc na przykład pliki .o. Sama biblioteka najczęściej ma rozszerzenie .a (gdy zawiera statyczny kod) lub .so.* (dla bibliotek współdzielonych). Biblioteka eksportuje na zewnątrz nazwy procedur w niej zawartych, aby linker wiedział, jaki adres podać programowi, który chce skorzystać z takiej procedury.

Będę w tym artykule używał składni i linii poleceń NASMa (Netwide Assembler) z linkerem LD i archiwizatorem AR.

Napiszmy więc jakiś prosty kod źródłowy. Oto on:


(przeskocz przykładowy moduł biblioteki)
;  Biblioteka Standardowa
;  Emisja dźwięku przez głośniczek
;  Autor: Bogdan Drozdowski, 09.2002
;  kontakt: bogdandr MAŁPKA op.pl
;  Wersja Linux: 05.02.2004
;  Ostatnia modyfikacja: 29.08.2004

%include "../incl/linuxbsd/nasm/n_system.inc"

global  _graj_dzwiek

KIOCSOUND       equ     0x4B2F

section .data

konsola		db	"/dev/console", 0

struc timespec
        .tv_sec:	resd 1
        .tv_nsec:	resd 1
endstruc

t1 istruc timespec
t2 istruc timespec

segment biblioteka_dzwiek


_graj_dzwiek:

;  graj

;  wejście:	BX = żądana częstotliwość dźwięku w Hz, co najmniej 19
;		CX:DX = czas trwania dźwięku w mikrosekundach
;
;  wyjście:	CF = 0 - wykonane bez błędów
;		CF = 1 - błąd:	 BX za mały

	pushfd
	push    eax
	push    ebx
	push    ecx
	push    edx
	push	esi

	cmp     bx, 19		;najniższa możliwa częstotliwość to ok. 18Hz
	jb      ._graj_blad

	push	ecx
	push	edx
	push	ebx

	mov     eax, sys_open	; otwieramy konsolę do zapisu
	mov     ebx, konsola
	mov     ecx, O_WRONLY
	mov     edx, 600q
	int     80h

        cmp     eax, 0
        jg      .otw_ok

        mov     eax, 1  	; jak nie otworzyliśmy konsoli,
        			; piszemy na standardowe wyjście
.otw_ok:
        mov     ebx, eax	; EBX = uchwyt do pliku
	mov	esi, eax	; ESI = uchwyt do pliku

	mov     eax, sys_ioctl	; sys_ioctl = 54
	mov     ecx, KIOCSOUND
	xor     edx, edx	; wyłączenie ewentualnych dźwięków
	int     80h

	pop	ebx		; BX = częstotliwość
	mov	eax, 1234ddh
	xor	edx, edx
	div	ebx		; EAX=1234DD/EBX - ta liczba idzie do ioctl

	mov     edx, eax
	mov	ebx, esi	; EBX = uchwyt do konsoli lub stdout
	mov     eax, sys_ioctl
	int     80h

	pop	edx
	pop	ecx

				; pauza o długości CX:DX mikrosekund:
	mov	eax, ecx
	shl	eax, 16
	mov	ebx, 1000000
	mov	ax, dx		; EAX = CX:DX
	xor	edx, edx
	div     ebx
	mov     [t1+timespec.tv_sec], eax ;  EAX = liczba sekund

	mov     ebx, 1000
	mov     eax, edx
	mul     ebx
	mov     [t1+timespec.tv_nsec], eax ;  EAX = liczba nanosekund

	mov     eax, sys_nanosleep
	mov     ebx, t1
	mov     ecx, t2
	int     80h		; robimy przerwę...


	mov	eax, sys_ioctl
	mov	ebx, esi	; EBX = uchwyt do konsoli/stdout
	mov	ecx, KIOCSOUND
	xor	edx, edx	; wyłączamy dźwięk
	int	80h

	cmp	ebx, 2		; nie zamykamy stdout
	jbe	._graj_koniec

        mov	eax, sys_close  ; sys_close = 6
        int	80h


._graj_koniec:
	pop	esi
	pop	edx
	pop	ecx
	pop	ebx
	pop	eax
	popfd
	clc			;  zwróć brak błędu

	ret


._graj_blad:
	pop	esi
	pop	edx
	pop	ecx
	pop	ebx
	pop	eax

	popfd
	stc			;  zwróć błąd

	ret

Jest to moja procedura wytwarzająca dźwięk w głośniczku (patrz mój artykuł o programowaniu głośniczka). Trochę tego jest, co? Ale jest tu dużo spraw, które można omówić.

Zacznijmy więc po kolei:

  1. global...

    Funkcje, które mają być widoczne na zewnątrz tego pliku, a więc możliwe do użycia przez inne programy, muszą być zadeklarowane jako public (w NASMie: global). Tutaj jest to na wszelki wypadek. Niektóre kompilatory domyślnie traktują wszystkie symbole jako publiczne, inne nie. Jeśli w programie będziemy chcieli korzystać z takiej funkcji, należy ją zadeklarować jako extrn (FASM) lub extern (NASM).

  2. Deklaracja segmentu

    Żaden przyzwoity kompilator nie pozwoli na pisanie kodu poza jakimkolwiek segmentem (no chyba, że domyślnie zakłada segment kodu, jak NASM). Normalnie, w zwykłych programach, rolę tę pełni dyrektywa section .text.

  3. Gwiazdki lub inne elementy oddzielające (tu usunięte)

    Mogą się wydawać śmieszne lub niepotrzebne, ale gdy liczba procedur w pliku zaczyna sięgać 10-20, to NAPRAWDĘ zwiększają czytelność kodu, oddzielając procedury, dane itd.

  4. Deklaracja procedury (wcześniej zadeklarowanej jako global)

    Znak podkreślenia z przodu jest tylko po to, by w razie czego nie był identyczny z jakąś etykietą w programie korzystającym z biblioteki.

  5. To, czego procedura oczekuje i to, co zwraca.

    Jedną procedurę łatwo zapamiętać. Ale co zrobić, gdy jest ich już 100? Analizować kod każdej, aby sprawdzić, co robi, bo akurat szukamy takiej jednej....? No przecież nie.

  6. Dobrą techniką programowania jest deklaracja stałych w stylu EQU (lub #define w C). Zamiast nic nie znaczącej liczby można użyć wiele znaczącego zwrotu, co przyda się dalej w kodzie. I nie kosztuje to ani jednego bajtu. Oczywiście, ukrywa to część kodu (tutaj: numery portów), ale w razie potrzeby zmienia się tę wielkość tylko w 1 miejscu, a nie w 20.

  7. Zachowywanie zmienianych rejestrów (push)

    Poza wartościami zwracanymi nic nie może być zmienione! Nieprzyjemnym uczuciem byłoby spędzenie kilku godzin przy odpluskwianiu (debugowaniu) programu tylko dlatego, że ktoś zapomniał zachować jakiegoś rejestru, prawda?

  8. Sprawdzanie warunków wejścia, czy są prawidłowe. Zawsze należy wszystko przewidzieć.

  9. Kod procedury. Z punktu widzenia tego artykułu jego treść jest dla nas nieistotna.

  10. Punkt(y) wyjścia

    Procedura może mieć dowolnie wiele punktów wyjścia. Tutaj zastosowano dwa, dla dwóch różnych sytuacji:

    1. parametr był dobry, procedura zakończyła się bez błędów

    2. parametr był zły, zwróć informację o błędzie

Mamy więc już plik źródłowy. Co z nim zrobić? Skompilować, oczywiście!

nasm -f elf naszplik.asm

(-f - określ format pliku wyjściowego: Executable-Linkable Format, typowy dla Linuksa)

Mamy już plik naszplik.o. W pewnym sensie on już jest biblioteką! I można go używać w innych programach, na przykład w pliku program2.asm mamy (FASM):

	...
	extrn _graj_dzwiek		; NASM: extern _graj_dzwiek

	...
	...
	mov bx,440
	mov cx,0fh
	mov dx,4240h
	call _graj_dzwiek
	...

I możemy teraz zrobić:

	nasm -f elf program2.asm
	ld -s -o program2 program2.o naszplik.o

a linker zajmie się wszystkim za nas - utworzy plik program2, zawierający także naszplik.o. Jaka z tego korzyść? Plik program2.asm może będzie zmieniany w przyszłości wiele razy, ale naszplik.asm/.o będzie ciągle taki sam. A w razie chęci zmiany procedury _graj_dzwiek wystarczy ją zmienić w jednym pliku i tylko jego ponownie skompilować, bez potrzeby wprowadzania tej samej zmiany w kilkunastu innych programach. Te programy wystarczy tylko ponownie skompilować z nową biblioteką, bez jakichkolwiek zmian kodu.

No dobra, ale co z plikami .a?
Otóż są one odpowiednio połączonymi plikami .o. I wszystko działa tak samo.

No ale jak to zrobić?
Służą do tego specjalne programy, w DOSie nazywane librarian (bibliotekarz). My tutaj użyjemy archiwizatora AR. Pliki .o, które chcemy połączyć w bibliotekę podajemy na linii poleceń:

	ar -r libasm.a plik1.o plik2.o

I otrzymujemy plik libasm.a, który można dołączać linkerem do programów:

	ld -s -o naszprog naszprog.o -L/ścieżka_do_pliku.a -lasm

lub:

	ld -s -o naszprog naszprog.o /ścieżka_do_pliku.a/libasm.a


Biblioteki współdzielone .so

Prawie wszystkie programy w Linuksie używają podstawowej biblioteki systemu - biblioteki języka C. Wyobrażacie sobie, ile miejsca w pamięci zajęłyby wszystkie używane kopie tej biblioteki? Na pewno niemało. A poradzono sobie z tym, tworząc specjalny rodzaj plików - bibliotekę współdzieloną, ładowaną i łączoną z programem dynamicznie (w chwili uruchomienia). Pliki te (o rozszerzeniu .so) są odpowiednikami plików DLL znanych z systemów Windows. Teraz pokażę, jak pisać i kompilować takie pliki. Wszystko to znajdziecie też w dokumentacji kompilatora NASM.

Reguły są takie:

  1. Dalej trzymajcie się wszystkich powyższych uwag do kodu (komentarze itp.).

  2. NIE możemy już się odwoływać normalnie do swoich własnych zmiennych!

    Dlaczego? Przyczyna jest prosta: biblioteki współdzielone są pisane jako kod niezależny od pozycji (Position-Independent Code, PIC) i po prostu nie wiedzą, pod jakim adresem zostaną załadowane przez system. Adres może za każdym razem być inny. Do swoich zmiennych musimy się więc odwoływać trochę inaczej, niż to było do tej pory. Do biblioteki współdzielonej linker dołącza strukturę Globalnej Tablicy Offsetów (Global Offset Table, GOT). Biblioteka deklaruje ją jako zewnętrzną i korzysta z niej do ustalenia adresu swojego kodu. Wystarczy wykonać call zaraz / zaraz: pop ebx i już adres etykiety zaraz znajduje się w EBX. Dodajemy do niego adres GOT od początku sekcji (_GLOBAL_OFFSET_TABLE_ wrt ..gotpc) i adres początku sekcji, otrzymując realny adres tablicy GOT + adres etykiety zaraz. Potem już tylko wystarczy odjąć adres etykiety zaraz i już EBX zawiera adres GOT. Do zmiennych możemy się teraz odnosić poprzez [ebx+nazwa_zmiennej].

  3. Kompilacja i łączenie.

    O ile kompilacja NASMem jest taka, jak zawsze, to łączenie programu jest zdecydowanie inne. Popatrzcie na opcje LD:

  4. Deklaracje zmiennych i funkcji globalnych.

    Każda funkcja, którą chcemy zrobić globalną (widoczną dla programów korzystających z biblioteki), musi być zadeklarowana nie tylko jako extern, ale musimy podać też, że jest to funkcja. Pełna dyrektywa wygląda teraz:

    		global nazwaFunkcji:function

    Przy eksportowaniu danych dodajemy słowo data i rozmiar danych, na przykład dla tablic:

    
    	global tablica1:data tablica1_dlugosc
    
    	tablica1:		resb	100
    	tablica1_dlugosc	equ	$ - tablica1
  5. Uruchamianie funkcji zewnętrznych (na przykład z biblioteki C)

    Sprawa jest już dużo prostsza niż w przypadku danych. Funkcję zewnętrzną deklarujemy oczywiście słowem extern, a zamiast call nazwaFunkcji piszemy

    		call nazwaFunkcji wrt ..plt

    PLT oznacza Procedure Linkage Table, czyli tablicę linkowania procedur (funkcji). Zawiera ona skoki do odpowiednich miejsc, gdzie znajduje się dana funkcja.

A oto gotowy przykład. Biblioteka eksportuje jedną funkcję, która po prostu wyświetla napis.


(przeskocz przykładową bibliotekę współdzieloną)
; Przykład linuksowej biblioteki współdzielonej .so
;
; Autor: Bogdan D., bogdandr (at) op.pl
;
; kompilacja:
;   nasm -f elf -o biblso.o biblso.asm
;   ld -shared -soname biblso.so.1 -o biblso.so.1 biblso.o


section	.text
extern	_GLOBAL_OFFSET_TABLE_	; zewnętrzny, uzupełniony przez linker
global	info:function		; eksportowana funkcja
;extern	printf			; funkcja zewnętrzna

; makro do pobierania adresu GOT; wynik w EBX.
%imacro	wez_GOT	0

	call	%%zaraz
	%%zaraz:
	pop	ebx
	add	ebx, _GLOBAL_OFFSET_TABLE_ + $$ - %%zaraz wrt ..gotpc
%endmacro

info:
				; zachowanie zmienianych rejestrów
	push	eax
	push	ebx
	push	ecx
	push	edx

	wez_GOT			; pobieramy adres GOT
	push	ebx		; zachowujemy EBX

	mov	eax, 4		; funkcja pisania do pliku
			; do ECX załaduj ADRES napisu (stad LEA a nie MOV)
	lea	ecx, [ebx + napis wrt ..gotoff]
	mov	ebx, 1		; plik = 1 = standardowe wyjście (ekran)
	mov	edx, napis_dl	; długość napisu
	int	80h		; wyświetl

; a tak uruchamiamy funkcje zewnętrzne:
	pop	ebx		; przywracamy EBX
	lea	ecx, [ebx + napis wrt ..gotoff]	; ECX = adres napisu
	push	ecx		; adres na stos (jak dla funkcji z C)
;	call	printf wrt ..plt	; uruchomienie funkcji
	add	esp, 4		; usunięcie argumentów ze stosu

				; przywracanie rejestrów
	pop	edx
	pop	ecx
	pop	ebx
	pop	eax

	xor	eax, eax	; funkcja zwraca 0 jako brak błędu
	ret

section .data

napis		db	"Jestem biblioteka wspoldzielona!", 10, 0
napis_dl	equ	$ - napis

Program sprawdzający, czy biblioteka działa jest wyjątkowo prosty: jedno uruchomienie funkcji z biblioteki i wyjście. Na uwagę zasługuje jednak ta długa linijka z uruchomieniem LD. Przyjrzyjmy się bliżej:


(przeskocz test biblioteki współdzielonej)
; Program testujący linuksową bibliotekę współdzieloną .so
;
; Autor: Bogdan D., bogdandr (at) op.pl
;
; kompilacja:
; nasm -f elf -o biblsotest.o biblsotest.asm
; ld -dynamic-linker /lib/ld-linux.so.2 -nostdlib \
;	-o biblsotest biblsotest.o biblso.so.1


section	.text
global	_start

extern	info

_start:

	call	info

	mov	eax, 1
	xor	ebx, ebx
	int	80h

Jeśli dostajecie błąd /usr/lib/libc.so.1: bad ELF interpreter: No such file or directory, to utwórzcie w katalogu /usr/lib (jako root) plik libc.so.1 jako dowiązanie symboliczne do libc.so i upewnijcie się, że plik /usr/lib/libc.so ma prawa wykonywania dla wszystkich.

Jeśli system nie widzi biblioteki współdzielonej (a nie chcecie jej pakować do globalnych katalogów jak /lib czy /usr/lib), należy ustawić dodatkową ścieżkę ich poszukiwania.
Ustawcie sobie zmienną środowiskową LD_LIBRARY_PATH tak, by zawierała ścieżki do Waszych bibliotek. Ja u siebie mam ustawioną LD_LIBRARY_PATH=$HOME:. , co oznacza, że poza domyślnymi katalogami, ma być przeszukany także mój katalog domowy oraz katalog bieżący (ta kropka po dwukropku), jakikolwiek by nie był.


Ładowanie bibliotek w czasie pracy programu

Gdy nasz program jest na sztywno (statycznie lub nie) łączony z jakąś biblioteką współdzieloną, to w trakcie jego uruchamiania system szuka pliku tej biblioteki, aby móc uruchomić nasz program. Jeśli system nie znajdzie biblioteki, to nawet nie uruchomi naszego programu. Czasem jednak chcemy mieć możliwość zareagowania na taki problem. Oczywiście, bez kluczowych bibliotek nie ma szans uruchomić programu, ale całą resztę można dość łatwo ładować w czasie działania programu. Daje to pewne korzyści:

  1. oszczędza pamięć - ładujemy tylko te biblioteki, których nam naprawdę potrzeba, a tuż po zakończeniu pracy z biblioteką, można zwolnić pamięć przez nią zajmowaną.
  2. daje możliwość reagowania na brak biblioteki - na przykład można wyświetlić komunikat, że niektóre funkcje programu będą niedostępne. Ale program może nadal działać i wykonać swoje zadanie.

Ładowanie bibliotek w czasie pracy programu polega na wykorzystaniu funkcji z biblioteki libdl. Konkretnie, użyjemy trzech funkcji:

  1. dlopen - otwiera i ładuje bibliotekę

    Przyjmuje ona dwa argumenty. Od lewej (ostatni wkładany na stos) są to: nazwa pliku biblioteki współdzielonej (razem ze ścieżką, jeśli jest w niestandardowej) oraz jedna z liczb: RTLD_LAZY (wartość 1), RTLD_NOW (wartość 2), RTLD_GLOBAL (wartość 100h). Określają one sposób dostępu do funkcji w bibliotece, odpowiednio są to:

    Funkcja dlopen zwraca (w EAX) adres załadowanej biblioteki, którego będziemy potem używać.

  2. dlsym - wyławia z biblioteki adres interesującej nas funkcji

    Ta funkcja też przyjmuje dwa argumenty. Od lewej (ostatni wkładany na stos) są to: adres biblioteki, który otrzymaliśmy od funkcji dlopen oraz nazwa funkcji, która nas interesuje jako łańcuch znaków.
    Funkcja dlsym zwraca nam (w EAX) adres żądanej funkcji.

  3. dlclose - zamyka załadowaną bibliotekę

    Jedynym argumentem tej funkcji jest adres biblioteki, który otrzymaliśmy od funkcji dlopen.

Jest też funkcja systemowa sys_uselib, ale jej dokumentacja jest skromna. W użyciu pewnie byłaby trudniejsza niż libdl.

Pora na przykładowy program. Jego zadaniem będzie załadować bibliotekę biblso.so.1, którą utworzyliśmy w poprzednim podrozdziale, oraz uruchomienie jej jedynej funkcji - info. Oto kod w składnie NASM:


(przeskocz program ładujący bibliotekę)
; Program korzystający z biblioteki współdzielonej tak, że
;	nie musi być z nią łączony
;
; Autor: Bogdan D., bogdandr (na) op . pl
;
; kompilacja:
;   nasm -f elf -o shartest.o shartest.asm
;   gcc -s -o shartest shartest.o -ldl

section .text

; będziemy korzystać z biblioteki języka C, więc nasza funkcja
;  główna musi się nazywać  main
global main

%define RTLD_LAZY	0x00001	; znajduj adres funkcji w chwili wywołania
%define RTLD_NOW	0x00002	; znajduj adres funkcji od razu, w czasie
				; ładowania biblioteki
%define RTLD_GLOBAL	0x00100	; czy symbole będą od razu widoczne

extern dlopen
extern dlsym
extern dlclose

main:

	push	dword RTLD_LAZY	; ładowanie na żądanie
	push	dword bibl	; adres nazwy pliku
	call	dlopen		; otwieramy bibliotekę
	add	esp, 2*4	; zwalniamy argumenty ze stosu

	test	eax, eax	; sprawdzamy, czy nie błąd (EAX=0)
	jz	.koniec

	mov	[uchwyt], eax	; zachowujemy adres biblioteki

	push	dword funkcja	; adres nazwy żądanej funkcji
	push	dword [uchwyt]	; adres biblioteki
	call	dlsym		; szukamy adresu
	add	esp, 2*4

	mov	[adr_fun], eax	; EAX = znaleziony adres
	call	eax		; uruchomienie bezpośrednie
	call	[adr_fun]	; uruchomienie pośrednie

	push	dword [uchwyt]	; adres biblioteki
	call	dlclose		; zwalniamy ją z pamięci
	add	esp, 1*4

.koniec:
	ret			; zakończenie funkcji main

section .data

bibl		db	"biblso.so.1", 0	; nazwa biblioteki
funkcja		db	"info", 0		; nazwa szukanej funkcji
uchwyt		dd	0
adr_fun		dd	0

Muszę wspomnieć o dwóch dość ważnych rzeczach.
Pierwszą jest sposób kompilacji. Skoro łączymy nasz program z biblioteką C, to nasza funkcja główna musi się teraz nazywać main, a NIE _start (gdyż funkcja _start już jest w bibliotece języka C). Kompilacja wygląda teraz tak, jak napisałem w programie:

nasm -f elf -o shartest.o shartest.asm
gcc -s -o shartest shartest.o -ldl

W tym przypadku kompilator GCC uruchamia za nas linker LD, który dołączy niezbędne biblioteki.

Drugą rzeczą jest domyślna ścieżka poszukiwania bibliotek współdzielonych. Jeśli nie chcecie zaśmiecać systemu (lub nie macie uprawnień), pakując swoje biblioteki do /lib czy /usr/lib, ustawcie sobie zmienną środowiskową LD_LIBRARY_PATH tak, by zawierała ścieżki do Waszych bibliotek. Ja u siebie mam ustawioną LD_LIBRARY_PATH=$HOME:. , co oznacza, że poza domyślnymi katalogami, ma być przeszukany także mój katalog domowy oraz katalog bieżący (ta kropka po dwukropku), jakikolwiek by nie był.



Spis treści off-line (Alt+1)
Spis treści on-line (Alt+2)
Ułatwienia dla niepełnosprawnych (Alt+0)