În acest laborator vom prezenta modul în care se realizează apeluri de funcții. Vom vedea cum putem folosi instrucțiunile call
și ret
pentru a realiza apeluri de funcții și cum folosim stiva pentru a transmite parametrii unei funcții.
Laboratorul este de forma learn by doing partea practică alternând între secțiuni de tip tutorial, cu parcurgere pas cu pas și prezentarea soluției, și exerciții care trebuie să fie rezolvate.
Pentru acest laborator vom folosi un sistem Microsoft Windows, asamblorul nasm și IDE-ul SASM. Pentru compilarea codului sursă C vom folosi compilatorul din suita Microsoft Visual Studio (cl
).
call
și ret
pentru a realiza un apel de funcțieÎn cadrul exercițiilor vom folosi arhiva de laborator.
Descărcați arhiva, decomprimați-o și accesați directorul aferent.
În SASM deschideți fișierul NASMHello.asm
, fișier din instalarea implicită de NASM și compilați-l și rulați-l. Observați afișarea mesajului Hello, world!
Build
din meniu, sau direct tasta F9
.
Deschideți în SASM fișierul hello-world.asm
din arhiva de laborator. Compilați-l și rulați-l și pe acesta și observați comportamentul.
Sunt câteva diferențe între cele două programe:
hello-world.asm
folosește apelul funcției puts
(funcție externă modulului curent) pentru a efectua a afișarea. Pentru aceasta pune argumentul pe stivă și apelează funcția.msg
din programul hello-world.asm
conține octeții 13
și 10
. Aceștia simbolizează caracterele carriage-return și line-feed, mai cunoscute și sub numele forma \r\n
, folosite pentru a adăuga o linie nouă pe Windows.
Încheierea cu \r\n
este, în general, utilă pentru afișarea șirurilor. Întrucât însă funcția puts
pune automat o linie nouă după șirul afișat, prezența acestor caractere este opțională. Este, însă, utilă în cazul folosirii funcției printf
.
După cum spuneam, în final, totul ajunge în limbaj de asamblare. Adesea ajungem să avem acces doar la codul obiect al unor programe și vrem să inspectăm modul în care arată.
Pentru a observa acest lucru, haideți să compilăm până la codul obiect un program scris în C și apoi să-l dezasamblăm. Este vorba de programul test.c
din arhiva de laborator.
Pentru a compila un program vom folosi linia de comandă și de acolo comanda cl
care reprezintă compilatorul și linker-ul din Visual Studio.
All apps
, apoi mergeți la litera V
, selectați directorul Visual Studio 2015
și alegeți opțiunea Visual Studio x86 Native Command Prompt
.cl <nume-fisier>.cpp
unde <nume-fisier>
este numele fișierului.
În cazul nostru, întrucât dorim doar să compilăm fișierul test.c
la modulul obiect, vom accesa din prompt-ul Visual Studio directorul în care se găsește fișierul și apoi vom rula comanda
cl /c test.c
În urma rulării comenzii de mai sus în directorul curent vom avea fișierul obiect test.obj.
Putem obține și forma în limbaj de asamblare a acestuia folosind comanda
cl /FAs test.c
În urma rulării comenzii de mai sus obținem fișierul test.asm
pe care îl putem vizualiza folosind comanda
type test.asm
Pentru a dezasambla codul unui modul obiect vom folosi un utilitar frecvent întâlnit în lumea Unix: objdump
. Pentru aceasta trebuie ca în prompt-ul Visual Studio sau în alt prompt să accesați directorul de binare al SASM. Este vorba de C:\Program Files (x86)\SASM\MinGW64\bin
. De acolo, pentru dezasamblare, vom rula comanda
.\objdump.exe -d <path-to-obj-file>
unde <path-to-obj-file>
este calea către fișierul obiect test.obj
.
Veți obține un output similar celui de mai jos
C:\Program Files (x86)\SASM\MinGW\bin>.\objdump.exe -d -M intel C:\Users\razvan\test.obj C:\Users\razvan\test.obj: file format pe-i386 Disassembly of section .text: 00000000 <_main>: 0: 55 push ebp 1: 8b ec mov ebp,esp 3: 6a 0f push 0xf 5: e8 00 00 00 00 call a <_main+0xa> a: 83 c4 04 add esp,0x4 d: 50 push eax e: 68 00 00 00 00 push 0x0 13: e8 00 00 00 00 call 18 <_main+0x18> 18: 83 c4 08 add esp,0x8 1b: 33 c0 xor eax,eax 1d: 5d pop ebp 1e: c3 ret 1f: cc int3 00000020 <_first_func>: 20: 55 push ebp 21: 8b ec mov ebp,esp 23: 51 push ecx 24: c7 45 fc 03 00 00 00 mov DWORD PTR [ebp-0x4],0x3 2b: 68 00 00 00 00 push 0x0 30: e8 00 00 00 00 call 35 <_first_func+0x15> 35: 83 c4 04 add esp,0x4 38: 8b 45 fc mov eax,DWORD PTR [ebp-0x4] 3b: 50 push eax 3c: 8d 4d 08 lea ecx,[ebp+0x8] 3f: 51 push ecx 40: e8 00 00 00 00 call 45 <_first_func+0x25> 45: 83 c4 08 add esp,0x8 48: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 4b: 8b e5 mov esp,ebp 4d: 5d pop ebp 4e: c3 ret 4f: cc int3 00000050 <_second_func>: 50: 55 push ebp 51: 8b ec mov ebp,esp 53: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 56: 8b 08 mov ecx,DWORD PTR [eax] 58: 03 4d 0c add ecx,DWORD PTR [ebp+0xc] 5b: 8b 55 08 mov edx,DWORD PTR [ebp+0x8] 5e: 89 0a mov DWORD PTR [edx],ecx 60: 5d pop ebp 61: c3
Există multe alte utilitare care permit dezasamblare de module obiect, majoritatea cu interfața grafică și oferind și suport pentru debugging. objdump
este un utilitar simplu care poate fi rapid folosit în linia de comandă.
Este interesant de urmărit, atât în fișierul test.asm
cât și în dezasamblarea sa, modul în care se face un apel de funcție, lucru despre care vom discuta în continuare.
Atunci când apelăm o funcție, pașii sunt următorii:
După cum știm, operațiile pe stivă sunt de două tipuri:
push val
în care valoarea val
este plasată pe stivăpop reg/mem
în care ce se găsește în vârful stivei se plasează în registru sau într-o zonă de memorie
În momentul în care se face push
spunem că stiva crește (se adaugă elemente). În mod oarecum paradoxal însă, pointerul de stivă (indicat de registrul esp
pe 32 de biți) scade. Acest lucru se întâmplă întrucât stiva crește în jos, de la adresa mari către adrese mici.
La fel, în momentul care facem pop
spunem că stiva scade (se scot elemente). Acum pointer-ul de stivă (indicat de registrul esp
pe 32 de biți) crește.
Un sumar al acestui lucru este explicat foarte bine la acest link: https://en.wikibooks.org/wiki/X86_Disassembly/The_Stack
După cum spuneam, în momentul în care realizăm un apel de funcție în assembly acesta se traduce în cei trei pași de mai sus. De exemplu, în fișierul hello-world.asm
am apelat funcția puts
folosind sintaxa:
push msg call puts add esp, 4
În primă fază am plasat pe stivă argumentul funcției puts
adică adresa șirului msg
. Apoi am apelat funcția puts
. Apoi am restaurat stiva (care crescuse prin apelul push
) adăugând 4
octeți (dimensiunea unui cuvânt pe 32 de biți) la registrul de stivă (esp
).
Astfel sunt traduse majoritatea apelurilor de funcții. Dacă urmărim dezasamblarea fișierul test.obj
putem observa acest șablon de apel și în alte părți. Mai jos sunt secvențele extrase din dezasamblarea de mai sus:
d: 50 push %eax e: 68 00 00 00 00 push $0x0 13: e8 00 00 00 00 call 18 <_main+0x18> 18: 83 c4 08 add $0x8,%esp 2b: 68 00 00 00 00 push $0x0 30: e8 00 00 00 00 call 35 <_first_func+0x15> 35: 83 c4 04 add $0x4,%esp 3b: 50 push %eax 3c: 8d 4d 08 lea 0x8(%ebp),%ecx 3f: 51 push %ecx 40: e8 00 00 00 00 call 45 <_first_func+0x25> 45: 83 c4 08 add $0x8,%esp
Contează mai puțin, pentru înțelegerea noastră din acest moment, de ce unele instrucțiuni arată cum arată, este importantă înțelegerea pașilor urmați pentru apelarea unei funcții: plasarea argumentelor pe stivă, apelul funcției, restaurarea stivei.
Pentru afișarea unui string în SASM putem folosi macro-ul PRINT_STRING
. Sau putem folosi o funcție precum puts
. În fișierul print-string.asm
este implementată afișarea unui string folosind macro-ul PRINT_STRING
.
Urmărind fișierul hello-world.asm
ca exemplu, implementați afișarea șirului folosind și puts
.
Programul print-string-len.asm
afișează lungimea unui șir folosind macro-ul PRINT_DEC
. Calculul lungimii șirului mystring
are loc în cadrul programului (este deja implementat).
Implementați programul pentru a face afișarea lungimii șirului folosind funcția printf
.
La sfârșit veți avea afișată de două ori lungimea șirului: o dată cu apelul macro-ului SASM PRINT_DEC
și apoi cu apelul funcției externe printf
.
printf
este de forma printf("String length is %u\n", len);
. Trebuie să construiți stiva pentru acest apel.
Pașii de urmat sunt:
printf
ca simbol extern."String length is %u", 13, 10, 0
.printf
, adică:printf
folosind call
.
Lungimea șirului se găsește în registrul ecx
.
În soluția de mai sus adăugați funcția reverse_string
astfel încât să aveți un listing similar celui de mai jos:
[...] section .text global CMAIN reverse_string: push ebp mov ebp, esp mov eax, dword [ebp+8] mov ecx, dword [ebp+12] add eax, ecx dec eax mov edx, dword [ebp+16] copy_one_byte: mov bl, byte [eax] mov byte [edx], bl dec eax inc edx loopnz copy_one_byte inc edx mov byte [edx], 0 leave ret CMAIN: push ebp mov ebp, esp [...]
Funcția reverse_string
inversează un șir și are următoarea signatură: void reverse_string(const char *src, size_t len, char *dst);
. Astfel ca primele len
caractere și șirul src
sunt inversate în șirul dst
.
Realizați inversarea șirului mystring
într-un nou șir și afișați acel nou șir.
store_string times 64 db 0
Construcția creează un șir de 64 de octeți de zero, suficient pentru a stoca inversul șirului.
Apelul echivalent în C al funcției este reverse_string(mystring, ecx, store_string);
. În registrul ecx
am presupus că este calculată lungimea șirului.
Nu puteți folosi direct valoarea ecx
în forma ei curentă. După apelul funcției printf
pentru afișare numărului valoarea ecx
se pierde. Ca să o păstrați, aveți două opțiuni:
ecx
în prealabil pe stivă (folosind push ecx
înaintea apelului printf
) și apoi să o restaurați după apelul printf
(folosind pop ecx
).ecx
într-o variabilă globală, pe care o definiți în secțiunea .data
.
Nu puteți folosi un alt registru pentru că sunt șanse foarte mari ca și acel registru să fie modificat de apelul printf
pentru afișarea lungimii șirului.
Atunci când apelăm o funcție spune că funcția care apelează (contextul care apelează) se cheamă apelant (sau caller) iar funcția apelată se cheamă apelat (sau callee). Până acum am discutat despre cum arată lucrurile la nivelul apelantului (cum construim stiva acolo). Haideți să urmărim ce se întâmplă la nivelul apelatului.
Până în momentul instrucțiunii call
stiva conține parametrii funcției. Apelul call
poate fi echivalat grosier următoarei secvențe:
push eip jmp function_name
Adică și apelul call
folosește în continuare stiva și salvează adresa următoarei instrucțiuni, cea de după call
numită și instrucțiunea de retur sau adresa de retur (return value, return address). Aceasta este necesară pentru a ști, în apelat, unde să revenim.
Suplimentar, în apelat, la începutul său (numit preambul, preamble) se salvează frame pointer-ul (în arhitectura i386 este vorba de registrul ebp
) urmând ca frame pointer-ul să refere adresa curentă de pe stivă (adică tocmai fostul frame pointer). Deși nu este obligatorie, salvarea frame pointer-ului ajută la debugging și este în cele mai multe cazuri folosită. Din aceste motive, orice apel de funcție va avea în general, preambulul:
push ebp mov ebp, esp
Aceste modificări au loc în apelat. De aceea este responsabilitatea apelatului să restaureze stiva la vechea sa valoare. De aceea este uzuală existența unui epilog care să readucă stiva la starea sa inițială; acest epilog este:
leave
În acest moment stiva este ca la începutul funcției, adică imediat după call
, referind adresa de retur. Urmează apelul
ret
care este grosier echivalentul instrucțiunii:
pop eip
Adică ia valoarea din vârful stivei și o plasează în eip
urmând continuarea execuției programului de la acea adresă.
Un sumar al acestui comportament, plasat pe apelul echivalent reverse_string(mystring, len, store_string);
este indicat în diagrama de mai jos, accesibilă în format PDF și original DIA la adresa: http://elf.cs.pub.ro/asm/res/laboratoare/lab-04-img/
De observat că pe parcursul execuției funcției, ceea ce nu se schimbă este poziția frame pointer-ul. Acesta este și motivul denumirii sale: pointează la frame-ul curent de funcției. De aceea este comun ca accesarea parametrilor unei funcții să se realizeze prin intermediul frame pointer-ului. Presupunând un sistem pe 32 de biți și parametri de dimensiunea cuvântului procesorului (32 de biți, 4 octeți) vom avea:
ebp+8
ebp+12
ebp+16
Acesta este motivul pentru care, pentru a obține parametrii funcției reverse_string
în registrele respectiv eax
, ecx
, edx
, folosim construcțiile:
mov eax, dword [ebp+8] ; retrieve first function argument in eax mov ecx, dword [ebp+12] ; retrieve second function argument in ecx mov edx, dword [ebp+16] ; retrieve third function argument in edx
Ne propunem implementarea funcției toupper
care traduce literele mici în litere mari. Pentru aceasta, porniți de la fișierul toupper.asm
din arhiva de exerciții a laboratorului și completați corpul funcției topupper
.
Șirul folosit este mystring
și presupunem că este un șir valid. Acest șir este transmis ca argument funcției toupper
în momentul apelului.
Faceți înlocuirea in place, nu este nevoie de un alt șir.
0x20
din valoare. Aceasta este diferența între litere mici și mari; de exemplu a
este 0x61
iar A
este 0x41
. Puteți vedea în pagina de manual ascii.
Ca să citiți sau să scrieți octet cu octet folosiți construcția byte [reg]
așa cum apare și în implementarea determinării lungimii unui șir în fișierul print-string-len.asm
, unde [reg]
este registrul de tip pointer în care este stocată adresa șirului în acel punct.
Vă opriți atunci când ați ajuns la valoarea 0
(NUL
byte). Pentru verificare puteți folosi test
așa cum se întâmplă și în implementarea determinării unui șir în fișierul print-string-len.asm
.
Implementați funcția toupper
astfel încât translatarea să aibă loc doar pentru caractare reprezentând litere mici, nu litere mari sau alte tipuri de caractere.
Realizați și folosiți o funcție care face translatarea rot13 a unui șir.
Implementați rot13
pe un array de șiruri: șirurile sunt continue în memorie separate prin terminatorul de șirul (NUL
-byte, 0
). De exemplu: ana\0are\0mere\0
este un array de trei șiruri.
.data
, de forma
len dd 10
în care să rețineți fie lungimea totală a șirului (de la începutul până la ultimul NUL
-byte), fie numărul de șiruri din array.