Zona stivei şi transferul execuţiei; Turbo Debugger

Mecanismul de execuţie a programului are la bază însăşi construcţia CPU, prin specializarea corespunzătoare a anumitor regiştri (denumiţi Instruction Pointer, Stack Pointer, Base Pointer) şi prin prevederea unor instrucţiuni corespunzătoare (CALL, RET, PUSH, POP); sistemul de operare intervine şi el în acest mecanism, măcar pentru faptul că trebuie să aloce fiecărui program care intră în execuţie anumite resurse de memorie, între care o "zonă de cod" şi o zonă de stivă ("stack") - ceea ce iarăşi, se bazează pe existenţa regiştrilor CPU denumiţi Code Segment şi Stack Segment.

Pentru a evidenţia aceste elemente, plecăm de la un program simplu şi-l "urmărim" folosind Turbo Debugger - utilitar inclus în pachetul Borland C++ (pentru Windows; la fel de bine desigur, putem folosi produse similare, pe Linux: "GNU's GDB Debugger", sau un "front-end" precum Insight Debugger).

Click pe butonul Start al Windows-ului, click pe meniul Run... şi lansăm cmd.exe (interpretorul de comenzi din Windows, vechiul COMMAND.COM din DOS; dacă sistemul este instalat cum se cuvine, dispunem de "comanda" debug şi de edit - programul DOS pentru editarea de fişiere text obişnuite). Fie folosind C:\Teste>edit limb2.c, fie folosind comanda C:Teste>copy con limb2.c ("CON" este rezervat în Windows pentru "consolă", permiţând copierea de la tastatură într-un fişier, sau dintr-un fişier pe ecran) - creem fişierul "limb2.c", prezentat în prima imagine de mai jos.

Apelăm BCC, dar folosind opţiunile de compilare -v (include în executabil informaţii necesare debugg-erului, de exemplu tabelul simbolurilor) şi -p (foloseşte convenţia de apelare Pascal, în loc de convenţia de apelare C): bcc -v -p limb2.c. Ca urmare, se obţin fişierele "limb2.OBJ" (care conţine codul obiect) şi executabilul "limb2.exe". Apoi, lansăm Turbo Debbuger, tot de pe linia de comandă C:\Teste>td limb2.c şi obţinem:

Accesând meniul View, opţiunea Variables - am obţinut fereastra "Variables"; derulând, vedem şi simbolurile noastre: MY_VAR are (la momentul încărcării pentru execuţie) valoarea 7777 = 0x1E61, iar MY_FUNCTIE are adresa 1369:02A7 (codul funcţiei my_functie() este memorat în segmentul 0x1369, începând de la offsetul 0x02A7). Observând ce se afişează în fereastra "Variables", trebuie să fie clar: pe lângă variabilele explicitate în programul respectiv, compilatorul asociază oricărui program, în mod standard, numeroase alte variabile, corespunzând opţiunilor prevăzute pentru compilare, diverselor biblioteci incluse, etc.

Accesând View, CPU obţinem:

Fereastra CPU are cinci diviziuni ("panel"); se poate trece din una în alta folosind tasta TAB (sau prin click-stânga în panoul respectiv); click-dreapta în interiorul unui panou va deschide un meniu local, cu opţiuni pentru panoul respectiv. Un panou reflectă zona de cod a programului încărcat pentru depanare, un altul redă conţinutul curent al regiştrilor CPU (click-dreapta va permite comutarea între afişarea regiştrilor de 16 biţi AX, etc. şi afişarea regiştrilor extinşi EAX, etc.); un panou redă starea curentă a flagurilor microprocesorului (c corespunde flagului Carry, z corespunde flagului Zero, etc.); panoul în care apare SS ("Stack Segment") reflectă conţinutul curent al zonei stivă asociate programului; în sfârşit, un panou este destinat să redea conţinutul curent al altor zone de memorie (pe figură - conţinutul zonei DS:0).

Maximizăm fereastra CPU; clicând în panoul datelor şi apoi click-dreapta, deschidem meniul local aferent:

Am folosit opţiunea Goto... indicând ca adresă de poziţionare a panoului DS:0x00A8; aceasta este adresa variabilei "my_var" şi am determinat-o citind în panoul de cod:

#LIMB2#5: my_var = 1234;
   cs:0296 C706A800D204   mov word ptr [MY_VAR],04D2

Instrucţiunea codificată la adresa CS:0x0296 este de tipul "încarcă la adresa, data", unde aici adresa=[MY_VAR] şi data=04D2; 0xC706 este partea de "opcode" a instrucţiunii, iar "A800" reprezintă adresa relativă 0x00A8 (în segmentul implicit DS) şi "D204" reprezintă data de încărcat, 0x04D2; deducem că adresa variabilei "my_var" este DS:0x00A8.

În panoul de cod, se foloseşte semnul pentru a marca instrucţiunea care urmează să fie pusă în execuţie când se apasă tasta funcţională F7 ("Trace"). Punctul de start al execuţiei programului este adresa CS:0293▶ şi vedem pe panoul regiştrilor că IP = 0293. Registrul IP (sau extins, EIP) este menit să păstreze adresa relativă (în cadrul segmentului de cod CS) a instrucţiunii care urmează să se execute; tastând F7, se va executa instrucţiunea de adresă CS:IP şi IP va fi avansat sau poziţionat automat pentru a viza următoarea instrucţiune de executat.

Imaginea de mai sus redă starea programului după ce s-a tastat de trei ori F7; ultima instrucţiune executată a fost cea de la adresa CS:0296 (şi efectul ei se vede în panoul datelor: la adresa DS:00A8 a variabilei "my_var", a apărut valoarea 0x04D2, în locul valorii anterioare 0x1E61). IP = 029C vizează acum instrucţiunea prin care se apelează my_functie(): call MY_FUNCTIE, având codul constituit din octeţii E8, 08, 00. Apelarea funcţiei înseamnă rezolvarea a două probleme:
   — activarea codului funcţiei my_functie(), de la adresa CS:02A7; pentru ca my_functie() să intre în execuţie trebuie ca adresa relativă 0x02A7 să fie "adusă" în registrul IP;
   — după încheierea executării codului my_functie(), va trebui continuată execuţia funcţiei main(), începând de la adresa CS:029F (a instrucţiunii "my_var += 1111", care urmează în main() după "call MY_FUNCTIE").

Ambele probleme ar fi banale, dacă IP ar putea fi încărcat direct (precum regiştrii obişnuiţi) cu o valoare dată: după IP = 0x02A7 (încărcare directă în IP a adresei MY_FUNCTIE), s-ar declanşa execuţia codului MY_FUNCTIE, urmând ca la încheiere să punem IP = 0x29F, revenind în main(). S-a evitat însă o asemenea "soluţie": a lăsa posibilitatea de a folosi IP la fel cum este folosit orice alt registru (în particular, de a încărca IP cu o adresă oarecare) ar crea riscuri inutile în funcţionarea sistemului, având în vedere rolul cheie dedicat acestui registru (de a păstra mereu adresa relativă a instrucţiunii care urmează a fi executate); pe de altă parte, s-ar încălca subtil principiul adresării relative, atrăgând limitări importante. Registrul IP a rămas un registru privat al CPU, inaccesibil în mod direct (prin instrucţiuni explicite); dar există posibilitatea de a fixa în IP o anumită adresă, operând cu deplasamente faţă de valoarea curentă din IP.

Să lămurim ce înseamnă adresa curentă din IP. În momentul când IP = 029C, se va pune în execuţie instrucţiunea de la această adresă, codificată prin octeţii E8 08 00: se preia şi se decodifică primul octet E8, iar IP este avansat pentru a referi următorul octet din zona de cod (IP = 029D); codul E8 fiind codul unei operaţii de apel de subrutină, urmează să se preia şi următorii doi octeţi, pentru a completa informaţia necesară executării operaţiei (precizând deplasamentul faţă de valoarea curentă IP, a adresei rutinei de apelat); cum la fiecare preluare de câte un octet, IP este automat incrementat pentru a referi următorul octet din zona de cod - rezultă că în final vom avea IP = 029F (indicând - conform rolului preconizat pentru acest registru - adresa următoarei instrucţiuni din zona de cod).

Prin urmare, adresa curentă din IP - faţă de care trebuie operată deplasarea până la adresa rutinei apelate - este în cazul nostru IP = 029F (adresa instrucţiunii următoare celei de apelare). Adunând deplasamentul indicat (de către cei doi octeţi 08, 00 care urmează după codul E8 al operaţiei), deci adunând 0x0008 - vom obţine în IP valoarea 0x029F + 0x0008 = 0x02A7, adică exact adresa subrutinei MY_FUNCTIE de apelat (desigur, calculul deplasamentelor este lăsat de obicei în seama asamblorului).

A doua problemă, problema revenirii în programul apelant, nu poate fi rezolvată decât:
   — reţinând adresa de revenire CS:029F şi
   — prevăzând o posibilitate de a fixa IP = 0x029F după ce MY_FUNCTIE s-a încheiat.

Ar fi suficient să dispunem de un registru special JP, în care să se salveze valoarea curentă a lui IP (JP = 0x029F), urmând ca după execuţia subrutinei MY_FUNCTIE să se reconstituie IP = JP. Dar o asemenea idee are defectul major că nu permite apeluri imbricate: dacă din main() se apelează functieA() şi din functieA() se apelează functieB(), atunci trebuie revenit din functieB() în functieA(), apoi din functieA() în main() - deci ar aparea necesitatea unui al doilea registru JP…

Soluţia la care s-a ajuns constă în asocierea la program a unei a treia zone în memoria internă, pe lângă zona de date şi zona de cod - numită zona stivei; registrul SS Stack Segment este destinat să păstreze adresa de bază a segmentului de memorie care va fi alocat zonei stivei (sau să o conţină la un moment sau altul). Executând instrucţiunea marcată în imaginea precedentă (tastând F7, nu F8), obţinem:

Vedem că s-a depus în zona stivei (la adresa SS:FFF4) adresa 0x029F (drept adresa de revenire din my_functie() în main()) şi că se va trece la execuţia subprogramului MY_FUNCTIE (s-a adunat deplasamentul 0x0008 la valoarea precedentă IP = 0x029F, obţinând IP = 0x02A7, conform calculului redat mai sus); la încheierea execuţiei acestuia (tastăm de cinci ori F7), se va scoate în IP adresa de revenire existentă în zona stivei:

Desigur, după ce IP a preluat adresa de revenire, nu mai este necesară reţinerea ei în zona stivei şi este de dorit eliberarea locului respectiv în vederea unei reutilizări. Având în vedere şi cerinţa de a face posibile apeluri imbricate, rezultă că zona stivei trebuie întreţinută ca o structură Last In First Out; într-adevăr, ordinea de revenire este inversă cu ordinea de apelare (de exemplu, dacă P apelează P1, iar P1 apelează P2, atunci ordinea de apelare este P-->P1-->P2, iar ordinea revenirilor este P2-->P1-->P).

În scopul asigurării acestui mecanism, CPU a fost prevăzut cu registrul SP Stack Pointer, care are rolul de a păstra adresa relativă faţă de baza stivei indicată de SS, a ultimei valori (sau adresă de revenire, după caz) depuse spre păstrare în zone stivei; când adresa de revenire indicată de SP este scoasă în IP, ea este şi "eliminată" din zona stivei - în sensul că SP este poziţionat automat pentru a indica valoarea precedentă celei tocmai "ieşite" din stivă.

Pentru a evita restricţionări de genul "zona de cod sau de date se poate extinde numai până la cutare adresă, fiindcă de la această adresă începe zona stivei" - s-a optat pentru o construcţie descrescătoare a zonei stivei; la depunerea în stivă, SP scade cu 2 (dacă se depune un "word"), iar la "scoatere" din stivă SP creşte cu 2 (indicând precedenta valoare depusă).

Pentru depunerea unei adrese în zona stivei (respectiv pentru scoaterea ultimei valori depuse), CPU a fost prevăzut cu instrucţiuni care, în limbajele de asamblare intră în cadrul mnemonicei PUSH (respectiv POP). Efectul vizat pentru o instrucţiune "push REG" este următorul: se decrementează SP cu 2, pentru a indica locul noii valori depuse drept "ultima" în zona stivei şi în locul indicat acum de SP, se depune conţinutul registrului REG. Iar "pop REG" are efectul următor: valoarea indicată de SP (adică valoarea existentă în stivă la offsetul SP faţă de baza SS; în panoul stivei ea este marcată prin ▶) este transferată în REG şi apoi SP este incrementat cu 2, indicând precedenta valoare existentă în zona stivei.

Efectul instrucţiunii call P1 este următorul: se decrementează SP cu 2, se depune în locul indicat acum de SP adresa curentă din IP (în cazul nostru - 0x029F, care este adresa de revenire), apoi se determină adresa la care se face transferul execuţiei (adunând la IP deplasamentul corespunzător) şi aceasta devine noua valoare a lui IP.

După încheierea execuţiei subprogramului apelat astfel, urmează să se "scoată" adresa de revenire din zona stivei, revenind în programul apelant; să observăm o ultimă problemă importantă: cum anume poate şti CPU că execuţia subprogramului este încheiată şi că urmează să se revina la adresa care a fost salvată în zona stivei prin instrucţiunea de apel "call"? Pentru rezolvarea problemei încheierii execuţiei unui subprogram şi revenirii în programul apelant s-au prevăzut microprocesorului instrucţiuni care în limbajele de asamblare au mnemonica RET (de la RETurn). O instrucţiune RET încheie execuţia subprogramului prin faptul că determină transferarea în IP a adresei indicate de SP (iar SP urcă cu 2) - ceea ce înseamnă că execuţia va continua de la noua adresă adusă în CS:IP (şi implicit, subprogramul şi-a încheiat execuţia, revenindu-se în programul apelant).

În general, fiecare program deţine în cursul execuţiei sale, o zonă stivă proprie; însă, pentru gestionarea stivei proprii - fiecare program va trebui să folosească aceiaşi doi regiştri, SS şi SP; rezultă că în principiu, un program care întrerupe execuţia în curs a unui alt program, va trebui să salveze în prealabil valorile găsite de el în SS şi în SP (folosind apoi SS şi SP pentru propria execuţie), urmând ca la încheierea intervenţiei sale să reconstituie valorile SS şi SP proprii programului pe care l-a întrerupt. Apelarea unui subprogram este în mod implicit, o întrerupere a execuţiei programului apelant - astfel că fiecare program apelat trebuie să salveze măcar SP-ul corespunzător apelantului (urmând a-l reconstitui înainte de RET); se vede într-adevăr, pe imaginile de mai sus că şi main() şi my_functie() debutează fiecare prin push BP; mov BP, SP şi încheie fiecare prin pop BP; ret; astfel, fiecare (folosind registrul BP Base Pointer) salvează în zona stivă şi în final reconstituie, adresa SP corespunzătoare "cadrului-stivă" al apelantului.

Observaţii

   — Este de aşteptat ca toate aceste instrucţiuni de lucru cu zona stivei (push, pop, call, ret) să necesite mai mult timp pentru a fi realizate, în comparaţie cu acele instrucţiuni care angajează doar regiştrii interni - dat fiind că ele implică accesarea prin intermediul magistralelor, a unei zone de memorie externe microprocesorului. Este tentantă folosirea zonei stivei drept zonă de salvări temporare a unor rezultate intermediare - dar ca timp, va fi mai economic de efectuat astfel de memorări temporare nu în zona stivei, ci în regiştrii interni găsiţi disponibili în momentul respectiv.

   — Dacă există multe subprograme de apelat (inclusiv, apelarea multiplu repetată a unui subprogram), timpul de execuţie a programului creşte în mod artificial, datorită cumulării timpilor necesari execuţiei instrucţiunilor CALL; este un timp consumat nu pentru realizarea propriu-zisă a scopurilor programului, ci pentru pasarea sarcinilor către executanţi! Este de semnalat însă şi posibilitatea (dar nu în limbaj de nivel înalt) de "programare fără CALL" (dar cu funcţii, sau subprograme), evitând timpul suplimentar cerut de execuţia instrucţiunilor de intrare şi ieşire în/din subprogram; pentru aceasta, codul programului ar trebui să fie o stivă "PROG" de adrese ale subrutinelor ce vor trebui apelate, adrese stivuite în ordinea apelării lor; execuţia programului constă atunci în execuţia succesivă a subrutinelor din stiva "PROG", declanşată prin forţarea initială SP = PROG şi execuţia imediată a unui RET - ca urmare, IP va prelua prima adresă înregistrată în zona "PROG", determinând execuţia subrutinei respective, iar SP va coborî cu 2; încheierea execuţiei subrutinei (RET-ul acesteia) va determina scoaterea din stiva "PROG" a următoarei adrese şi activarea subrutinei respective, SP coborând încă cu 2 şi referind adresa subrutinei care va prelua controlul după RET-ul celei în curs de execuţie, ş.a.m.d. Această idee - metoda salturilor indirecte - este folosită de exemplu, pentru scrierea programelor interpretoare de comenzi (a vedea de exemplu, COMMAND.COM din DOS 5.0, dezasamblând primii 24 octeţi).