tutorial do zrozumienia jak z kodu C++ generuje się kod assembelara

0

Jest taka stronka: https://godbolt.org/ na której można podpatrzyć jak kod C++ przekłada się na assemblera i dzięki czemu można się pobawić, postarać zrozumieć dlaczego np. przekazanie argumentu do funkcji dla "fundamental type" będzie szybsze przez wartość niż przez referencję czy jakieś inne takie kejsy można przetestować sobie i zobaczyć działanie chociażby RVO dla wektora. Niestety nawet jak poczytasz sobie o rejestrach procesora, jego instrukcjach to i tak jest niesamowicie ciężko zrozumieć dlaczego tak a nie inaczej generuje się kod assemblera. Np. ja zauważyłem, że przy każdym wejściu do funkcji mamy:

push    rbp
mov     rbp, rsp

a przy wyjściu:

pop     rbp
ret

No to co szukasz na google i znajdujesz, że na wierzchołku stosu jest zapamiętanie poprzedniej ramki stosu by na końcu do niej zrobić returna. Ale tu już nie rozumiesz szerszego kontekstu bo uczyłeś się kiedyś że do stosu można odwołać się zawsze tylko od góry więc 2 rejestry segment stosu i wierzchołek stosu (SS::SP) powinny wystarczyć a tu jakieś jeszcze BP jest, o co tu chodzi. Masakra jest żeby to zrozumieć. Czy zna ktoś z was tutorial gdzie jest omówiony tego szerszy kontekst od A do Z. Pewnie pod jakimiś hasłami trzeba szukać jak kompilator generuje kod assemblerowy z C++ i dlaczego tak a nie inaczej. Może na youtubie coś ktoś z Was się natknął na coś takiego?

1

Porównaj wywołania funkcji z jednym int'em, dwoma, trema.
Patrz na kolejne instrukcje po: mov rbp, rsp

1

Chodzi o to, że łatwiej jest bezpośrednio odwoływać się do stosu poprzez adresowanie względem rejestru rbp. Popatrz sobie na to co polecił Ci @_13th_Dragon i zobacz przy okazji jak zorganizowane w kodzie asma są zmienne lokalne. Gdyby odwołania do zmiennych lokalnych były bazowane na rejestrze rsp to za każdym razem trzeba byłoby zwracać uwagę na kolejne wartości wrzucone na stos i korygować przesunięcie od rsp do jakiejś konkretnej zmiennej lokalnej i to samo z dereferencją argumentów funkcji. Ramka stosu rozwiązuje ten problem, bo rbp masz stały przez cały czas wykonania funkcji i wtedy nie musisz za każdym razem modyfikować przesunięcia po modyfikacji stosu. Pamiętam, że kiedyś widziałem jakąś flagę kompilatora, która pozwala na usunięcie rbp z ramek stosu funkcji i zmniejszyć przez to rozmiar execa - wtedy adresowanie wewnątrz funkcji odbywa się w sposób rsp+<modyfikowane przesunięcie>

1

IMHO żeby zrozumieć takie rzeczy, to musisz zrozumieć po prostu samego assemblera, a żeby zrozumieć assemblera, musisz ogarnąć jakieś podstawy architektury, na którą piszesz.
Pewnie sporo by Ci się rozjaśniło, jakbyś pooglądał np. filmiki Gynvaela Coldwinda o Asm:

Tutaj video o interesującym Cię temacie - ramka stosu:

0

Rodzajów ramek jest pewnie tyle co języków programowania.
Są rzeczy niezmienne, ale wiele się zmienia w zależności od ustawień optymalizacji czy wyboru języka lub sposobu przekazania parametrów.
Nawet liczba i wielkość parametrów może mieć znaczenie.
Adresowanie komórek stosu na x86 można było już zauważyć we wczesnych kompilatorach na te maszyny, nie wiem od kiedy dokładnie ale nie pamiętam żebym kiedykolwiek na x86 widział ramkę tylko z push/pop.

Tu masz jakiś bardzo krótki tutorial: https://www.cs.virginia.edu/~evans/cs216/guides/x86.html

2

Czy ktoś wie może jakie opcje kompilatora trzeba włączyć aby nie było czegoś takiego:
mov DWORD PTR [rbp-20], edi
tylko by było adresowanie przez rejestr rbp?
To jest kopiowanie argumentu funkcji do zmiennej lokalnej który przed wywołaniem calla foo() zostal przechowany w rej. ogólnego przeznaczenia edi.
Bo tyle sie naczytałem o tym, że jak odejmujesz od rbp to odwołujesz sie do zmiennych lokalnych a jak dodajesz to do argumentów funkcji - podglądasz jaki kod wygenerowal Ci https://godbolt.org/ i nie masz odwołania do argumentów funkcji przez rejestr rbp tylko przez rejestr ogólnego przeznaczenia.
Kod

void boo(int x)
{
    return;
}

int foo(int x)
{
    int y = 3;
    boo(5);
    return x + y;
}

int main()
{
    int x = 2;
    foo(x);
 
    return 0;
}

Wygenerowany assembler

boo(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        nop
        pop     rbp
        ret
foo(int):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 24
        mov     DWORD PTR [rbp-20], edi
        mov     DWORD PTR [rbp-4], 3
        mov     edi, 5
        call    boo(int)
        mov     edx, DWORD PTR [rbp-20]
        mov     eax, DWORD PTR [rbp-4]
        add     eax, edx
        leave
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], 2
        mov     eax, DWORD PTR [rbp-4]
        mov     edi, eax
        call    foo(int)
        mov     eax, 0
        leave
        ret

Rozumiem te wszystkie instrukcje, np. fajnie widać gdy w funkcji foo nie robisz calla do boo to wtedy nie ma alokacj zmiennych na stosie (brak instrukcji sub rsp, 24), no bo kompilator nawet na -O0 uznaje, że niepotrzebne to wtedy - nie ma sensu zwiększać stack pointera, dzięki czemu potem też leave odpada i jest zastąpiony przez krotszą instrukcję "pop rbp". Ale fajnie jakbym też zobaczył to adresowanie do argumentow funkcji przez rejestr rbp tylko jaką opcje kompilacji dać?
Fajnie też widać jak przekazujesz strukturę jako argument funkcji. Jak w strukturce masz 1 inta to mniej kodu assemblerowego generuje się przy przekazywaniu przez wartość. Jak masz 2 inty to dalej wygrywa przekazywanie rpzez wartość, a jak już 3 inty to wtedy mniej kodu generuje się przy przekazywaniu przez const referencję.
Z dziwnych rzeczy to ciekawe jest to że np. w mainie alokuje 16 bajtów na stosie zamiast 4. Tyle by mu spokojnie wystarczyło na zmienną x. Chyba że te nadmiarowe bajty są już z myślą o callu f-cji (wrzucenie na stos segment kodu, Instruction Pointer i rbp). Może dlatego są te 3 dodatkowe bajty.

1

@fvg:

fvg napisał(a):

Bo tyle sie naczytałem o tym, że jak odejmujesz od rbp to odwołujesz sie do zmiennych lokalnych a jak dodajesz to do argumentów funkcji

Jest wiele konwencji przekazywania parametrów: https://en.wikipedia.org/wiki/X86_calling_conventions

Ta zabytkowa wersja o której piszesz - poprzez stos, a.k.a. "pascal" - to chyba umarła ostatecznie w czasach, kiedy komputery miały połowę obecnej ilości bitów. Nie wiem, czy da się jeszcze zmusić kompilator by jej używał. Wszyscy i zawsze pakują tak dużo argumentów, ile się da, w rejestry.

Walczu...walczu...walczu... dobra, udało mi się, jak dołożysz do swojego programu:

struct dupadupa{
    char a[64];
};

int dupa(dupadupa dupadupadupa)
{
    dupadupadupa.a[0] = 0;
    return 0;
}

daje:

dupa(dupadupa):
        push    rbp
        mov     rbp, rsp
        mov     BYTE PTR [rbp+16], 0
        mov     eax, 0
        pop     rbp
        ret

W wyjątkowym przypadku argumentu tak wielkiego, że nie zmieści się do żadnego rejestru, konwencja rewertuje się do starego dobrego przekazywania przez stos.


W tym linku który podałem, możesz przeczytać (pytałeś dlaczego 16 bytów jest użyte):

In Linux, GCC sets the de facto standard for calling conventions. Since GCC version 4.5, the stack must be aligned to a 16-byte boundary when calling a function (previous versions only required a 4-byte alignment).[1][3]

1

sub rsp, 16 wynika z tego, że zgodnie z ABI, rsp zawsze powinno być podzielne przez 16. To co call robi to nie jest już zmartwienie wołającego, w kontekście alokowania dodatkowych zmiennych.

1 użytkowników online, w tym zalogowanych: 0, gości: 1