Architektura Komputerów 2 – Laboratorium nr 5 – Łączenie kodu C i Asemblera

1. Dostęp do funkcji języka C z poziomu Asemblera

Aby wywołać funkcję napisaną w C z kodu Asemblerowego należy umieścić jej argumenty całkowite (liczby lub wskaźniki na adresy w pamięci) kolejno w rejestrach RDI, RSI, RDX, RCX, R8, R9, argumenty zmiennoprzecinkowe w rejestrach XMM0-XMM7 i wywołać tą funkcję korzystając z rozkazu call. Jeśli przekazujemy argumenty zmiennoprzecinkowe, wtedy do rejestru RAX musimy również wpisać ich ilość.

Przykład wywołania funkcji:

mov $1, %rax  # Ilość argumentów zmiennoprzecinkowych
              # - przesyłany jest jeden parametr i znajdzie się on
              # w rejestrze XMM0
mov $25, %rsi # Pierwszy parametr - typu całkowitego
mov $75, %rdi # Drugi parametr - typu całkowitego
movss liczba, %xmm0 # Trzeci parametr - typu zmiennoprzecinkowego
                    # skopiowany z 4-bajtowej komórki w pamięci
call funkcja        # Wywołanie funkcji

Kod należy także odpowiednio skompilować i zlinkować. Możemy wykonać tą operację jednocześnie korzystając z polecenia:

gcc kod_asm.s kod_c.c -o plik_wykonywalny -g

W kodzie Asemblerowym możemy korzystać także z innych funkcji C np. z biblioteki stdio.h.

W ramach zadania z zajęć należało wczytać od użytkownika dwie liczby – całkowitą i zmiennoprzecinkową, przekazać je do funkcji napisanej w C zwracającej wynik działania a^2+b^3, gdzie a i b to kolejne liczby – całkowita i zmiennoprzecinkowa, a następnie korzystając z funkcji printf wyświetlić ten wynik.

Kod źródłowy w C:

float funkcja(int a, float b)
{
    return a*a+b*b*b;
}

Kod źródłowy w Asemblerze:

.data
format_d: .asciz "%d",  # Łańcuchy znaków wykorzystywane do
format_f: .asciz "%f",  # wywoływania funkcji scanf i printf
nowa_linia: .asciz "\n"

.bss
.comm liczba1, 4 # Bufory na liczby typu integer i float
.comm liczba2, 4

.text
.global main

main:
#
# Pobranie od użytkownika pierwszej liczby - typu całkowitego
#
# Odpowiednik poniższego kodu w C: scanf(&liczba1, "%d");
mov $0, %rax        # Przesyłamy 0 parametrów zmiennoprzecinkowych
mov $format_d, %rdi # Pierwszy parametr całkowity dla scanf
                    # - format w jakim ma zostać zapisany
                    # wynik w buforze
mov $liczba1, %rsi  # Drugi parametr całkowity dla scanf
                    # - adres bufora do które zapisany
                    # ma zostać wynik
call scanf          # Wywołanie funkcji scanf z biblioteki stdio.h



#
# Pobranie od użytkownika drugiej liczby - typu zmiennoprzecinkowego
#
mov $0, %rax        # Przesyłamy 0 parametrów zmiennoprzecinkowych
mov $format_f, %rdi # Pierwszy parametr całkowity dla scanf
                    # - format w jakim ma zostać zapisany
                    # wynik w buforze
mov $liczba2, %rsi  # Drugi parametr całkowity dla scanf
                    # - adres bufora do które zapisany
                    # ma zostać wynik
call scanf          # Wywołanie funkcji scanf z biblioteki stdio.h



#
# Wywołanie funkcji napisanej w C z parametrami
# stało i zmiennoprzecinkowymi
#
mov $1, %rax # Ilość argumentów zmiennoprzecinkowych
             # - przesyłany jest jeden parametr w rejestrze XMM0
             # jeśli było by ich więcej musiały by one zostać
             # umieszczone w kolejnych rejestrach XMM
mov $0, %rdi # Czyszczenie rejestru RDI - do jego młodszych
             # czterech bajtów wpisana zostanie wartość
             # wczytanej liczby typu int
mov $0, %rcx # Licznik na potrzeby adresacji pamięci niżej
mov liczba1(, %rcx, 4), %edi # Przeniesienie pierwszego parametru
                             # - typu całkowitego do rejestru RDI
movss liczba2, %xmm0  # Przeniesienie drugiego parametru
                      # - typu zmiennoprzecinkowego do rejestru XMM0
call funkcja          # Wywołanie funkcji
cvtps2pd %xmm0, %xmm0 # Konwersja wyniku na double aby możliwe było
                      # wyświetlenie go przez funkcje printf



#
# Wyświetlenie wyniku z użyciem funkcji printf
#
mov $1, %rax # Przesyłamy jeden parametr zmiennoprzecinkowy
             # - liczbę do wyświetlenia (w rejestrze XMM0)
mov $format_f, %rdi # Pierwszy parametr typu całkowitego
                    # - format w jakim wyświetlona ma zostać liczba
sub $8, %rsp # Workaround, aby printf nie zmienił wartości
             # ostatniej komórki na stosie. Jest to potrzebne tylko
             # przy wyświetlaniu liczb zmiennoprzecinkowych.
             # Wskaźnik na stos należy przesunąć o wielokrotność
             # liczby 8 równą ilości parametrów ZP (8*RAX).
call printf  # Wywołanie funkcji printf
add $8, %rsp # Workaround -||-



#
# Wyświetlenie znaku nowej linii
#
mov $0, %rax # Nie przesyłamy żadnych parametrów ZP
mov $nowa_linia, %rdi # Pierwszy parametr typu całkowitego
                      # - wskaźnik na ciąg znaków do wyświetlenia
                      # - znak nowej linii
call printf # Wywołanie funkcji printf



#
# Zwrot wartości EXIT_SUCCESS
#
mov $0, %rax # Brak parametrów zmiennoprzecinkowych
call exit

2. Wstawka Asemblerowa w kodzie C

Wstawki w kodzie C wykonuje się korzystając ze słowa kluczowego asm(). W kolejnych liniach, w cudzysłowach, wpisuje rozkazy Asemblerowe. Każdą z linii zawierających rozkaz musi zostać ujęta w cudzysłowy oraz kończyć się znakiem nowej linii – \n, przed zamknięciem cudzysłowu. W następnych liniach, zaczynając od dwukropka podajemy kolejno: zmienne do których skopiowana zostanie wartość z rejestrów po zakończeniu wstawki, zmienne których wartości trafią do rejestrów przed wykonaniem rozkazów oraz rejestry których będziemy używać.

Wartości wejściowe i wyjściowe umieszczone będą w rejestrach wybranych przez kompilator. Aby uzyskać do nich dostęp należy skorzystać z aliasów, wpisując znak % oraz numer parametru – np. %5. Aby uzyskać dostęp do rejestrów korzystając z ich nazw należy użyć podwójnego znaku % oraz nazwy rejestru – np. %%RAX.

Dostęp do stałych zadeklarowanych wcześniej w kodzie jest możliwy wprost po podaniu ich nazwy.

Poniżej kod z zajęć i zarazem przykład wykorzystania kodu Asemblerowego wewnątrz kodu C. Program ma za zadanie zmienić wielkość liter (na małe) we wcześniej zadeklarowanej zmiennej typu string.

#include <stdio.h>

// Deklaracja zmiennej przechowującej ciąg znaków do konwersji
char str[] = "AbCdEfGh";
// Stała przechowująca długość tego ciągu
const int len = 8;

int main(void)
{
    //
    // Wstawka Asemblerowa
    //
    asm(
    "mov $0, %%rbx \n" // Zerowanie rejestru RBX - licznika do pętli.
    // Każdy mnemonik rejestru należy poprzedzić znakami %%.

    "petla: \n" // Etykieta powrotu do pętli

    "mov (%0, %%rbx, 1), %%al \n" // Skopiowanie n-tej komórki stringa
    // do rejestru Al. %0 to alias rejestru w którym kompilator C umieści
    // pierwszy parametr wejściowy (wskaźnik na pierwszą komórkę stringa).

    "and $223, %%al \n" // Wyzerowanie 5 bity kodu znaku ASCII
                        // (zamiana na duża literę)
    "add $32, %%al \n"  // Dodanie do kodu litery wartości 2^5
                        // (zamiana na małą literę)

    "mov %%al, (%0, %%rbx, 1) \n" // Zapisanie zmienionej wartości do stringa

    "inc %%rbx \n"      // Zwiększenie licznika pętli
    "cmp len, %%ebx \n" // Porównanie licznika pętli ze stałą "len"
                        // zadeklarowaną w kodzie C
    "jl petla \n" // Powrót na początek pętli aż do wykonania operacji
                  // dla każdego znaku ze stringa

    : // Nie mamy żadnych parametrów wyjściowych. Jeśli by takie były
    // należało by je zadeklarować podobnie jak w lini poniżej, jednak
    // zamiast "r" należało by użyć "r=". Spowodowało by to przeniesienie
    // wartości z rejestru oznaczonego w kodzie jako %0, %1 itp. do zmiennej
    // po wykonaniu wstawki.

    :"r"(&str) // Lista parametrów wejściowych - zmiennych które zostaną
    // zapisane do rejestrów i będzie możliwy ich odczyt w kodzie Asemblerowym.
    // Podobnie jak wyżej - są one dostępne jako aliasy na rejestry - %0, %1 itp.

    :"%rax", "%rbx" // Rejestry których będziemy używać w kodzie Asemblerowym.
    );

    //
    // Wyświetlenie wyniku
    //
    printf("Wynik: %s\n", str);

    //
    // Zwrot wartości EXIT_SUCCESS
    //
    return 0;
}

3. Dostęp do funkcji Asemblerowych z kodu w języku C

Aby móc używać funkcji napisanych w innym języku i dołączonych dopiero na etapie kompilacji, musimy zapowiedzieć ich ich dołączenie, podać typ zawracany oraz typy przyjmowanych parametrów. Służy do tego słowo kluczowe extern za którym umieszcza się typowy prototyp funkcji:

extern int nazwa_funkcji(int parametr_pierwszy, float parametr_drugi);

W języku Asembler funkcję również należy zadeklarować na początku kodu (w sekcji text) korzystając z poniższej składni:

.global nazwa_funkcji1, nazwa_funkcji2
.type nazwa_funkcji1, @function
.type nazwa_funkcji2, @function

Następnie należy umieścić etykiety o nazwach identycznych jak nazwy funkcji i kończących się rozkazem ret. Parametry wywołania z C zostaną umieszczone w rejestrach opisanych w punkcie pierwszym. Wartość zwracaną należy umieścić w rejestrze RAX w przypadku wartości całkowitych lub XMM0 w przypadku wartości zmiennoprzecinkowych.

Kod źródłowy w C:

#include <stdio.h>

// Deklaracja funkcji które dołączone zostaną
// do programu dopiero na etapie linkowania kodu
extern void szyfr_cezara(char * str, int len);

// Deklaracja zmiennych
char txt[] = "aBcDefGG";
int len = 8;

int main(void)
{
    // Wywołanie funkcji Asemblerowej
    szyfr_cezara(&txt, len);

    // Wyświetlenie wyniku
    printf("Wynik: %s\n", txt);

    // Zwrot wartości EXIT_SUCCESS na wyjściu programu
    return 0;
}

Kod źródłowy w Asemblerze:

.data

.text
# Zadeklarowane tutaj funkcje będą możliwe do wykorzystania
# w języku C po zlinkowaniu plików wynikowych kompilacji obu kodów
.global szyfr_cezara
.type szyfr_cezara, @function

#
# Funkcja szyfrująca podany ciąg znaków szyfrem cezara
# z przesunięciem o 3
#
# Deklaracja w C: szyfr_cezara(&txt, len);
#
szyfr_cezara:
    # Odłożenie rejestru bazowego na stos i skopiwanie obecnej
    # wartości wskaźnika stosu do rejestru bazowego
    push %rbp
    mov %rsp, %rbp

    # Parametry wywołania funkcji umieszczone zostaną
    # w rejestach RDI i RSI.
    # * W rejestrze RDI znajdzie się wskaźnik na pierwszą
    # komórkę stringa.
    # * W rejestrze RSI znajdzie się długość tego stringa.

    # Pętla wykonująca się dla każdego znaku
    mov $0, %rax
    petla_glowna:

        # Skopiowanie n-tego znaku stringa do rejestru BL
        mov (%rdi, %rax, 1), %bl

        # Wykonanie szyfrowania
        cmp $'Z', %bl
        jle duze

        male:
            add $3, %bl
            cmp $'z', %bl
            jle zapisz
            sub $26, %bl
            jmp zapisz

        duze:
            add $3, %bl
            cmp $'Z', %bl
            jle zapisz
            sub $26, %bl

        # Zapis zaszyfrowanego znaku do stringa
        zapisz:
        mov %bl, (%rdi, %rax, 1)

    # Instrukcje sterujące dla pętli
    inc %rax
    cmp %rsi, %rax
    jl petla_glowna

    # Przywrócenie poprzedniej wartości rejestru bazowego
    # i wskaźnika stosu
    mov %rbp, %rsp
    pop %rbp
ret # Powrót do miejsca wywołania funkcji

Specjalne podziękowania dla Mateusza Gniewkowskiego za pomoc w przygotowaniu tego wpisu :-)

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *