W tym temacie omówię jak można połączyć C++ z Asemblerem, omówię związaną z tym teorie i na przykładach zobrazuje różne metody zabrania się do tego
Opisane metody są związane wyłącznie z kompilatorem GCC.
Dwie podstawowe metody łączenia kodu Asemblera z C++ to asemblerowe wstawki i linkowanie z przekompilowanym kodem asemblerowym.
Po co łączyć C++ z Asemblerem?, widzę parę powodów:
Niewątpliwą wadą korzystania z kodu asemblera przy projekcie jest to, że kod nie będzie łatwo dało się przenieść pomiędzy architekturami procesora.
Ten HowTo bardziej kieruje dla osób chcących zrozumieć jak kompilator działa, raczej niż z praktycznych przyczyn takiego rozwiązania.
Zakładam również iż odbiorca posiada podstawową wiedzę jak działa procesor i jak wygląda kod asemblerowy.
Względnie łatwa metoda integracji kodu asemblerowego z C++
Według mojej opini, metoda dobra dla niewielkich ilości instrukcji
Zacznijmy od tego, iż kod piszemy w asemblerze według składni AT&T.
Jest to konieczność, gdyż GCC korzysta z kompilatora kodu asemblera o nazwie GAS (GNU Assembler), a domyślną jego składnią jest AT&T.
Omówie teraz rozszerzoną wstawkę asemblerową, po szczegółowy opis, proszę zajrzyj na linki dostępne pod koniec tej strony.
W uproszczeniu składnia wygląda tak:
asm ( W tych liniach znajdują się instrukcje asemblerowe : Opcjonalne operary wyjściowe : Opcjonalne operary wejściowe : Opcjonalna lista rejestrów o zmienionej wartości );
Przedstawie przykładową funkcje zwracającą wartość logiczną prawdy, jeżeli liczba ma parzystą ilość jedynek:
bool isParity(uint32_t number) {
uint8_t parity = 0;
asm( "cmpl $0, %0;" Zapełniamy rejestr flagowy procesora wartością (number)
"jnp notparity;" Jeżeli wartość (number) nie jest parzysta, skocz do (notparity)
"movb $1, %1;" Umieść wartość 1 w zmiennej parity
"notparity:" Labelka (notparity)
: "=r" (number) Umieść wartość (number) w dowolnym rejestrze, ten rejestr będzie dostępny pod (%0)
: "m" (parity) Przekaż adres zmiennej parity pod (%1)
);
return parity == 1;
}
Program wypisuje które z liczb od 0 do 19 mają parzystą ilość bitów.
Kompilacja i wykonanie przebiega następująco:
$ make Kompilacja w katalogu rozpakowanych plików g++ Parity.cpp -o Parzystosc $ ./Parzystosc Wykonanie programu Liczba 0 ma parzysta ilosc jedynek bitowych Liczba 1 ma nieparzysta ilosc jedynek bitowych Liczba 2 ma nieparzysta ilosc jedynek bitowych Liczba 3 ma parzysta ilosc jedynek bitowych Liczba 4 ma nieparzysta ilosc jedynek bitowych Liczba 5 ma parzysta ilosc jedynek bitowych Liczba 6 ma parzysta ilosc jedynek bitowych Liczba 7 ma nieparzysta ilosc jedynek bitowych Liczba 8 ma nieparzysta ilosc jedynek bitowych Liczba 9 ma parzysta ilosc jedynek bitowych Liczba 10 ma parzysta ilosc jedynek bitowych Liczba 11 ma nieparzysta ilosc jedynek bitowych Liczba 12 ma parzysta ilosc jedynek bitowych Liczba 13 ma nieparzysta ilosc jedynek bitowych Liczba 14 ma nieparzysta ilosc jedynek bitowych Liczba 15 ma parzysta ilosc jedynek bitowych Liczba 16 ma nieparzysta ilosc jedynek bitowych Liczba 17 ma parzysta ilosc jedynek bitowych Liczba 18 ma parzysta ilosc jedynek bitowych Liczba 19 ma nieparzysta ilosc jedynek bitowych
Kod źródłowy można pobrać Tutaj.
Linkowanie kodu jednego typu z drugim typem na bazie plików obiektowych nie jest niczym specjalnym.
Linkowanie można wykonać z każdym językiem (kompilatorem) który produkuje plik według tego standardu, takie jak: Ada, C, C++, Asembler.
Aby połączyć kod C++ z Asemblerem, musimy wyprodukować plik obiektowy z Asemblera i wywołać go z C++.
Najlepszy sposób wykonania tego jest poprzez napisanie funkcji w Asemblerze i wywołanie jej z C++.
Jako ciekowostkę podam że jądro Linuksa zaczyna się od kodu Asemblera, inicjuje procesor i wywołuje kod C.
Skoro postanowiliśmy napisać funkcje, należy zastanowić się jak przekazuje się dane pomiędzy funkcjami w różnych kompilatorach/systemach.
Jako że w naszym przypadku rozważamy architekturę intelowską i kompilator GCC, należy wykorzystać konwencje wywołań cdecl.
Uproszczając, wystarczy powiedzieć, że (architektura 32 bitowa):
Co musimy zrobić, to stworzyć deklaracje funkcji w kodzie C++ i napisać ją w asemblerze.
Sytuacja jest trochę bardziej skomplikowana niż opis niestety.
Linker łączy wywołanie funkcji z definicją jej po symbolach w plikach obiektowych.
Mówiąc kolokwialnie po nazwie, niestety nazwy funkcji w C++ są trochę bardziej skomplikowane i mogą zależeć od wersji kompilatora.
Dlatego też musimy powiedzieć kompilatorowi, aby spodziewał się funkcji napisanej w C, a nie w C++.
Aby tego dokonać należy napisać poniższą linie:
extern "C" void function1(void);Linia ta oznacza:
Dla ciekawych jaką nazwę oczekuje kompilator bez standardu "C", odsyłam do części bonusowej: Dlaczego używamy standardu nazewnictwa z "C".
Czasami konieczne może być poprzedzenie nazwy funkcji znakime podłogi '_' w kodzie asemblera.
Wspomnę krótko o dwóch konwencjach pisania kodu asemblera, AT&T i intelowskiej.
Domyślnie kompilator GCC (GAS - Gnu Assembler) używa konwencji AT&T, choć można mu powiedzieć inaczej
Pierwszą rzeczą jaką można zauważyć, to to że konwencja AT&T jest bardziej rygorystyczna niż intelowska, np.: stałe są poprzedzone znakiem $, a rejestry %.
Inną ważną rzeczą jest to że argumenty instrukcji w tych dwóch konwencjach podawane są w odwrotnej kolejności względem siebie.
Wskaźniki ramki są generowane przez kompilator aby wspomóc w debugowaniu, funkcje te można wyłączyć w GCC, choć jest to niezalecane.
Dodatkowo korzystanie z ramki stosu jest niezmiernie użyteczne przy adresowaniu zmiennych w funkcji.
Rozważmy taką definicje:
void function(int a, int b, int c);Stos wyglądałby następująco:
| ... | Zmienne lokalne wywołującej funkcji i stosy poprzednich wywołań | |
|---|---|---|
| C | EBP+16 | Trzeci argument |
| B | EBP+12 | Drugi argument |
| A | EBP+8 | Pierwszy argument |
| RET | EBP+4 | Adres powrotu |
| EBP | EBP | Poprzednia wartość EBP |
| ... | Zmienne lokalne funkcji i stosy wywołań z niej |
Tak wygląda kod sprawdzający parzystą ilość jedynek w liczbie jako osobny plik asemblerowy przy użyciu kompilatora GAS na architekturze 32 bitowej
.code32
.globl getParity
// %ebp-8 = number
// %ebp-4 = adres powrotu
// %ebp = poprzednia wartość ebp
.text
getParity:
push %ebp
movl %esp, %ebp
movl $0, %eax
testl $0xFFFFFFFF, 8(%ebp)
jnp getParityExit
movl $1, %eax
getParityExit:
pop %ebp
ret
Kod źródłowy z plikiem Makefile można pobrać Tutaj.
Tak wygląda kod sprawdzający parzystą ilość jedynek w liczbie jako osobny plik asemblerowy przy użyciu kompilatora GAS na architekturze amd64
.code64
.globl getParity
// %rdi = number
//
// %rbp-4 = adres powrotu
// %rbp = poprzednia wartość rbp
.text
getParity:
pushq %rbp
movq %rsp, %rbp
movq $0, %rax
testq $0xFFFFFFFFFFFFFFFF, %rdi
jnp getParityExit
movq $1, %rax
getParityExit:
popq %rbp
ret
Kod źródłowy z plikiem Makefile można pobrać Tutaj.Dla pokazania różnicy pomiędzy składanią intelowską a AT&T użyjemy do kompilacji kodu asemblerowego kompilatora NASM i skompilujemy kod sprawdzający parzystość dla architektury amd64
bits 64
section text
global getParity
; rdi = number
;
; rbp-4 = adres powrotu
; rbp = poprzednia wartość rbp
getParity:
push rbp
mov rbp, rsp
mov rax, 0
test rdi, 0xFFFFFFFFFFFFFFFF
jnp getParityExit
mov rax, 1
getParityExit:
pop rbp
ret
Kod źródłowy z plikiem Makefile można pobrać Tutaj.Stwórzmy poniższy prosty kod:
extern "C" void function1(void);
extern void function2(void);
int main(int argc, char *argv[])
{
function1();
function2();
return 0;
}
Skompilujmy go (wyłącznie kompilacja do pliku obiektowego, bez linkowania):
g++ -c main.cpp -o main.oWykorzystując program nm zobaczmy jakie symbole są w tym pliku:
nm main.o
U function1
U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
U _Z9function2v
Jak widzmy, w przypadku funkcji z "C", linker będzie spodziewał się nazwy funkcji function1, natomiast bez określenia standardu z C, linker spodziewać się będzie nazwy funkcji _Z9function2v.
W internecie można znaleźć inne strony mówiące o temacie: