Łączenie C++ z Asemblerem

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.

Spis treści

  1. Jaki w tym cel?
  2. Wstawki asemblerowe
    1. Teoria
    2. Zalety i wady
    3. Przykład
  3. Linkowanie z kodem Asemblera
    1. Teoria
      1. Konwencja wywołania
      2. Symbole i pliki obiektowe
      3. Składnia asemblera
      4. Wskaźnik ramki
    2. Teoria
    3. Przykłady
      1. Architektura 32 bitowa (x86)
      2. Architektura 64 bitowa (amd64)
      3. Wykorzystanie NASM
  4. Bonus
    1. Dlaczego używamy standardu nazewnictwa z "C"
  5. Źródła zewnętrzne

Jaki w tym cel?

Po co łączyć C++ z Asemblerem?, widzę parę powodów:

  • Optymalizacja
    Gdy kod wykonywalny napisany w asemblerze będzie znacznie bardziej optymalny, niż ten wygenerowany przez kompilator C++
  • Konieczność
    Gdy C++ nie daje możliwości wykonania pewnych czynności, fragmenty kodu odpowiedzialne za zarządzeniem procesora, inicjalizacja procesora, zarządzanie przerwaniami, sterowniki.
    Jądro Linuksa (co prawda napisane w C) posiada kod asemblerowy, większość tego kodu jest opakowana w funkcje które zawierają wstawki asemblerowe, jednakże część inicjalizacyjna jądra jest zlinkowana z kodu obiektowego.
  • Efektywność
    W zasadzie podpunkt można zaliczyć pod optymalizacje, chciałem jednak zaznaczyć fakt, iż C++ nie wykorzystuje wszystkich funkcji procesora, prostym przykładem jest brak możliwości zwrócenia flagi parzystości.
    Z problemem tym spotkałem się kiedyś podczas próby implementacji Kodu hamminga.
  • Rozmiar
    Praktycznie problem wyłącznie w programowaniu urządzeń embedded - kompilator może wygenerować za duży kod, w asemblerze można napisać mniejszy kod, bądź użyć wątpliwych obejść (haków) standardowych procedur.

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.

Wstawki asemblerowe

Względnie łatwa metoda integracji kodu asemblerowego z C++
Według mojej opini, metoda dobra dla niewielkich ilości instrukcji

Teoria

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
);

Zalety i wady

  • Zalety
    • Brak dodatkowych plików
    • Możliwość wplątania w środek dowolnego fragmentu kodu
  • Wady
    • Ograniczone możliwości
    • Uciążliwe przy większej ilości kodu

Przykład

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 z kodem Asemblera

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.

Teoria

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.

Konwencja wywołania

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):

  • Argumenty do funkcji są przekazywane na stosie, w kolejności od ostatniego do pierwszego
  • Zwracaną wartość jest poprzez rejest EAX/RAX, w przypadku liczby zmiennoprzecikowej, rejest ST0 koprocesora jest użyty.
Podstawowa różnica pomiędzy architekturą AMD64 jest taka że parametry są przekazywane najpierw przez rejestry rdi, rsi, rdx, rcx, r8, r9 potem przez stos, jeżeli ich zabraknie.

Symbole i pliki obiektowe

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:
extern
Informuje kompilator aby definicji tej funkcji nie spodziewał się w tym pliku
"C"
Informuje kompilator że nazwa funkcji jest podana według standardu C
void function1(void)
Deklaracja funkcji

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.

Składnia 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źnik ramki

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ń
CEBP+16Trzeci argument
BEBP+12Drugi argument
AEBP+8Pierwszy argument
RETEBP+4Adres powrotu
EBPEBPPoprzednia wartość EBP
...Zmienne lokalne funkcji i stosy wywołań z niej
Możemy sobie łatwo wyobrazić, że jeżeli wartość EBP nie jest modyfikowana, debugger nie ma problemów z analizą wywołań wstecz.

Zalety i wady

  • Zalety
    • Można łączyć wiele różnych języków
    • Większe możliwości
      Można napisać cały osobny moduł programu w innym języku.
  • Wady
    • Bardziej skomplikowane
    • Wymaga osobnych plików

Przykłady

Architektura 32 bitowa (x86)

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.

Architektura 64 bitowa (amd64)

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.
Należy pamiętać że przy architekturze 64 bitowej, wartości najpierw są przekazywane przez rejestry.

Wykorzystanie NASM

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.
Należy pamiętać że przy architekturze 64 bitowej, wartości najpierw są przekazywane przez rejestry.

Bonus

Dlaczego używamy standardu nazewnictwa z "C"

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.o
Wykorzystują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.

Źródła zewnętrzne

W internecie można znaleźć inne strony mówiące o temacie: