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:
; 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:
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).
Ż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
.
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.
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.
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.
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.
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?
Sprawdzanie warunków wejścia, czy są prawidłowe. Zawsze należy wszystko przewidzieć.
Kod procedury. Z punktu widzenia tego artykułu jego treść jest dla nas nieistotna.
Procedura może mieć dowolnie wiele punktów wyjścia. Tutaj zastosowano dwa, dla dwóch różnych sytuacji:
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
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:
Dalej trzymajcie się wszystkich powyższych uwag do kodu (komentarze itp.).
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].
O ile kompilacja NASMem jest taka, jak zawsze, to łączenie programu jest zdecydowanie inne. Popatrzcie na opcje LD:
-shared
Mówi o tym, że LD ma zbudować bibliotekę współdzieloną, zamiast zwyczajnego pliku wykonywalnego. LD zadba o wszystko, co trzeba (GOT itd).
-soname biblso.so.1
Nazwa biblioteki. Ale uwaga - NIE jest to nazwa pliku, tylko wewnętrzna nazwa samej biblioteki. Jak będziecie dodawać kolejne wersje, to nie zmieniajcie nazwy wewnętrznej, tylko nazwę pliku .so, a zróbcie dowiązanie symboliczne do tego pliku, z nazwą taką jak wewnętrzna nazwa biblioteki, na przykład waszabibl.so.1 jako link do waszabibl.so.1.1.5.
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
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.
; 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:
Mówi o nazwie programu, którego trzeba użyć do dynamicznego łączenia. Bez tej opcji
nasz program nie podziała i dostaniemy błąd
Accessing a corrupted shared library
Nie dołącza żadnych standardowych bibliotek.
Nazwy pliku wyjściowego i wejściowego.
Biblioteka, z którą należy połączyć ten program
; 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ł.
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:
Ładowanie bibliotek w czasie pracy programu polega na wykorzystaniu funkcji z biblioteki libdl. Konkretnie, użyjemy trzech funkcji:
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ć.
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.
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:
; 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ćmainglobal 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ł.