Łączenie programu Java i programu pisanego w asemblerze może mieć szereg różnych zalet. Część aplikacji pisana w Javie może odpowiadać na wysokopoziomowe aspekty jego działania, takie jak GUI, czy całą logikę, która nie zależy w żaden sposób od architektury na której uruchomiony jest program, natomiast część asemblerowa to część blisko związana z konkretną architekturą lub systemem, która może korzystać z systemowych rozwiązań, umyślnie ukrytych w Javie. Asembler oczywiście z powodzeniem można zamienić na język C czy C++, jednak z uwagi na ciekawość tematu zdecydowałem się jednak użyć asma na systemie Linux.

Aby połączyć oba języki w sposób, w którym by one ze sobą współpracowały, potrzeba zrobić tak naprawdę kilka rzeczy:
- - Napisać program w Javie i sprecyzować, które metody będą zaimplementowane w asemblerze,
- - Stworzyć interfejs, z którego dowiemy się odpowiednych rzeczy potrzebnych do spełnienia punktu 3,
- - Napisać te metody w asemblerze.
Proste :). Na początek program będzie ekstremalnie prosty. Warto zacząć od znanego przykładu Hello World, który będzie miał za zadanie wyświetlić na ekranie prosty napis za pomocą systemowej funkcji sys_write, z uwagi na jego prostotę jak i popularność. Po skończeniu tego programu będzie go można zmodyfikować na coś równie prostego, ale bardziej praktycznego - lister procesów systemowych pisany w języku Java, co będzie jednocześnie lepszym przykładem jeśli chodzi o ingerencję w struktury danych środowiska Java w samym asemblerze. Proces tworzenia takiego bardziej zaawansowanego, jednak nadal prostego, programu opiszę w części drugiej, w kolejnym poście. Zacznijmy więc od punktu pierwszego części pierwszej.
1. Napisać program w Javie.
Ten program to tylko kilka linijek, nie ma sensu się nad nim rozpisywać - jeśli kliknąłeś w Czytaj Dalej, to zapewne wiesz np. to, do czego służy metoda main :).
- public class Hello {
- public static void main(String[] args) {
- System.out.println("start");
- System.loadLibrary("native");
- (new Hello()).nativeCode();
- System.out.println("stop");
- }
- public native void nativeCode();
- }
Są tutaj jednak dwie rzeczy, warte zaznaczenia. Pierwsze to deklaracja metody pod koniec - public native void nativeCode(), która mówi kompilatorowi, że w tej klasie znajduje się funkcja nativeCode, jest jednak ona oznaczona jako znajdująca się w zupełnie innym module, pisanym w języku natywnym (native, znaczenie jest podobne co specyfikator extern w językach C/C++). Nie ma potrzeby ani sensu pisać ciała tej funkcji, skoro właśnie zostało jasno napisane, że jest ono gdzie indziej, dlatego po tej deklaracji zamiast nawiasów klamrowych należy postawić średnik.
Druga rzecz to ładowanie odpowiedniej biblioteki za pomocą metody loadLibrary klasy System. Metoda ta w argumencie przyjmuje nazwę wskazującą na bibliotekę, którą JVM ma dołączyć do puli bibliotek używanych np. przy dolinkowywaniu metod oznaczonych jako metody natywne (tak jest w tym przypadku). Przy każdej lokalizacji metody natywnej JVM będzie przeszukiwała każdą dołączoną bibliotekę, a więc w tym przypadku także i bibliotekę na którą wskazuje nazwa native. Pytanie brzmi, w jaki sposób nazwa wskazuje na bibliotekę? Odpowiedź zależy od systemu, na którym działa JVM. Jeśli jest to Linux, JVM używa standardowej konwencji nazewnictwa bibliotek, w tym przypadku będzie to libnative.so. Podkreślona część to nazwa wpisana w argumencie metody loadLibrary, tak więc przy wywołaniu loadLibrary("alamakota") poszukiwaną biblioteką będzie libalamakota.so. W systemach Windowsowych sytuacja ma się inaczej, ponieważ tam standardową nazwą biblioteki jest po prostu nazwa z rozszerzeniem .dll, tak więc dla naszego przykładu JVM zacznie szukać biblioteki native.dll.
Teraz należy skompilować program:
- $ javac Hello.java
- $ ls -la *.class
- -rw-r--r-- 1 antek users 526 2008-04-26 13:02 Hello.class
Mamy plik class, jednak uruchamiając go dostajemy błąd.
- $ java Hello
- start
- Exception in thread "main" java.lang.UnsatisfiedLinkError: no native in java.library.path
- at java.lang.ClassLoader.loadLibrary(ClassLoader.java:1682)
- at java.lang.Runtime.loadLibrary0(Runtime.java:823)
- at java.lang.System.loadLibrary(System.java:1030)
- at Hello.main(Hello.java:5)
Z błędu tego można dowiedzieć się czterech rzeczy: pierwszy to wystąpienie wyjątku UnsatisfiedLinkError, czyli wiadomość od JVM o tym, że nie była ona w stanie odnaleźć wszystkich procedur natywnych. Dwie kolejne wiadomości znajdują się po drukropku: "no native in java.library.path", czyli błąd podczas odnajdowania biblioteki o nazwie native (a więc w naszym przypadku libnative.so), do szukania tej biblioteki JVM wykorzystuje zmienną java.library.path - tą informację wykorzystamy na samym końcu. Ostatnia, czwarta informacja, to miejsce w kodzie w którym został wyrzucony wyjątek jak też i stacktrace.
Błąd jest logiczny i oczekiwany, jako, że nie posiadamy ani biblioteki ani tym bardziej procedury, tak więc należałoby teraz przejść do punktu drugiego, aby naprawić ten problem.
2. Tworzenie interfejsu.
Punkt ten może brzmieć skomplikowanie, ale jest on banalnie prosty. Ogranicza się do wywołania jednego narzędzia, dołączonego do Java SDK: javah. Argumentem tego polecenia będzie w pełni kwalifikowana nazwa klasy, której interfejs chcemy stworzyć. W przypadku tego programu nazwa klasy wraz ze wszystkimi pakietami wyglądać będzie jasno: Hello.
- $ javah Hello
- $ ls -la *.h
- -rw-r--r-- 1 antek users 362 2008-04-26 11:57 Hello.h
Program nie wyświetlił żadnych informacji, za to stworzył nowy plik: Hello.h. Po zajrzeniu do środka...
- $ cat Hello.h
- /* DO NOT EDIT THIS FILE - it is machine generated */
- #include <jni.h>
- /* Header for class Hello */
- #ifndef _Included_Hello
- #define _Included_Hello
- #ifdef __cplusplus
- extern "C" {
- #endif
- /*
- * Class: Hello
- * Method: nativeCode
- * Signature: ()V
- */
- JNIEXPORT void JNICALL Java_Hello_nativeCode
- (JNIEnv *, jobject);
- #ifdef __cplusplus
- }
- #endif
- #endif
... można zobaczyć standardowy plik nagłówkowy języka C/C++. Do naszych celów nie jest on przydatny, jako plik -- posiada on jednak pojedyncze informacje, które są ważne, a konkretnie chodzi o dwie: komentarz Signature oraz nazwe funkcji (w tym przypadku chodzi o string Java_Hello_nativeCode). Nazwa funkcji jest generowana w teoretycznie łatwy do zauważenia sposób, tak więc przy odrobinie wprawy punkt tworzenia interfejsu (a więc ten punkt który czytasz w tej chwili) można pominąć całkowicie. Komentarz Signature natomiast zawiera informacje o argumentach przyjmowanych przez tą funkcję; string ()V zakodowany jest zgodnie ze standardową metodą kodowania sygnatur w środowisku Java i choć sama metoda nie jest skomplikowana, poświęcę na nią jednego posta na tym blogu (znajduje się on tutaj - Sygnatury w języku Java).
W skrócie, z tego pliku nagłowkowego należy wynieść jedną informację: nazwę procedury, którą należy oznaczyć blok kodu w asemblerze, tylko tyle. Skoro już ją mamy, można przejść do punktu trzeciego...
3. Pisanie procedur w asemblerze.
Zakładam, że znasz asembler. Zakładam też, że nie w postaci AT&T :), więc do pisania kodu nie musisz wykorzystywać standardowego asemblera GNU Assembler, ale również takiego jak np. nasm [4], który posiada bardziej jasną dla niektórych składnię w notacji Intelowskiej. Ja posłużę się jednak notacją AT&T, która posiada swoisty, linuxowy zapach ;). Jeśli jednak przeszkadza Ci to tak bardzo, że nie widzisz sensu przebijania się przez to, tutaj możesz znaleźć kod asm w wersji Intelowskiej. Opisy poniżej jednak robione będą na wersji AT&T.
Dobrym przyzwyczajeniem jest pomyślenie, w jaki sposób ma program działać, przed napisaniem takiego programu. W tym przypadku program będzie wyświetlał napis, jak wspomniałem wcześniej, za pomocą kernelowej funkcji sys_write (oczywiście w userland, w całym poście nie ma żadnych rzeczy wymagających poruszania nawet nazwy ring0). Jeśli nie miałeś okazji korzystać z gas, to jest dobry moment, aby zacząć. Program będzie zawierał dane (napis) i kod, tak więc aby żyć zgodnie z naturą, potrzebne są nam dwie sekcje: .data i .text.
Na początek stub pliku, przypominający nieco plik .asm dla masm32:
- .data
- .text
Zapisz plik jako native.s. Najpierw zajmiemy się sekcją .data, która jest o wiele krótsza i cała jej zmiana może być przeprowadzona dodając jedną linijkę - nasz napis.
- .data
- napis: .ascii "Witaj, świecie!\n\0"
Dwie uwagi: jedna jest truizmem, druga też, ale mniejszym. Koniec stringa okupują znaki \n i \0. Pierwszy oznacza przejście do nowej linii - to jest ten truizm, drugi natomiast to zakończenie stringa bajtem 0 (co oznacza string ASCIIZ). Również wydaje się to być truizmem, gdyby nie fakt, że sys_write, w przeciwieństwie do DOS'owej funkcji ax=9h/int 21h nie wymaga takiego kończenia stringów, ponieważ jeden z argumentów które przyjmuje mówi o długości stringa. To zero jest bardziej wymagane przez to, że w celach rozruszania swoich pordzewiałych zdolności pisania czegokolwiek w asemblerze, dołączę również funkcję strlen.
Mając zrobioną sekcję .data, należy teraz przejść do sekcji .text:
- .text
- .globl Java_Hello_nativeCode
- Java_Hello_nativeCode:
- pushl $napis
- call strlen
- pushl %eax
- pushl $napis
- call puts
- ret
Nie trzymam się tu do końca notacji fastcall którą Linux zdaje się wychwalać, ale to nie powinien być problem. Widać tu label o nazwie takiej, jaką zobaczyć można w interfejsie w pliku .H, punkt wyżej. Ponadto, oznaczony jest on jako symbol globalny (.globl), czyli symbol, którego można eksportować poza bibliotekę. Kod wywołuje najpierw funkcję strlen, która w eax zwraca ilość znaków napisu. Następnie funkcja puts wyświetla napis na ekranie, przyjmując w argumentach sam napis, jak i ilość znaków tego napisu. Poniżej przedstawiona jest funkcja strlen, którą wstawić możesz nad Java_Hello_nativeCode:
- strlen:
- movl 4 (%esp), %edi
- xorl %eax, %eax
- leal -1 (%eax), %ecx
- repnz scasb
- neg %ecx
- leal -1 (%ecx), %eax
- ret $4
Następnie funkcja puts, wykorzystująca sys_write. Jak wspomniałem już wcześniej, obowiązuje tu konwencja fastcall, czyli przekazywanie argumentów za pomocą rejestrów, nie stosu, przy czym rejestr eax zarezerwowany jest na wybór funkcji [3], a int 0x80 zawsze jest stałe. Pierwszy argument funkcji pójdzie do ebx, drugi do ecx, trzeci do edx, itp. sys_write w ebx wymaga identyfikatora deskryptora pliku, do którego ma zapisać dane - jeśli ma zapisać na standardowe wyjście, wtedy deskryptor przyjmuje wartość 1 (stdout). Ecx to rejestr przetrzymujący adres napisu do wyświetlenia, natomiast edx ilość znaków, które mają pojawić się w miejscu wskazywanym przez ten deskryptor.
- puts:
- movl $4, %eax
- leal -3 (%eax), %ebx
- movl 4 (%esp), %ecx
- movl 8 (%esp), %edx
- int $0x80
- ret $8
Jest więc już chyba wszystko, czego potrzebujemy. Teraz należy ten plik skompilować programem as:
- $ as native.s -o native.o
- $ ld native.o -o libnative.so -shared
- ld: warning: creating a DT_TEXTREL in object.
Zwróc uwagę na wyjściową nazwę pliku - libnative.so. Ponadto, linker wyświetlił ostrzeżenie o stworzeniu pewnych relokacji, co jest logiczne, jako że biblioteka nie zostanie załadowana pod sprecyzowany w pliku lib adres podstawowy (image base). Relokacje te można w prosty sposób podejrzeć korzystając z narzędzia readelf:
- $ readelf -r libnative.so
- Relocation section '.rel.dyn' at offset 0x1ac contains 2 entries:
- Offset Info Type Sym.Value Sym. Name
- 000001f0 00000008 R_386_RELATIVE
- 000001fb 00000008 R_386_RELATIVE
Nie trzeba wiedzieć wszystkiego o budowie plików ELF, o specyfice Linuxa ani niczym konkretnym, poza oczywiście podstawowymi informacjami na temat ogólnie plików wykonywalnych i problemów, jakie mogą napotkać. Widać tutaj stworzone dwa wpisy relokacyjne, pod offsetami 0x1F0 i 0x1FB, o typach R_386_RELATIVE, które odnoszą się do poniżej zaznaczonych miejsc, skutkując zmianą offsetów podczas dynamicznego ładowania biblioteki (miejsca oznaczone są nawiasami klamrowymi, w części kodu bajtowego, czyli drugiej kolumnie)::
- 000001ef <Java_Hello_nativeCode>:
- 1ef: 68 [00 20 00 00] push $0x2000
- 1f4: e8 c3 ff ff ff call 1bc <strlen>
- 1f9: 50 push %eax
- 1fa: 68 [00 20 00 00] push $0x2000
- 1ff: e8 cb ff ff ff call 1cf <puts>
- 204: c3 ret
Powyższe relokacje mogą stanowić problem przy próbie stworzenia takiej biblioteki na architekturze x64, jako, że wtedy linker narzeka na brak możliwości stworzenia takich relokacji. Nie jestem ekspertem w tym systemie, więc nie potrafię powiedzieć dlaczego tak jest, jest jednak wyjście z tej sytuacji można uzyskać podczas korzystania z tzw. PIC - Position Independent Code [6]. Podczas pisania biblioteki na architekturze x86 w języku C/C++ (czyli korzystając z kompilatora gcc), dodając przełącznik -fPIC kompilator tworzy kod, który nie jest zależny od konkretnego image base, jak też nie zawiera relokacji - polecam sprawdzić w jaki sposób generowany jest ten kod. Dla zaostrzenia ciekawości dodam, że wykorzystywany jest tam znany sposób na odczytanie rejestru EIP przy pomocy kombinacji instrukcji CALL/POP, a więc kodu często spotykanego w wirusach :). GNU Assembler nie supportuje jednak tworzenia kodu PIC (a przynajmniej tyle zdołałem się dowiedzieć), supportuje go jednak NASM [4] w najnowszej wersji [5].
Podsumowując nasz aktualny stan, powinniśmy mieć już gotową bibliotekę libnative.so, jak też i plik Hello.class. Program Java zawiera informacje o bibliotece libnative.so, tak więc JVM powinna wiedzieć, w której bibliotece należy szukać brakujących metod, jak również i biblioteka zawiera brakujące metody. Pora sprawdzić, czy wszystko działa jak należy.
4. Uruchamianie programu.
Program po zwykłym uruchomieniu nadal rzuca wyjątkiem UnsatisfiedLinkError, ale tylko dlatego, że JVM należy zwrócić uwagę na katalog, w którym ma szukać tej biblioteki. Można to zrobić za pomocą zmiany zmiennej java.library.path, jak pisałem wcześniej. W moim przypadku biblioteka znajduje się w katalogu "asm", więc mogę napisać tak:
- $ ls asm/*.so -la
- -rwxr-xr-x 1 antek users 5145 2008-04-26 14:00 asm/libnative.so
- $ java -Djava.library.path=asm Hello
- start
- Witaj świecie!
- stop
Kod w asemblerze został uruchomiony, a więc główny cel został spełniony. :) W części drugiej postaram się przedstawić w jaki sposób komunikować się z Javą na nieco wyższym poziomie, można by rzecz bardziej praktycznym niż w tej chwili...
A. Referencje:
0. Java Native Interface (JNI), http://en.wikipedia.org/wiki/Java_Native_Interface
1. Deklaracja sys_write, http://www.gelato.unsw.edu.au/lxr/source/fs/read_write.c#L360
2. Introduction to UNIX assembly programming, http://asm.sourceforge.net/intro/
3. Tabela syscalli - /usr/src/linux/arch/i386/kernel/syscall_table.S (kernel 2.6.20, architektura i386)
4. NASM - Netwide Assembler - http://nasm.sourceforge.net/
5. NASM i kod PIC - http://web.mit.edu/nasm_v0.98/doc/nasm/html/nasmdoc8.html#section-8.2
5. NASM i kod PIC - http://developer.apple.com/documentation/DeveloperTools/nasm/nasmdoc8.ht...
6. Kod PIC - http://www.gentoo.org/proj/en/hardened/pic-guide.xml
