Aukšto lygio kodo reprezentacija žemo lygio kodu IA-32 šeimos procesoriams
Povilas Tumėnas, 2006-04-07 17:00:25

Na, jei jau paklausėt, tai pavadinimo geresnio tikrai nebegalėjau sugalvot ;)). Kalbant apie pavadinimą, tai man pačiam aišku, jog iš pavadinimo jums neturėtų būti aišku apie ką aš šitam straipsnyje kalbėsiu, o kalbėsiu, paprastai šnekant, apie tai kaip kompiliatoriai dažniausiai išverčia aukšto lygio kodą į asemblerio instrukcijas.

Reikalavimai straipsnio skaitytojui: truputis smegenų, IA-32 asemblerio pagrindai ir dvejetainės bei šešioliktainės skaičių sistemos supratimas (beto asemblerio pavyzdžiam naudosiu intelio sintaksę, nes švelniai tariant "nemėgstu" AT&T sintaksės). Straipsnis skirtas pagrinde tiem, kas domisi atvirkštine inžinerija, arba tiem, kam nepatinka programuoti "juodos dežutės" principu ir nori žinoti kas vyksta po visais aukšto lygio programavimo kalbos uždengtais sluoksniais. Beje aš pats tik pradedantysis, todėl per daug nepykit jei privelsiu kokių nors klaidų, netikslumų ar paprasčiausiai nusišnekėsiu. Šiuo metu galit prisidegti cigaretę, atsidaryti kokį alaus butelį, pasitaisyti kavos ir t.t., nes panašaus pobūdžio informacija kitaip sunkiai virškinama.

Aritmetika

Šiame skyrelyje pasistenksiu paaiškinti kaip kompiliatoriai verčia aritmetines operacijas į asemblerį ir, kaip jos atrodo. Visų pirma norint suprasti kaip asembleryje implementuojama aritmetika reikia turėti puikų supratima kam reikalingi ir kur naudojami flagai (veliavėlės skamba nekaip todėl naudosiu žodį flagai), o jie naudojami beveik kiekvienoje aritmetinėje procedūroje, todėl nuo jų ir pradėsim.

CF ir OF flagai

Šitie du flagai vieni iš svarbiausių kalbant apie aritmetines instrukcijas. Vienintelis skirtumas tarp jų yra tai su kokiais duomenų tipais jie naudojami. Na turėtumėte žinoti, jog asembleryje duomenų tipai tėra keli ir tai jie nėra griežtai specifikuojami (tai priklauso nuo asemblerio) - viskas procesoriaus lygyje papraščiausi bitai. Kai kurios asemblerio instrukcijos net nežino su kokio tipo skaičiais (su ženklu (signed) ar be ženklo (unsigned)) dirba, tokiu instrukcijų pavyzdžiai : ADD, SUB , tačiau tai šioms instrukcijoms ir nėra svarbu, rezultatas vistiek būna toks pat. O kai kurios instrukcijos pavyzdžiui MUL ir DIV turi skirtingas versijas skaičiams su ženklu ir be ženklo.
Viena iš vietų kur skaičių su ženklu ir be ženklo reprezentacija yra svarbi tai perpildymai. Turėtumėte žinoti, jog skaičiai su ženklu yra vienu bitu mažesni nei jų ekvivalentai be ženklo, nes vienas bitas yra naudojamas ženklui atvaizduoti. Taigi, čia ir pasirodo kur yra naudojami CF ir OF flagai, vietoj to, kad procesorius turėtų kiekvienos paprastos aritmetinės instrukcijos dvi versijas (viena skaičiams su ženklu, kitą be ženklo), įykus perpildymui šitos instrukcijos uždeda reikiamus flagus, atitinkamai: CF - perpildymams skaičiams be ženklo ir OF - perpildymams skaičiams su ženklu ir atsirinkti teisingą skaičiaus reikšmę palieka sudetingesnėm instrukcijom kurios atliks ką nors su tais skaičiais.
Pavyzdžiui paimkime paprastą aritmetinę operaciją:

MOV AL, 70h (112 dešimtainėje)
MOV BL, 50h (80 dešimtainėje)
ADD AL, BL

Šita instrukcija duotų skirtingus rezultatus, priklausomai nuo to, ar al registras yra laikomas kaip skaičius su ženklu, ar be ženklo. Rezultatas, laikant šį skaičių skaičiumi be ženklo, šiuo atveju būtų C0h (192 dešimtainėje). Tačiau jei laikytume šį skaičių skaičiumi su ženklu C0 reikštų -64, nes kairysis bitas skaičiuose su ženklu reiškia ženklą, o šiuo atveju jis būtų 1 (1 reiškia minusą). Taigi šiuo atveju CF flagas būtų 0, nes skaičių be ženklo perpildymas neįvyko, o OF būtų 1, nes skaičių su ženklu perpildymas įvyko.

ZF flagas

Šio flago naudojimas labai paprastas, jis "įjungtas" tai yra lygus 1 tada, kai aritmetinės instrukcijos reikšmė lygi 0 , o kai reikšmė būna nelygi 0 tada šis flagas yra išvalomas ir būna lygus 0. Pavyzdžiui instrukcija CMP, kuri nustato ar du skaičiai yra lygūs, atima vieną operandą iš kito ir pagal tai įjungia arba išjungia ZF flagą, jei abu skaičiai yra lygūs, tada atimties rezultatas bus 0 ir ZF po instrukcijos įvykdymo bus 1.

SF flagas

Šio flago naudojimas taip pat labai paprastas - jo reikšmė tai kairiausiai esantis aritmetinės instrukcijos bitas, na galima sakyti, jog jame saugomas skaičių su ženklu ženklas.

Paprasta sveikųjų skaičių aritmetika

Sudėtis ir atimtis

Sudėtis ir atimtis labai paprastos operacijos, kurios IA-32 procesoriuose yra implementuotos labai efektyviai, todėl kompiliatoriai dažniausiai tokiom operacijom sugeneruoja paprastą asemblerio kodą susidedantį iš elementarių ADD ir SUB instrukcijų. Beto yra dar vienas sudėties ir atimties būdas kurį kartais galite sutikti, tai LEA instrukcijos panaudojimas, nors iš pradžių gali atrodyti, jog šita instrukcija nuskaito ką nors iš atminties ar panašiai (nes antras jos operandas yra apskliaudžiamas laužtiniais skliaustais) tačiau nieko panašaus nevyksta, tik apskaičiuojama reikšmė tarp tų laužtinių skliaustų ir ji įkeliama į kairyjį operandą. Na pavyzdžiui jei mes norėtume sudėti edx su ecx ir rezultatą įkelti į eax, su LEA galėtume tai padaryti taip:

LEA EAX, [EDX + ECX]

Tačiau, kaip ir minėjau ADD ir SUB instrukcijos yra optimizuotos ir jos veikia greičiau nei LEA, todėl sudėčiai ir atimčiai (šiuo atveju TIK SUDĖČIAI IR ATIMČIAI) naudojama tik tada, kai iš eilės yra vykdoma daug sudėties ir atimties instrukcijų, nes LEA instrukcija vykdoma kitoje sekcijoje nei ADD ir SUB instrukcijos, todėl ją įmaišant tarp ADD ir SUB instrukcijų pasiekiamas didesnis paralelizmas tarp procesoriaus darbo sekcijų.

Daugyba ir dalyba

Šios dvi operacijos yra gan sunkiai atliekamos dabartinės architektūros procesoriais (išskyrus dalybą ir daugybą iš skaičiaus 2 pakeltu kokiu nors laipsniu, apie ką papasakosiu truputį vėliau) , todėl operacijos yra labai lėtos, pavyzdžiui paprastai DIV instrukcijai atlikti kartais prireikia ~50 procesoriaus ciklų, kompiliatoriai vengia šių operacijų, o jei dar naudojamos optimizacijos bandant didinti greitį tokios operacijos naudojamos tik tada kai tai neišvengiama. Sugryžkime prie mano minėtos daugybos ir dalybos iš skaičiaus 2 pakelto tam tikru laipsniu, ši operacija kompiuteriui labai paprasta, nes dvejetainėje sistemoje ją įvykdyti elementaru. Todėl kompiliatoriai dalybai ir daugybai su tokiais skaičiais naudoja dvejetainius perstūmimus, kurie IA-32 procesoriuje yra implementuoti kaip SHL (Shift Left - Perstūmimas i kairę) ir SHR (Shift Right - Perstūmimas į dešinę) ar SAL ir SAR kurios yra beveik ekvivalenčios paminėtom dviejom instrukcijom. Esminis jų skirtumas yra tas, jog pirmosios dvi dirba su skaičiais be ženklo, o pastarosios su skaičiais su ženklu, todėl aš jų atskirai nenagrinėsiu. Pavyzdžiui jei turėtume štai tokia kodo eilutę:

y * 4;

Kiekvienas save gerbiantis kompiliatorius sugeneruotų štai tokį kodą šiai operacijai:

SHL EAX, 2

SHL - instrukcija perstumia bitus į kairę, na, ją paprastai galime įsivaizduoti taip: SHL skaičius, x , bus lygu skaičius * (2^x).
SHR - instrukcija perstumia bitus į dešinę, jai pavyzdžio neberodysiu, tačiau parodysiu koks jos ekvivalentas matematiškai : SHR skaičius, x , bus lygu skaičius / (2^x).

Pereikim konkrečiai prie daugybos, taigi, kaip minėjau anksčiau kompiliatoriai smarkiai vengia daugybos ir dalybos naudojant MUL/DIV instrukcijas, bet kai šiuo metu kalbame apie daugybą, tai parodysiu pavyzdį ką kompiliatorius kartais sugeneruoja norėdamas išvengti šių instrukcijų ir kodėl jis taip elgiasi:

LEA ECX, [EAX+EAX*4]

Na turbūt jau supratote, jog čia tai kas yra eax registre dauginama iš 5-ių, beto šitą kodą sugeneravo VS 2005 kompiliatorius, su /Ox - Full optimization flagu, be jokių optimizacijų kompiliatorius vietoj šito sugeneruoja paprasčiausią IMUL instrukciją, tačiau dėlko kompiliatorius vietoj IMUL instrukcijos pasirinko šitą variantą? Ogi todėl, kad LEA naudoja sugeneruotas lenteles pagal kurias ji apskaičiuoja kai kurias aritmetines operacijas, todėl su kai kuriais skaičiais ji atlieka savo darbą daug greičiau nei tai atliktų atitinkamas MUL ar IMUL variantas.

Dalyba. Dalybos kompiliatoriai nemėgsta, o tam jie turi priežaščių, jei nepamiršot kartais papraščiausiai DIV instrukcijai įvykdyti prireikia ~50 procesoriaus ciklų, todėl kartais jie tampa labai išradingi, ypač jei dalinama iš kokios nors konstantos. Dalinant iš nežinomo daliklio kompiliatorius neturi kitokios išeities kaip naudoti DIV/IDIV instrukciją, o kaip ir minėjau dalinant iš konstantų kompiliatoriai tampa išradingi, tas jų išradingumas pasireiškia tuom, jog jie pradeda naudoti ekvivalentinę daugybą (reciprocal multiplication), kurią dabar ir pabandysiu kuo paprasčiau paaiškinti.
Taigi kas tai yra ta ekvivalentinė daugyba? Jos esmė yra ta, jog vietoj dalybos iš konstantų naudojama ekvivalentė daugybos operacija, kurios metu gaunamas tas pats rezultatas. Ji naudojama, nes daugyba dažniausiai būna ~5 kartus greitesnė nei dalyba. Tarkim, jog norime padalinti 100 iš 4-ių, tą patį rezultatą galėtume gauti ir 100 padauginę iš 0,25. Šitokios daugybos sugeneruoto kodo aiškumą sumažina dar ir tai, jog mes naudojame sveikuosius skaičius, todėl kompiliatoriui tenka naudoti nustatyto kablelio aritmetiką (fixed-point arithmetic) , nes jei verstume skaičius į slankiojo kablelio skaičius, tai tokia "optimizacija" greičiausiai sunaudotu daugiau procesoriaus ciklų nei paprasta dalybos instrukcija. Nustatytos vietos kablelio skaičiai, tai realiujų skaičių reprezentacijos būdas, kai realusis skaičius reprezentuojamas naudojant "įsivaizduojamąjį" kablelį, kuris yra nustatytoje vietoje ir kuris atskiria skaičių į dvi dalis - sveikąją ir trupmeninę. Jei norit sužinoti daugiau apie nustatyto kablelio aritmetiką pagooglinkit "fixed-point arithmetic" rasit daug informacijos apie tai. Pereikim prie pavyzdžių, tada manau bus aiškiau. Pirmas pavyzdys:

MOV ECX, [ESP] ; [ESP] = dalomasis skaičius
MOV EAX, CCCCCCCDh
MUL ECX
SHR EDX, 2
PUSH EDX

Taigi matome, jog vyksta kažkokia tai daugyba, bet dauginama iš kažkokio neaiškaus skaičiaus, kuris ir yra kažkokios trupmenos nustatyto kablelio reprezentacijos ekvivalentas, toliau dar naudojant SHR instrukciją dauginama iš 4-ių. Na visų pirma pasiaiškinkime kaip sužinoti kas per trupmena yra tas magiškas skaičius (magiškas dėlto, kad tie skaičiai taip ir vadinami - magic numbers) , tai galime padaryti su šita formule:

T = (1 - Magiškas_Skaičius) / (1 - 2^32)

Pasiverčiam CCCCCCCDh į dešimtainę - gaunam 3435973837, (1 - 3435973837) / ( 1 - 2^32) = 0,8 (4/5), na matom , kad dauginama iš 0,8 , tačiau tai dar ne viskas, nes kompiliatorius dar taip pat su SHR instrukcija dalina iš 4-ių ir matome, jog jis dalina ne EAX, o EDX, taip daro dėlto, nes MUL instrukcija gražina 64 bitų rezultatą, per du registrus - EDX ir EAX. EDX'e būna mum reikalinga reikšmė - nustatyto kablelio skaičiaus sveikoji dalis. Taigi padauginus skaičių iš 4/5-ųjų ir padalinus iš keturių, gaunasi, jog visos šitos instrukcijos tai tėra dalyba iš 5-ių. Beto jei įsigilinot, jum turėjo iškilti klausimas, dėlko tiesiog nebuvo dauginama iš 1/5 ? Trumpai tariant, nes nemažai realiųjų skaičių neįmanoma tiksliai atvaizduoti nustatyto kablelio reprezentacijoje.
Pažiurėkim į kitą pavyzdį:

MOV ECX, [ESP]
MOV EAX, 24924925
MUL ECX
SUB ECX, EDX
SHR ECX, 1
ADD ECX, EDX
SHR ECX, 3
PUSH ECX

Turbųt jau pastebėjote, jog šis pavyzdys ne tiek daug skiriasi nuo praeitojo - vėl iš kažko dauginama. Dar matome dalybą naudojant SHR, tačiau iš kur ta sudėtis ir atimtis ir kam ji reikalinga? Nesijaudinkit tuoj tą ir sužinosime, bet visų pirma apskaičiuokime iš kokio skaičiaus dauginama. Pasiverčiam 24924925h į dešimtainę, gaunam 613566757, ( 1 - 613566757) / ( 1 - 2^32) = 0.1428571428.. gaunasi ne visai tiksli 1/7, dėlto kompiliatorius ir atlieka tas atimties ir sudeties operacijas, nes 1/7 šiuo atveju neįmanoma tiksliai atvaizduoti nustatyto kablelio skaičių reprezentacijoje. Atlikdamas tas operacijas kompiliatorius sumažina paklaidą, kuri atsiranda dauginant iš ne visai tikslaus skaičiaus. Pabandykim atvaizduoti matematiškai viską kas čia daroma:

(((a - b) / 2 ) + b) / 8

a - dalijamasis skaičius
b - dalijamasis skaičius padaugintas iš 1/7

((( a - a/7 ) / 2 - a/7) / 8 = ((( 6/7*a) /2 - a/7) / 8 = (( 6/14*a - a/7) / 8 = (8/14 * a) / 8 = a / 14

Na kaip matote, suprastinau tą mūsų lygtį ir gauname, jog tai tėra paprasčiausia dalyba iš 14-os.

Funkcijos

Funkcijos (dar vadinamos procedūromis ar paprogramėmis) tai pagrindinės procedūrinių ir objektinių programavimo kalbų struktūros dalys, todėl užsiiminejant atvirkštine inžinerija nemaža dalis laiko praleidžiama jų ieškant ir jas idenfikuojant. Paprastai šnekant funkcija tai kodo gabalas kuris gali būti iškviečiamas iš įvairių programos dalių. Kviečiant funkcijas į jas gali būti perduodami parametrai, taip pat ji gali gražinti kokią nors reikšmę, arba ji išvis gali neturėti jokių parametrų ir negražinti nieko. Funkcijos esmė yra ta, jog ji atlikusi savo darbą visada sugrįžta į ta vietą iš kurios ji buvo iškviesta. Funkcijos turi daug skirtingų iškvietimo būdų (calling conventions), kurie vienas nuo kito pagrinde skiriasi parametrų perdavimo būdais, apie kuriuos kalbėsime vėliau, o dabar pabandysiu paaiškinti kas tai yra neatskiriama funkcijų dalis - stekas ir kaip jis susijęs su funkcijomis.

Stekas

Stekas tai paprasta duomenų struktūra esanti atmintyje veikianti LIFO (Last In First Out) principu, leidžianti procesoriui nuskaityti iš atminties tam tikrus duomenis labai greitai, kol laikomasi visų taisyklių. Steką galima įsivaizduoti kaip kokių lėkščių krūvą, kurios sudėtos viena ant kitos. Nesukčiaujant galime paimti tik viršutinę lėkštę, o padėti lėkštes taip pat tegalime ant viršaus, tai ir yra LIFO principas - pirmą įstumtą reikšmę į steką bus galima išimti tik paskutinę. Steko manipuliavimui pagrinde yra dvi instrukcijos push ir pop, push instrukcija įstumia kokia nors reikšmę į steką, pop instrukcija paima kokią nors reikšmę iš steko, po paėmimo tos reikšmės steke nebelieka. Dar viena steko savybe kurią reikia prisiminti yra ta, jog stekas "auga" link žemesnių atminties adresų. Stekas atrodo taip:

Na jei nežinojot prieš pradėdami skaityti straipsnį kas tai yra stekas, tai turbūt jum turėjo iškilti klausimas kas tai yra steko freimas, tai yra išskirta steko vieta kokiai nors funkcijai, "dabartinis seko freimas" tai steko freimas funkcijos kuri tuo metu yra vykdoma, tai yra ta vieta kur yra saugomi parametrai perduodami į ta funkciją, kur saugomas sugrįžimo adresas, į kurį bus peršokama kai funkcija baigs savo darbą ir kur saugomi vietiniai funkcijos kintamieji. Na tarkime, kad turime funkciją:

void _stdcall function(int a, int b)
{
int x, z;
....
}

Kai ji bus iškviesta stekas atrodys taip:

Kai šita funkcija užbaigs savo darbą, atmintis kurią užima jos steko freimas, bus atlaisvinta ir panaudojama kitos funkcijos steko freimui sukurti. Pasiaiškinkime steke esančius duomenis, taip manau, naudojantis šitu pavyzdžiu, bus lengviau suvokti kas per žvėris yra stekas. Taigi pradėkim nuo apačios - parametrų perduodamų į funkciją. Parametrai į funkciją gali būtį perduodami ne vien tik per steką, pavyzdžiui - per registrus, tai priklauso nuo iškvietimo būdo, kuriuos vėliau aptarsime plačiau. Na matome, jog funkcijos steko freimas prasideda nuo parametrų, jei ji tokių turi ir jei jie perduodami per steką, šita dalis jums manau nesukelia jokių klausimų, toliau eina sugrįžimo adresas. Kaip ir minėjau - funkcijų esmė tai, jog jos atlikusios savo darbą grįžta į tą vietą iš kur buvo kviestos ir dabar matome pagrindinį būdą kaip tą sugrįžimą galima implementuoti. Visą tą funkcijos sugrįžimą galima implementuoti pavyzdžiui išsaugojant sugrįžimo adresą kokiam nors specialiam kintamąjame ir funkcijai baigus darba peršokti į tame kintamąjame esantį adresą, dar galime prieš kviesdami funkciją , tos kitos funkcijos gale įrašyti absoliutų šuolį naudojant JMP instrukciją. Tačiau beveik visada kompiliatoriai sugrįžimui implementuoti naudoja CALL ir RET instrukcijų porą. CALL instrukcija įstumia į steką adresą instrukcijos einančios po ja, o RET instrukcija išima tą adresą iš steko ir ten nukreipia programos eigą. Skiltį Senas EBP dabar praleiskim. Po jos toliau eina vietiniai funkcijos kintamieji , tai gali būti funkcijoje koks nors naudojamas buferis, ar nuoroda (pointer) ar kokia nors reikšme, na visi įmanomi duomenų tipai, manau tai irgi nesukelia jokių klausimų kaip ir perduodami parametrai. Grįžkim prie EBP, tuom pačiu dar išsiaiškinsime kas tai yra funkcijų prologai ir epilogai , taigi iš kur jis ten atsiranda ir kam jis reikalingas? Dauguma funkcijų prieš pradėdamos savo darbą initializuoja steko freimą su kodo gabalu vadinamu prologu , pažiurėkim kaip tas kodas atrodo:

PUSH EBP
MOV EBP, ESP
SUB ESP, x

Idėja yra ta, jog yra reikalingas būdas, kad galėtume lengvai ir greitai pasiekti vietinius kintamuosius ir į funkciją perduodamus parametrus. Prieš pagaliau sužinant kam tas EBP reikalingas, turiu paminėti kam reikalingas ESP - (Stack Pointer) jis papraščiausias kintamasis kuriame yra dabar naudojamas steko adresas, pavyzdžiui kai CALL instrukcija į steką įstums sugrįžimo adresą, ESP bus tas steko adresas kuriame bus sugrįžimo adresas. Taigi į steką funkcijos prologo yra įstumiamas EBP kuriame yra adresas į praeitos funkcijos steko freime esantį seną EBP , nes kaip matome sekanti instrukcija į EBP įrašo ESP, kaip ir minėjau ESP būna dabartinis steko adresas, o juk dabar steke mes randames prie Seno EBP nes katik buvo įstumtas EBP su instrukcija PUSH EBP. O šitas adresas išsaugomas yra tam, jog kviečiančioji funkcija paskui veiktų normaliai, nes EBP yra naudojamas vietiniam kintamiesiam ir perduodamiems parametrams funkcijoje pasiekti, todėl kai funkcija pabaigia savo darbą EBP yra atstatomas į buvusią reikšmę kuri paimama iš skilties Senas EBP, nes jei jis būtų neatstatytas tai funkcija nebegalėtų pasiekti vietinių parametrų. Kodas kuris tai atlieka vadinamas epilogu, jis atlieka ne vien tik tai ir apie jį truputį vėliau, nes dar nebaigiau su prologu. Paskutinėje prologo eilutėje iš steko pointerio atimamas kazkoks tai skaičius x, kuris yra vietiniu kintamųjų dydis baitais, mūsų funkcijos atveju tai būtų skaičius 8, nes turime du int tipo kintamuosius ir jie užima 8 baitus, taigi papraščiausiai atlaisvinama vieta vietiniems kintamiesiems, jei funkcija neturi vietinių kintamųjų kodas būtų SUB ESP, 0 kurį kompiliatoriai praleidžia nes jis nedaro nieko ir tik naudoją procesoriaus veiklą. Beto, atimtis, o ne sudėtis dėlto, jog juk stekas auga žemyn tai yra link žemesnių adresų. Kai funkcija baigia savo darbą ji arba ją kviečiančioji funkcija turi išvalyti steko freimą, kas tai atlieka priklauso nuo kvietimo tipo (mūsų atveju funkcija yra _stdcall tipo todėl iškviestoji funkcija turi pati išvalyti steką), tai daroma su kodo gabalu vadinamu epilogu. Epilogas turi
atitaisyti tai ką atliko prologas, taigi jis turi atlikti šiuos veiksmus:

1. Atstatyti ESP reikšmę, tai yra padaroma į ESP įrašant EBP reikšmę, tai reikalinga tam, kad RET instrukcija galėtų nuskaitytį teisingą sugrįžimo adresą.

2. Atstatyti seną EBP reikšmę kuri dabar yra pačiame steko viršuje, dažniausiai naudojant POP instrukciją.

3. Išvalyti į funkciją perduodamus argumentus ir sugrįžti į tą vietą iš kur ji buvo kviesta.

Pažiurėkim į mūsų funkcijos epilogą:

MOV ESP, EBP
; Įvykdomas pirmas punktas, atstatoma ESP reikšmė.
POP EBP
; Antras punktas, atstatoma EBP reikšmė.
RETN 8
; Įvykdomas paskutinis punktas, išvalomi argumentai ir
; nukreipiama programos eiga, tai padaroma su viena
; instrukcija - RETN (RET = RETN) , kuri turi vieną parametrą - 8, tai reiškia, jog iš steko bus išimti 8 baitai, kas yra lygu perduodamų argumentų dydžiui.

Ankščiau minėjau tai, jog EBP naudojamas funkcijos argumentams ir vietiniems kintamiesiems pasiekti, identifikuoti su kuriais kažkas tai yra daroma labai lengva, tai galime padaryti pažiurėję koks yra naudojamas poslinkis (offset), tai kas yra virš EBP yra vietiniai kintamieji, o tai kas yra žemiau EBP - funkcijos argumentai. Pažiurėkim į paveikslėlį:

Taigi matom, jog tai kas yra pasiekiama naudojant EBP su teigiamu poslinkiu yra funkcijos argumentai, o tai kas yra pasiekama su EBP su neigiamu poslinkiu yra vietiniai kintamieji. Beto turėčiau paminėti, jog kartais optimizuojantys kompiliatoriai kintamiesiems pasiekti naudoja ESP, o EBP būna išvis nenaudojamas t.y. būna sukuriamas steko freimas išvis be EBP, tačiau su tokiais atvejais ne dažnai susidursit, todėl tam dėmesio neskirsiu.

Funkcijų kvietimo būdai (Calling conventions)

Tam, kad funkcijos "veiktų" taip kaip priklauso, kviečiančioji funkcija turi žinoti ne tik kviečiamosios funkcijos prototipą (perduodamų argumentų tipus ir kiekį), bet ir "susitarti" kokiu būdu ir kaip bus perduodami argumentai argumentai į ta funkciją, bei susitarti kas atliks valytojo darbą ir iškviestąjai funkcijai baigus darbą išvalys steką. Dažniausiai naudojami yra trys kvietimo būdai, kiekvienas iš jų turi savų pliusų ir minusų, tačiau tai nelabai sutampa su mano straipsnio tema, todėl pasistengsiu išlikti tik prie kvietimo būdų "mechanikos". Pradėkim nuo mums jau sutikto - stdcall.

STDCALL

stdcall dar kitaip vadinamas standartiniu kvietimo būdų (standart convention) arba kartais dar vadinamas WINAPI , pastarąjį pavadinimą šis kvietimo būdas gavo dėlto, jog tai standartinis Win32 API funkcijų kvietimo būdas, todėl windowsuose jums su juo teks susidurti labai dažnai. Pagrindinės stdcall savybės:

Argumentai: Perduodami iš dešinės į kairę, per steką.
Steko valymas: Steką išvalo iškviestoji funkcija.

Pereikim prie pavyzdžio, tarkim, jog turime štai tokią funkciją:

int _stdcall test(int a,int b,int c)
{
return a * b * c;
}

Ir ji yra kviečiama štai tokio kodo:

int chuck_norris_owns_your_compiler = test(1,2,3);

Niekas nemokino jūsų, kad kintamųjų vardai turi buti informatyvūs? Jei ne - imkit pavyzdį iš manęs. Taigi, sugeneruotas (VS 2k5, be jokių optimizacijų) kodas:

00401755 | PUSH 3
; Taigi į steką įstumiami argumentai ir kaip minėjau prie stdcall taisyklių - jie yra įstumiami iš dešinės į kairę
00401757 | PUSH 2
;
00401759 | PUSH 1
;
0040175B | CALL Test.00401730
; Kviečiama funkcija

00401760 | MOV DWORD PTR SS:[EBP-4],EAX
; Reikšmė gražinama EAX'e ir įrašoma į steką, jei prisimenat dalį apie steką turėjot pastebėti, jog
; EBP turi neigiamą poslinkį, o tai reiškia, jog yra rašoma į vietinį kintamąjį


00401730 | PUSH EBP
; Prologas. Įstumiamas senas EBP
00401731 | MOV EBP,ESP
; EBP = Dabartinė padėtis stekė, t.y. ESP , šitom dvejom instrukcijom kompiliatorius "atidaro" steko freimą, beto nematom
; SUB instrukcijos kuri išskirtų vietą vietiniems kintamiesiems, todėl galime teigti, kad jų ši funkcija neturi

00401733 | MOV EAX,DWORD PTR SS:[EBP+8]
; EAX = 1, turėjot pastebėti, jog EBP su teigiamu poslinkiu tai reiškia, jog į EAX yra įkeliamasį funkciją perduodamas argumentas
00401736 | IMUL EAX,DWORD PTR SS:[EBP+C]
; a * b
0040173A | IMUL EAX,DWORD PTR SS:[EBP+10]
; a * b sandaugos rezultatas * c
0040173E | POP EBP
; Epilogas. Atstatoma senoji EBP reikšmė
0040173F | RETN 0C
; Išvalomi argumentai ir grįžtama atgal.

CDECL

cdecl tai C ir C++ kalbose pagal nutylėjimą naudojamas kvietimo būdas. Unikali cdecl kviečiamų funkcijų savybė yra ta, jog funkcijos argumentų skaičius gali būti dinamiškas, tai yra pasiekama tuom, jog stekas yra išvalomas kviečiančiosios funkcijos, o ne kviečiamosios. Pagrindinės cdecl sąvybės:

Argumentai: Perduodami iš dešinės į kairę, per steką.
Steko valymas: Steką išvalo kviečiančioji funkcija.

Pavyzdys:

int _cdecl test(int a,int b,int c)
{
return a * b * c;
}

Turim lygiai tokią pačią funkciją ir tokį pat jį kviečiantį kodą:

int she_sells_C_shells = test(1,2,3);

Pažiurėkim kuom skiriasi sugeneruotas (VS 2k5, be jokių optimizacijų) kodas:

00401745 | PUSH 3
; Argumentai į steką sustumiami taip pat kaip ir praeitą kartą
00401747 | PUSH 2
;
00401749 | PUSH 1
;
0040174B | CALL Test.00401730
; Kviečiama funkcija
00401750 | ADD ESP,0C
; Prie ESP pridemas funkcijos argumentų dydis baitais, taip "išvalomas" stekas
00401753 | MOV DWORD PTR SS:[EBP-4],EAX
; Reikšmė vėl gražinama EAX'e


00401730 | PUSH EBP
; Atidaromas steko freimas
00401731 | MOV EBP,ESP
; Toks pat kodas kaip ir praeitą kartą
00401733 | MOV EAX,DWORD PTR SS:[EBP+8]
;
00401736 | IMUL EAX,DWORD PTR SS:[EBP+C]
;
0040173A | IMUL EAX,DWORD PTR SS:[EBP+10]
;
0040173E | POP EBP
;
0040173F | RETN
; Tik šį kartą RETN neturi jokiu parametrų, todėl stekas neišvalomas, o tik nukreipiama programos eiga

FASTCALL

Iš pavadinimo turėjot suprasti, jog tai turėtų būti greitesnis funkcijų kvietimo būdas, tačiau tai tiesa tik tam tikrais atvejais. Šio kvietimo būdo sąvybės kinta tarp įvairių kompiliatorių , todėl patartina jį naudoti tik tada kai programuotojas tikrai žino ką daro. Sąvybės:

Argumentai: Idėja yra ta, jog didesniam greičiui pasiekti argumentai turėtų būti perduodami per registrus, tačiau kaip ir minėjau kiekvienas kompiliatorius kuris implementuoja šį kvietimo būdą, implementuoja jį skirtingai. Microsofto kompiliatorius perduoda per registrus tik du 32 bitų argumentus (Naudojami registrai ECX,EDX), visus likusius arba didesnius perduoda per steką. Borlando kompiliatorius per registrus perduoda tris 32 bitų kintamuosius (naudojami registrai EAX,ECX,EDX), o visus kitus per steką.
Steko valymas: Steką išvalo iškviestoji funkcija.

Turėčiau paminėti, jog aš nagrinėju tik dažniausiai sutinkamus atvejus, nes ezoterika yra virš mano straipsnio ribos. Būtent šiuo atveju aš galėjau dar nemažai prirašyti apie tai kaip kiti kompiliatoriai implementuoja fastcall'ą tačiau nematau tame prasmės, na o jei jums vis dėlto įdomu pasinagrinėkit, pavyzdžiui, WATCOM'o fastcall'o implementacija, pamatysit kaip ji skiriasi nuo visų likusių. Užteks pliurpalų, pereikim prie pavyzdžio:

Lygiai tokia pati funkcija ir ją kviečiantis kodas:

int _fastcall test(int a,int b,int c)
{
return a * b * c;
}
int holly_shit_batman_this_is_fast = test(1,2,3);

Pažiurėkim ką sugeneruoja VS 2k5 be jokių optimizacijų:

00401755 | PUSH 3
; Taigi, matome, jog vienas iš argumentų perduodamas per steką, nes kaip ir minėjau VS per registrus perduoda tik du argumentus
00401757 | MOV EDX,2
; beto iš čia negalime suprasti kokia tvarka argumentai yra perduodami tačiau microsoftas, savo dokumentacijoje mini, jog jie yra
0040175C | MOV ECX,1
; perduodami iš kairės į dešinę
00401761 | CALL Test.00401730
; Kviečiama funkcija..

00401766 | MOV DWORD PTR SS:[EBP-4],EAX
; Reikšmė gražinama EAX'e


00401730 | PUSH EBP
; Paruošiamas steko freimas.
00401731 | MOV EBP,ESP
;
00401733 | SUB ESP,8
; Kažkas įdomaus....arba kvailo.Mūsų funkcija neturi vietinių kintamųjų, kodėl tada kompiliatorius išskiria 8 bitus vietos kintamiesiems?
00401736 | MOV DWORD PTR SS:[EBP-8],EDX
; Matome, jog tie du vietiniai kintamieji yra skirti kaip laikinos reikšmės mūsų perduodamiems argumentams, bet juk tai visai nebūtina!
00401739 | MOV DWORD PTR SS:[EBP-4],ECX
; Juk galima juos naudoti tiesiogiai, čia ir pasireiškia kompiliatorių kvailumas, tačiau... Nelabai galime jo kaltinti (juk kompiliuojame be
; optimizacijų) ir tik galime tikėtis, jog įjungus optimizacijas optimizatorius aptiktų šią kvailystę ir ją pataisytų.
; Beto įdomumo dėlei paminėsiu, jog optimizatorius tikrai gerai atlieka savo darba ir įjungus optimizacijas mūsų funkcija būtu
; pradanginama į bitų rojų, o į mūsų kintamąjį būtų papraščiausiai įrašoma apskaičiuota reikšmė (šiuo atveju 6-i)
; o jei ir tas pats mūsų kintamasis nebūtų niekur daugiau panaudojamas tai ir jis būtų pradanginamas.

0040173C | MOV EAX,DWORD PTR SS:[EBP-4]
; Na čia vėl beveik tas pats kaip ir praeitose funkcijose, manau nereikia nieko aiškinti.
0040173F | IMUL EAX,DWORD PTR SS:[EBP-8]
;
00401743 | IMUL EAX,DWORD PTR SS:[EBP+8]
;
00401747 | MOV ESP,EBP
;
00401749 | POP EBP
;
0040174A | RETN 4
; Kaip ir rašiau prie sąvybių, stekas išvalomas kviečiamosios funkcijos.

Duomenys

Šiame skyriuje kalbėsiu apie tai kaip žemame lygyje atrodo kintamieji, struktūros, masyvai ir t.t. Beto apie vietinius kintamuosius jau nemažai rašiau skyriuje apie steką ir su jais susidūrėme funkcijos kvietimo būdų skyrelyje, todėl nesikartosiu ir apie juos nebekalbėsiu. Pradėsiu nuo pačių elementariausių - globalinių kintamųjų.

Globaliniai kintamieji

Globalinių kintamųjų vertimas į žemo lygio kodą labai paprastas, vieta jiems išskiriama ir/arba jų reikšmės patalpinamos atitinkamoje failo vietoje (VS 2k5 globaliniems kintamiesiems vietą išskiria .data PE failo sekcijoje). O toliau jie pasiekiami per nustatytą (hardcoded) adresą. Pažiurėkim pavyzdį:

Turim štai tokį kodo gabalą:

int a[10];

int main()
{
a[0] = 0;
a[9] = 0;
}

Jam kompiliatorius sugeneruos štai ką:

00401730 | PUSH EBP
;
00401731 | MOV EBP,ESP
;
00401733 | MOV DWORD PTR DS:[403374],0
; Matome, jog yra rašoma į adresą kuris yra hardcodintas į programą
0040173D | MOV DWORD PTR DS:[403398],0
; Tas pats ir su kitu masyvo nariu
00401747 | POP EBP
00401748 | RETN

Nors šios rūšies kintamųjų vertimas į žemo lygio kodą yra labai elementarus ir jų suradimas ir atpažinimas atliekant atvirkštinę inžineriją taip pat elementarus, tačiau jei kada nors bandysite atlikti statinę žemo lygio kodo analizę, pamatysite, jog globaliniai kintamieji sukelia daug problemų. Jų reikšmė visada priklauso nuo konteksto, tai yra, jų reikšmė gali būti pakeičiama bet kurioje programos vietoje.

Konstantos

Konstantos C/C++ kalboje gali būti implementuojamos su preprocesoriaus direktyva #define arba prie globalinio kintamojo pridedant žodį const. Pirmuoju atveju toje vietoj kur būtų naudojama ta konstanta, preprocesorius įrašytų jos reikšmę ir atrodytų, jog ta reikšmė būtų hardcodinta. Antruoju atveju būtų lygiai tas pats kaip ir su paprastais globaliniais kintamaisias - reikšmė būtų kažkur atmintyje ir ji būtų pasiekiama per kažkokį tai nustatytą adresą.

Masyvai

Masyvai tai paprasta duomenų struktūra atmintyje, kuria sudaro tos pačios rūšies elementai išsidėstę nuosekliai. Darbas su jais žemame lygyje elementarus, todėl per daug nesigilinsiu ir iškarto pereisiu prie pavyzdžio kuriame viską ir paaiškinsiu:

int main()
{
int n = 5;
int a[100];
a[n] = 10;
}

Žemo lygio kodas (VS 2k5, be jokių optimizacijų):

00401730 | PUSH EBP
; Standartinis steko freimo atidarymas..
00401731 | MOV EBP,ESP
;
00401733 | SUB ESP,194
; O čia matome, jog steke išskiriama nemažai vietos 194h = 404 dešimtainėje, išskiriama vietos visam
; masyvui ir dar vienam vietiniam kintąjam (n). Beto vykdant atvirkštinę inžineriją, pamatę, jog steke išskiriam daug vietos
; beveik visada manyti, jog toje funkcijoje yra naudojamas kažkoks masyvas.

00401739 | MOV DWORD PTR SS:[EBP-194],5
; Įrašoma mūsų reikšmė į vietinį kintamąjį ( n = 5 )
00401743 | MOV EAX,DWORD PTR SS:[EBP-194]
; O dabar ta pati reikšmė paimama iš steko ir įrašoma į registrą, taip daroma nes kompiliatorius nori sutaupyti vietos,
; kad paskui rašydamas į penktą masyvo elementą, galetų apskaičiuoti jo adresą naudodamasis tik viena instrukcija.

00401749 | MOV DWORD PTR SS:[EBP+EAX*4-190],0A
; Čia yra į penktą elementą įrašomas skaičius dešimt, pasigilinkim kaip apskaičiuojamas to elemento adresas
; Na visų pirma masyvo pradžia bus EBP - 190 , o prie tos reikšmės pridedama EAX*4 - elemento numeris dauginamas
; iš keturių nes int tipo skaičiai užima 4-is baitus, taigi prie masyvo pradžios adreso pridėję elemento numeri*jo tipo užimama
; baitų kieki gausime to elemento adresą, į kurį ir galime įrašyti mūsų norimą skaičių.
00401754 | XOR EAX,EAX
; Išvalomas EAX, kuris buvo naudojamas kaip laikinas kintamasis.
00401756 | MOV ESP,EBP
; Epilogas..
00401758 | POP EBP
;
00401759 | RETN
;

Struktūros

Struktūros tai grupės įvairių tipų duomenų. Struktūrų elementų išdėstymas visada yra statiškas, tai yra jei kur nors programoje struktūra bus naudojama kelis kartus, tai jos elementų išsidėstymo tvarka kaip nors magiškai nepakis. Taip pat struktūrų dydis beveik visada yra statiškas (Struktūroje vienintelis elementas kuris gali turėti dinaminį dydį tai paskutinis elementas ir nemanau, kad jūs dažnai sutiksite tokių struktūrų). Struktūros atmintyje gali būti sulygintos pagal procesoriaus vidinį "žodžio" (word - žodis nusakantis tam tikrą atminties bitų kiekį, kurio dydis gali kisti priklausomai nuo procesoriaus, jo architektūros ir t.t.) ir nesulygintos. Paimkim štai tokią struktūrą:

struct test
{
int a;
short int b;
int c;
};

Jos elementai užima 4 baitus , 2 baitus ir 4 baitus atitinkamai. Jei struktūra būtų sulyginta ir na tarkim mes turim rodyklę į tą struktūrą kokiam ECX registre, tai jos elementus galetume pasiekti šitaip:

[ecx + 0]
; a , užima 4 baitus
[ecx + 4]
; b , turėtų užimti 2 baitus, tačiau mes matome, jog ji užima 4 baitus, taip yra dėlto nes ši struktūra yra sulyginta, o duomenys atmintyje yra dažniausiai sulyginami dėlto, kad
; procesoriui norėdamas pasiekti nesulygintą atmintį turi atlikti daugiau darbo.
[ecx + 8]
; c , 4 baitai

O čia matome nesulygintą struktūrą:

[ecx + 0]
; a , 4 baitai
[ecx + 4]
; b , 2 baitai
[ecx + 6]
; c , 4 baitai

Struktūros dažniausiai būna sulygintos, nes dažniausiai sunaudojama truputį daugiau atminties tačiau laimima nemažai darbo.

Sąlyginiai sakiniai

Algoritmai būna dviejų tipų besąlygiški (pavyzdžiui: z = x * c) ir sąlyginiai (pavyzdžiui: if (a == b) { a = c } ). Pirmuosius kompiliatorius verčia į žemo lygio kodą "tiesiai", tai yra viskas vykdoma iš eilės, o verčiant antruosius tenka implementuoti kažkokį tai sąlygos tikrinimą ir atitinkamą programos eigos nukreipimą, tai dažniausiai daroma su CMP instrukcija (sąlygos tikrinimui) ir JXX (programos eigos nukreipimui) šeimos instrukcijomis.

Sąlygos tikrinimas su CMP instrukcija

Norint suprasti kaip implementuojami sąlyginiai sakiniai reikia turėti gerą supratima kaip reikšmės yra lyginamos asembleryje. Kaip ir minėjau sąlyga dažniausiai tikrinama su CMP instrukcija, ši instrukcija beveik atitinka SUB instrukciją, ji skiriasi tik tuom, jog atėmusi iš vieno operando kitą, neįkelia jo reikšmės į atimamajį operandą. CMP instrukcija atimdama vieną operandą iš kito pakeičia flagus, pagal tai JXX šeimos instrukcijos ir atpažysta ar reikia atlikti šuolį. Padariau tokią lentelę, kurioje yra parodyta sąlyga, flagų reikšmės tada, kai sąlyga būna išpildoma ir šuolio instrukcijos kurios tos sąlygos atveju yra naudojamos programos eigai nukreipti. Taigi, lentelė:

Pilki laukeliai šios lentelės flagų skyriuje, reiškia, jog tie flagai neturi reikšmės atitinkamai šuolio instrukcijai. Klaustukai prie OF flago reiškia, jog tam, kad šuolio instrukcija atliktu šuolį yra nesvarbu kokia ten bus reikšmė, svarbu yra tai, jog ji atitiktų sąlygą prie SF flago. Pavyzdžiui trečioje sąlygoje (skaičiams su ženklu atveju) tam, kad viena iš atitinkamų šuolio instrukcijų atliktų šuolį reikia, kad SF reikšmė nebūtų lygi OF reikšmei. Tikiuos ši lentelė pravers toliau gilinantis į sąlyginius sakinius, nes nėra lengva prisiminti visas tas instrukcijus ir flagus.

IF-THEN sąlyginiai sakiniai

Šio tipo sakiniai tai pačios paprasčiausios sąlyginių sakinių konstrukcijos, tačiau ir jos turi šiokių tokių subtilybių. Tarkim turime štai tokį kodo gabalą:

if (everything == 0) {
nothing = 1;
}
nothing = 0;

Tokiam kodui iš žemo lygio perspektyvos sugeneruoti iš pradžių reiketų patikrinti ar kintamasis everything yra lygus nuliui, jei ne tada peršokti per kodą įrašantį į kintamąjį nothing vienetą ir toliau tęsti programos eigą. Juk jei šoktume į kodą kuris į nothing įrašytų vienetą paskui tektu šokti atgal , kad galetume tęsti eigą toliau, taip būtų be reikalo prarandami vertingi procesoriaus ciklai. Kompiliatorius versdamas kodą tiesiai ir taupydamas procesoriaus ciklus visada "apverčia" sąlygą, nes kitokiu atveju tektų naudoti mano minėtą neoptimalų būdą su dviejais šuoliais. Pažiurėkim ką sugeneruoja kompiliatorius:

004012F6 | CMP DWORD PTR SS:[EBP-8],0
; [EBP-8] = everything ir kaip matome jis yra vietinis funkcijos kintamasis, jis yra lyginimas su 0
004012FA | JNZ SHORT test.00401303
; šokama su JNZ (Jump if not zero) instrukcija, tai yra šokama tada kai tikrinima reikšmė nelygi nuliui, taigi sąlyga yra apverčiama
004012FC | MOV DWORD PTR SS:[EBP-4],1
; [EBP-4] = nothing = 1
00401303 | MOV DWORD PTR SS:[EBP-4],0
; nothing = 0

Išvada tokia, jog pačių paprasčiausių sąlyginiu IF-THEN sąlyga visada būna apverčiama, tai reikėtų įsikalti į galva ypač tiems kas užsiima atvirkštine inžinerija. Beto turėčiau paminėti, kad kartais tarp CMP ir JXX instrukcijų būna įmaišomos kitos instrukcijos kurios nepakeičia flagų, taip yra daroma dėlto, jog norima pasiekti didesni paraleliškumą procesoriuje.

IF-THEN-ELSE sakiniai

Šio tipo, jau truputį sudėtingesni sakiniai, nedaug kuom skiriasi nuo paprastesnių IF-THEN-ELSE sakinių, todėl daug teorijos apie juos nebus, tik parodysiu kelis pavyzdžius ir pabandysiu paaiškinti kas kaip ir kur. Pradėkim nuo paprasčiausio if-else sakinio kuris teturi tik dvi "atšakas" viena kuri įvykdoma jei sąlyga teisinga, o kitą kai sąlyga neteisinga. Tokio tipo kodas žemame lygyje skiriasi nuo paprasto if-then tik tuom, jog po patikrinimo yra sąlyginis šuolis į kodą esantį else šakoje, o po kodo kuris būtų įvykdomas jei sąlyga būtų teisinga būna besąlygiškas šuolis į koda po sąlyginiu sakiniu. Na paanalizuokim pavyzdį:

Kaip visada turim kodo gabalą:

if (everything == 0) {
nothing = 1;
}
else {
nothing = 0;
}
nothing++;

Na jo, šis kodas prasmės neturi, būtų galima vietoj 1 ir 0 rašyt 2 ir 1, bet pavyzdžiui tinka, todėl nesikabinėkit. :)

Disasemblerio listingas:

00401745 | CMP DWORD PTR SS:[EBP-4],0
; [EBP-4] = everything, tikrinama ar jis ne nulis
00401749 | JNZ SHORT Test.00401754
; vėl JNZ , vėl sąlyga apversta, tik šiuo atveju šokama į 00401754, kur matome, jog prie nothing pridedamas vienetas.
0040174B | MOV DWORD PTR SS:[EBP-8],1
; nothing = 1
00401752 | JMP SHORT Test.0040175B
; Kaip ir minėjau besąlygiškas šuolis į kodą už sąlyginio sakinio ribos
00401754 | MOV DWORD PTR SS:[EBP-8],0
; nothing = 0
0040175B | MOV EAX,DWORD PTR SS:[EBP-8]
; nothing reikšmė įkeliama į registrą tam, kad būtų galima pridėti vieną
0040175E | ADD EAX,1
; pridėdamas vienas, nežinau kam tokius komentuoju, bet manau netrugdo ;)
00401761 | MOV DWORD PTR SS:[EBP-8],EAX
; reikšmė įkeliama atgal į savo vietą.

Toliau turim, sudetingesnį sąlyginį sakinį sudarytą daugiau nei iš dviejų atšakų, visi principai tie patys tačiau tokį kodą žemame lygyje jau sunkiau išnarplioti. Teorijos jokios neaiškinsiu tiesiog pažiurėkim į pavyzdį.

Aukšto lygio kodas:

if (beer == 0) {
buybeer();
}
else if (beer <= 3 ) {
buymorebeer()
;}
else if (beer >= 8 ) {
sleep();
}
else {
somethingswrong();
}
beer = 0;

Padariau grafiką su schema, tikiuos tai padės jums perprasti kaip panašūs sakiniai verčiami į žemo lygio kodą:

Taigi matome, jog šio pavyzdžio pagrindai nuo ankstesnio pavyzdžio nesiskiria tik jo sudetingesnė struktūra.

Sudėtiniai sąlyginiai sakiniai

Dažniausiai sąlyginiuose sakiniuose sąlyga būna sudaryta ne iš vienos, o iš kelių dalių (pavyzdžiui (a>3 || b < 5)). Jų vertimas į asembleri taip pat nėra ypač sudėtingas, kompiliatorius paprasčiausiai išskaido sąlygą į kelias dalis. Pradėkim nuo AND (arba &) loginės operacijos, su kuria yra jungiamos sąlygos dalys.Tam, kad sąlyga būtų teisinga, sąlygos išraiška turi gražinti reikšmę true (kuri dažniausiai vaizduojama skaičiumi 1). Dvi sąlygos išraiškos sujungtos su logine operacija AND abi turi gražinti reikšmes true tam, kad bendra sąlyga būtų teisinga, na pavyzdžiui jei turėsime sąlyga a == 0 && b == 0 , a ir b reikšmės turi būti lygios 0 tam, kad sąlyga būtų teisinga. Tokio tipo išraiškos skaidomos gana paprastai, na tarkim, jog turim tokią sąlygą:

if (a > 10 && b < 10) {
belekas: a = 0;
}
toliau:
b = 0;

Kompiliatorius tokį kodą paverstų į štai tokią formą:

if a <= 10 then toliau
if b >= 10 then toliau
belekas:
a = 0;
toliau:
b = 0;

Taigi esmė tokia, jog sąlygų išraiškos sujungtos su AND būna apverčiamos, tada pirmai sąlygos išraiškai esant teisingai (apvertus atgal ji būtų neteisinga) šokama už sąlyginio sakinio ribos, jei jis vis dėlto neteisinga, tada tikrinama antra išraiška ir jei ir ji neteisinga, tada atliekamas kodas esantis sąlyginio sakinio viduj. Štai kaip tas pats pavyzdys išsiverčia į asemblerį:

00401745 | CMP DWORD PTR SS:[EBP-4],0A
; [EBP-4] = a , lyginamas su 10
00401749 | JBE SHORT Test.00401758
; JBE pažiurėję į lentelę matome jog naudojamas kai reikia lyginti <= , beto matome, jog jis naudojamas su skaičiais be ženklo
0040174B | CMP DWORD PTR SS:[EBP-8],0A
; [EBP-8] = b irgi lyginamas su 10
0040174F | JNB SHORT Test.00401758
; JNB iš lentelės matome, jog skirtas sąlygoms su >= , taip pat skaičiams be ženklo tikrinti
00401751 | MOV DWORD PTR SS:[EBP-4],0
; a = 0, sakinys sąlyginio sakinio viduj, kuris bus įvykdomas tik tada kai abi išraiškos gražins reikšmę false
00401758 | MOV DWORD PTR SS:[EBP-8],0
; b = 0, sakinys už sąlyginio sakinio, kuris bus įvykdomas bet kokiu atveju tačiau į jį bus peršokama iškart jei nors viena iš išraišku
; gražins reikšmę true

Toliau turime sąlyginius sakinius sujungtus su logine operacija OR ( | ). Tam, kad sąlyga gražintų reikšmę true, nors viena iš dviejų išraiškų kurios sujungtos su OR turi gražinti reikšmę true. Pažiurėkim kaip implementuojami tokie sakiniai. Turim tokį pat kodo gabalą kuriame skiriasi tik loginė operacija.

if (a > 10 || b < 10) {
something: a = 0;
}
toliau:
b = 0;

Na vėl pseudokodas kuris turėtų padėti suprast kokia OR sąlygų mechanika:

if a > 10 then belekas
if b >= 10 then toliau
belekas:
a = 0;
toliau:
b = 0;

Pirma išraiška kaip matome lieka neapversta ir jei ji gražina reikšmę true yra šokamą į kodą sąlyginio sakinio viduj, kaip ir turėtu būti nes esant nors vienai iš išraišku teisingom sąlyginis kodas turi būti atliekamas. Toliau jei vis dėlto pirma išraiška neteisinga, tai tikrinama antra išraiška kuri būna apversta ir jei ji teisinga (apvertus būtų neteisinga) reiškias, kad abi išraiškos gražino false todėl yra šokama už sąlyginio sakinio ribos. Šitam pavyzdžiui manau asemblerio listingo nebedėsiu, nes jis beveik niekuo nesiskiria nuo praeito išskyrus šuolio instrukcijom.

Ciklai

Ciklai žemame lygyje tai tiesiog sąlyginio kodo gabalai kurie vykdomi tol kol sąlyga yra teisinga. Ciklų yra nemažai tipų, tačiau kai jie verčiami į asemblerį viskas yra suprastinama iki kelių bendrinių tipų kuriuos aš tuoj ir parodysiu.

do-while ciklai

Tai turbūt pats elementariausias ciklų tipas, tiksliau elementariausiai verčiamas į asemblerį. Visų pirma eina kodas ciklo viduje, po to būna tikrinama sąlyga ar ji teisinga ir jei ji neteisinga šokama atgal. Pavyzdys:

do {
argh = argh - yarr;
yarr--;
} while ( yarr > 10 );

Disasemblerio listingas:

00401745 | MOV EAX,DWORD PTR SS:[EBP-8]
; EAX = [EBP-8] = argh
00401748 | SUB EAX,DWORD PTR SS:[EBP-4]
; argh - yarr
0040174B | MOV DWORD PTR SS:[EBP-8],EAX
; Įkeliamas rezultatas atgal į steka, kur jam ir vieta kaip vietiniam kintamajam :)
0040174E | MOV ECX,DWORD PTR SS:[EBP-4]
; ECX - laikinas kintamasis
00401751 | SUB EAX,1
; yarr--
00401754 | MOV DWORD PTR SS:[EBP-4],ECX
; laikina reikšmė atgal į steką
00401757 | CMP DWORD PTR SS:[EBP-4],0A
; lyginamas yarr su 10
0040175B | JG SHORT Test.00401745
; šokama atgal į cikle esantį koda jei yarr daugiau už dešimt

while ciklai

While ciklai juos verčiant į asembleri dažniausiai padaromi į do-while ciklus ir priekyje pridedamas sąlyginis if sakinys patikrinantis while sąlygą. Apie kitus vertimo būdus pakalbėsiu pavyzdžio šiam būdui. Tarkim, jog turim štai tokį kodo gabalą:

while ( yarr > 10 ) {
argh = argh - yarr;
yarr--;
}

Jis būtų paverstas į štai tokį alternatyvų kodą:

if ( yarr > 10 ) {
do {
argh = argh - yarr;
yarr--;
} while ( yarr > 10 );
}

Ir štai asm listingas (sugeneruotas VS 2k5 su pilnom optimizacijom (/Ox)) :

00401755 | MOV EAX,DWORD PTR SS:[ESP+4]
; EAX = yarr , beto kaip ir minėjau steko skiltyje, kartais optimizuojantys kompiliatoriai kintamūjų pasiekimui naudoja ESP
00401759 | CMP EAX,0A
; lyginamas yarr su 10
0040175C | MOV ECX,DWORD PTR SS:[ESP]
; ECX = argh, įterpiama instrukcija tarp lyginimo ir šuolio instrukcijos norint pasiekti didesni paraleliškumą
0040175F | JLE SHORT Test.00401772
; pagal šuolio instrukciją matome, jog if'o sąlyga kaip ir turi būti - yra apversta.
00401761 | SUB ECX,EAX
; argh - yarr
00401763 | SUB EAX,1
; yarr--
00401766 | CMP EAX,0A
; yarr lyginamas su 10
00401769 | JG SHORT Test.00401761
; jei jis didesnis už dešimt yra šokama atgal
0040176B | MOV DWORD PTR SS:[ESP+4],EAX
; yarr gražinamas į savo vietą steke
0040176F | MOV DWORD PTR SS:[ESP],ECX
; tas pats padaroma ir su argh

Kaip ir minėjau yra ir kitų while ciklo vertimo būdų, vienas iš jų būtų tikrinant sąlygą priekyje ir ant galo dadedant besąlygišką šuolį atgal. Pažiurėkim to pačio kodo pavyzdį išversta į asemblerį VS 2k5 kompiliatoriaus, tačiau šį kartą be jokių optimizacijų:

00401757 | CMP DWORD PTR SS:[EBP-4],0A
; Tikrinama sąlyga..
0040175B | JLE SHORT Test.00401771
; Šokama jei yarr mažesnis arba lygus 10
0040175D | MOV EDX,DWORD PTR SS:[EBP-8]
; EDX = argh
00401760 | SUB EDX,DWORD PTR SS:[EBP-4]
; argh - yarr
00401763 | MOV DWORD PTR SS:[EBP-8],EDX
; argh eina atgal į savo vietą steke :)
00401766 | MOV EAX,DWORD PTR SS:[EBP-4]
; EAX = yarr
00401769 | SUB EAX,1
; yarr--
0040176C | MOV DWORD PTR SS:[EBP-4],EAX
; yarr eina į savo vietą steke
0040176F | JMP SHORT Test.00401757
; šuolis atgal..

Na jei viską gerai supratot, tai jum turėjo iškilti klausimas dėko kompiliatorius įjungus optimizacijas pasirinko pirmąjį būdą, kuris yra netgi didesnis viena instrukcija už antrąjį? Paslaptis slypi tame, jog šuoliai atgal, t.y. į žemesnius atminties adresus IA-32 šeimos procesoriuose yra greitesni, nei šuoliai į priekį, o pirmame būde vienas toks yra sutaupomas. Juk antrame būde jei įeisime į tą ciklą iš jo išeiti galėsime, šiuo atveju, su JLE instrukcija, kuri bus šuolis į priekį, o pirmame būde jei sąlyga bus nebeteisinga paprasčiausiai nuvažiuosime žemyn.

for ciklai

for ciklai implementuojami kaip while ciklai tik prieš patį ciklą yra pridedamas inicijavimo kodas , o dar pačiam cikle yra įdedamas veiksmo kodas. Na pavyzdžiui:

for (a;b;c) {
...
}

būtų paverstas į:

a;
while (b) {
c
...
}

Tiek ir tebūtų apie for sakinius, nes jie kaip matote niekuom beveik nesiskiria nuo while sakinių.

Pabaigai

Taigi, pirmiausia norėčiau padėkoti tiems kas sugebėjo perskaityti iki pabaigos ir tegaliu tikėtis, jog nebuvau per daug nuobodus, nepridariau daug klaidų ir nenusišnekėjau. Tikiuos sužinojot/išmokot ka nors naujo ir, jog šis straipsnis jums buvo nors kiek naudingas, nes aš jam parašyti įdėjau nemažai pastangų ir laiko. ;) Beto laukiu komentarų, pasiūlymų kitiems straipsniams, kritikos, tik prašyčiau konstruktyvios.


Komentarai

Vardas:
Komentaras:

Copyright © 2005 - 2010, UAB „Critical Security“