(Zrobiłem podsumowanie całego kodu w tej odpowiedzi na wypadek, gdybyś chciał się nim bawić)
Podczas mojego kursu CS101 w 2003 roku robiłem tylko najbardziej podstawowe rzeczy w asm. I nigdy tak naprawdę nie rozumiałem, jak działają asm i stos, dopóki nie zdałem sobie sprawy, że wszystko to w zasadzie przypomina programowanie w C lub C ++ ... ale bez lokalnych zmiennych, parametrów i funkcji. Prawdopodobnie nie brzmi to jeszcze łatwo :) Pozwól, że ci pokażę (dla asm x86 ze składnią Intela ).
1. Co to jest stos
Stos to zwykle ciągły fragment pamięci przydzielony dla każdego wątku przed ich uruchomieniem. Możesz tam przechowywać co tylko zechcesz. W terminach C ++ ( fragment kodu 1 ):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2. Góra i dół stosu
W zasadzie możesz przechowywać wartości w losowych komórkach stack
tablicy ( fragment # 2.1 ):
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
Ale wyobraź sobie, jak trudno byłoby zapamiętać, które komórki stack
są już w użyciu, a które są „wolne”. Dlatego przechowujemy nowe wartości na stosie obok siebie.
Jedną dziwną rzeczą związaną ze stosem (x86) asm jest to, że dodajesz tam rzeczy zaczynając od ostatniego indeksu i przechodzisz do niższych indeksów: stos [999], następnie stos [998] i tak dalej ( fragment # 2.2 ):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
I nadal (uwaga, teraz będziesz zdezorientowany) „oficjalna” nazwa stack[999]
znajduje się na dole stosu .
Ostatnia używana komórka ( stack[997]
w powyższym przykładzie) jest nazywana szczytem stosu (zobacz Gdzie wierzchołek stosu znajduje się na x86 ).
3. Wskaźnik stosu (SP)
Na potrzeby tego omówienia załóżmy, że rejestry procesora są reprezentowane jako zmienne globalne (patrz Rejestry ogólnego przeznaczenia ).
int AX, BX, SP, BP, ...;
int main(){...}
Istnieje specjalny rejestr procesora (SP), który śledzi szczyt stosu. SP jest wskaźnikiem (przechowuje adres pamięci, taki jak 0xAAAABBCC). Ale na potrzeby tego postu użyję go jako indeksu tablicy (0, 1, 2, ...).
Po uruchomieniu wątku SP == STACK_CAPACITY
program i system operacyjny modyfikują go w razie potrzeby. Zasada jest taka, że nie można zapisywać na stosie komórek poza wierzchołkiem stosu, a każdy indeks mniejszy niż SP jest nieprawidłowy i niebezpieczny (z powodu przerwań systemowych ), więc
najpierw zmniejszamy SP, a następnie zapisujemy wartość do nowo przydzielonej komórki.
Jeśli chcesz umieścić kilka wartości na stosie w rzędzie, możesz zarezerwować miejsce na wszystkie z nich z góry ( fragment 3 ):
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
Uwaga. Teraz możesz zobaczyć, dlaczego alokacja na stosie jest tak szybka - to tylko jeden dekrement rejestru.
4. Zmienne lokalne
Przyjrzyjmy się tej uproszczonej funkcji ( fragment nr 4.1 ):
int triple(int a) {
int result = a * 3;
return result;
}
i przepisz go bez użycia zmiennej lokalnej ( fragment # 4.2 ):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
i zobacz, jak to się nazywa ( fragment # 4.3 ):
someVar = triple_noLocals(11);
SP += 1;
5. Push / pop
Dodanie nowego elementu na szczycie stosu jest tak częsta operacja, że procesory mają specjalną dyspozycję, że push
. Zaimplementujemy to w ten sposób ( fragment 5.1 ):
void push(int value) {
--SP;
stack[SP] = value;
}
Podobnie, biorąc górny element stosu ( fragment 5.2 ):
void pop(int& result) {
result = stack[SP];
++SP;
}
Typowy wzorzec użycia push / pop tymczasowo oszczędza pewną wartość. Powiedzmy, że mamy coś pożytecznego w zmiennej myVar
iz jakiegoś powodu musimy wykonać obliczenia, które ją nadpiszą ( fragment 5.3 ):
int myVar = ...;
push(myVar);
myVar += 10;
...
pop(myVar);
6. Parametry funkcji
Teraz przekażmy parametry za pomocą stosu ( fragment # 6 ):
int triple_noL_noParams() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11);
assert(triple(11) == triple_noL_noParams());
SP += 2;
}
7. return
oświadczenie
Zwróćmy wartość w rejestrze AX ( fragment nr 7 ):
void triple_noL_noP_noReturn() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1;
}
void main(){
...
push(AX);
push(11);
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1;
pop(AX);
...
}
8. Wskaźnik podstawy stosu (BP) (znany również jako wskaźnik ramki ) i ramka stosu
Weźmy bardziej „zaawansowaną” funkcję i przepiszmy ją w naszym C ++ podobnym do asm ( fragment # 8.1 ):
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() {
SP -= 2;
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2;
}
int main(){
push(AX);
push(22);
push(11);
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
Teraz wyobraź sobie, że zdecydowaliśmy się wprowadzić nową zmienną lokalną, aby przechowywać tam wynik przed zwróceniem, tak jak robimy to w tripple
(snippet # 4.1). Treść funkcji będzie wyglądać następująco ( fragment # 8.2 ):
SP -= 3;
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
Widzisz, musieliśmy zaktualizować każde odwołanie do parametrów funkcji i zmiennych lokalnych. Aby tego uniknąć, potrzebujemy indeksu kotwicy, który nie zmienia się, gdy stos rośnie.
Utworzymy kotwicę zaraz po wejściu do funkcji (zanim przydzielimy miejsce dla lokalnych), zapisując bieżącą górę (wartość SP) do rejestru BP. Fragment nr 8.3 :
void myAlgo_noLPR_withAnchor() {
push(BP);
BP = SP;
SP -= 2;
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
Kawałek stosu, który należy do funkcji i ma pełną kontrolę nad nią, nazywany jest ramką stosu funkcji . Np. myAlgo_noLPR_withAnchor
Ramka stosu to stack[996 .. 994]
(obie idexy włącznie).
Ramka zaczyna się od BP funkcji (po zaktualizowaniu jej wewnątrz funkcji) i trwa do następnej ramki stosu. Zatem parametry na stosie są częścią ramki stosu wywołującego (patrz uwaga 8a).
Uwagi:
8a. Wikipedia mówi inaczej o parametrach, ale tutaj trzymam się instrukcji programisty Intela , patrz t. 1, część 6.2.4.1 Wskaźnik podstawy stosu ramki i rysunek 6-2 w sekcji 6.3.2 Dalsza operacja wywołania i RET . Parametry funkcji i ramka stosu są częścią rekordu aktywacji funkcji (zobacz Generowanie dziennika funkcji ).
8b. dodatnie przesunięcia od punktu BP do parametrów funkcji, a ujemne przesunięcia wskazują na zmienne lokalne. Jest to bardzo przydatne do debugowania
8c. stack[BP]
przechowuje adres poprzedniej ramki stosu,stack[stack[BP]]
przechowuje poprzednią ramkę stosu i tak dalej. Podążając za tym łańcuchem, możesz odkryć ramki wszystkich funkcji w programie, które jeszcze nie wróciły. W ten sposób debugery pokazują wywołanie stosu
8d. pierwsze 3 instrukcje myAlgo_noLPR_withAnchor
, w których ustawiamy ramkę (zapisz stary BP, zaktualizuj BP, zarezerwuj miejsce dla lokalnych), nazywają się prologiem funkcji
9. Konwencje przywoławcze
We fragmencie 8.1 przesunęliśmy parametry myAlgo
od prawej do lewej i zwróciliśmy wynik w postaci AX
. Równie dobrze moglibyśmy przekazać parametry od lewej do prawej i wrócić BX
. Lub przekaż parametry w BX i CX i wróć w AX. Oczywiście caller ( main()
) i wywoływana funkcja muszą zgadzać się gdzie i w jakiej kolejności wszystkie te rzeczy są przechowywane.
Konwencja wywoływania to zestaw reguł dotyczących przekazywania parametrów i zwracania wyniku.
W powyższym kodzie użyliśmy konwencji wywoływania cdecl :
- Parametry są przekazywane na stosie, przy czym pierwszy argument znajduje się na najniższym adresie stosu w momencie wywołania (odłożony ostatni <...>). Obiekt wywołujący jest odpowiedzialny za zdejmowanie parametrów ze stosu po wywołaniu.
- zwracana wartość jest umieszczana w AX
- EBP i ESP muszą być zachowane przez wywoływanego (
myAlgo_noLPR_withAnchor
funkcję w naszym przypadku), tak aby wywołujący ( main
funkcja) mógł polegać na tych rejestrach, które nie zostały zmienione przez wywołanie.
- Wszystkie inne rejestry (EAX, <...>) mogą być dowolnie modyfikowane przez odbiorcę; jeśli wywołujący chce zachować wartość przed i po wywołaniu funkcji, musi zapisać wartość w innym miejscu (robimy to z AX)
(Źródło: przykład „32-bit cdecl” ze Stack Overflow Documentation; prawa autorskie 2016 icktoofay i Peter Cordes ; licencja na licencji CC BY-SA 3.0. Archiwum pełnej zawartości dokumentacji Stack Overflow można znaleźć na archive.org, w której ten przykład jest indeksowany według ID tematu 3261 i ID przykładu 11196).
10. Wywołania funkcji
Teraz najciekawsza część. Podobnie jak dane, kod wykonywalny jest również przechowywany w pamięci (całkowicie niezwiązany z pamięcią stosu), a każda instrukcja ma adres.
Jeśli nie wydano innego polecenia, CPU wykonuje instrukcje jedna po drugiej, w kolejności, w jakiej są one przechowywane w pamięci. Ale możemy nakazać procesorowi „przeskoczyć” do innego miejsca w pamięci i wykonać instrukcje z tego miejsca. W asm może to być dowolny adres, aw bardziej zaawansowanych językach, takich jak C ++, można przeskakiwać tylko do adresów oznaczonych etykietami ( istnieją obejścia, ale nie są one co najmniej ładne).
Weźmy tę funkcję ( fragment nr 10.1 ):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
Zamiast wywoływać tripple
C ++ w sposób, wykonaj następujące czynności:
- skopiuj
tripple
kod na początek myAlgo
treści
- przy
myAlgo
wejściu przeskocz tripple
kod za pomocągoto
- kiedy musimy wykonać
tripple
kod, zapisz na stosie adres linii kodu zaraz po tripple
wywołaniu, abyśmy mogli wrócić tutaj później i kontynuować wykonywanie ( PUSH_ADDRESS
makro poniżej)
- przeskocz na adres pierwszej linii (
tripple
funkcji) i wykonaj go do końca (3. i 4. razem to CALL
makro)
- na końcu
tripple
(po wyczyszczeniu lokalnych), weź adres zwrotny z góry stosu i wskocz tam ( RET
makro)
Ponieważ w C ++ nie ma łatwego sposobu, aby przejść do konkretnego adresu kodowego, będziemy używać etykiet do oznaczania miejsc skoków. Nie będę szczegółowo opisywać, jak działają poniższe makra, po prostu uwierz mi, że robią to, co mówię ( fragment # 10.2 ):
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP);
BP = SP;
SP -= 2;
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777);
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1;
SP += 2;
pop(AX);
}
Uwagi:
10a. ponieważ adres zwrotny jest przechowywany na stosie, w zasadzie możemy go zmienić. Tak działa atak niszczący stosy
10b. ostatnie 3 instrukcje na „końcu” triple_label
(czyszczenie miejscowych, przywracanie starego BP, powrót) nazywane są epilogiem funkcji
11. Montaż
Teraz spójrzmy na prawdziwy asm dla myAlgo_withCalls
. Aby to zrobić w programie Visual Studio:
- ustaw platformę kompilacji na x86 ( nie x86_64)
- typ kompilacji: Debug
- ustaw punkt przerwania gdzieś w myAlgo_withCalls
- uruchom, a gdy wykonanie zatrzyma się w punkcie przerwania, naciśnij Ctrl + Alt + D
Jedyną różnicą w porównaniu z naszym C ++ podobnym do asm jest to, że stos asm działa na bajtach zamiast na liczbach int. Aby zarezerwować miejsce na jeden int
, SP zostanie zmniejszone o 4 bajty.
Zaczynamy ( fragment nr 11.1 , numery wierszy w komentarzach pochodzą z sedna ):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
A asm dla tripple
( fragment # 11.2 ):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
Mam nadzieję, że po przeczytaniu tego posta montaż nie wygląda tak tajemniczo jak wcześniej :)
Oto linki z treści posta i dalsze lektury:
- Eli Bendersky , gdzie szczyt stosu jest na x86 - góra / dół, push / pop, SP, ramka stosu, konwencje wywoływania
- Eli Bendersky , Układ ramki stosu na x86-64 - argumenty przekazywane na x64, ramka stosu, czerwona strefa
- University of Mariland, Understanding the Stack - naprawdę dobrze napisane wprowadzenie do koncepcji stosu. (Dotyczy MIPS (nie x86) i składni GAS, ale nie ma to znaczenia dla tematu). Jeśli jesteś zainteresowany, zobacz inne uwagi na temat programowania MIPS ISA .
- x86 Asm wikibook, Rejestry ogólnego przeznaczenia
- x86 Demontaż wikibook, The Stack
- x86 Demontaż wikibook, Funkcje i ramki stosu
- Podręczniki programisty Intela - spodziewałem się, że będzie naprawdę hardkorowe, ale, co zaskakujące, jest dość łatwe do przeczytania (choć ilość informacji jest przytłaczająca)
- Jonathan de Boyne Pollard, Perilogues gen na temat funkcji - prolog / epilog, ramka stosu / rekord aktywacji, czerwona strefa