Understanding C by learning assembly

Legutóbb Alan megmutatta, hogyan kell használni a GDB-t eszközként a C tanulásához.

az absztrakciós rétegek nagyszerű eszközök a dolgok építéséhez, de néha akadályozhatják a tanulást. Célom ebben a bejegyzésben az, hogy meggyőzzem Önt arról, hogy a C szigorú megértése érdekében meg kell értenünk azt az összeállítást is, amelyet C fordítónk generál. Ezt úgy fogom megtenni, hogy megmutatom, hogyan kell szétszerelni és elolvasni egy egyszerű programot a GDB – vel, majd a GDB-t és az összeszerelési ismereteinket használjuk annak megértéséhez, hogy a statikus helyi változók hogyan működnek a C-ben.

Megjegyzés: Az ebben a bejegyzésben szereplő összes kódot egy x86_64 CPU-n állították össze, amely Mac OS X 10.8.1-et futtat a Clang 4.0 használatával, optimalizálás letiltva (-O0).

tanulási összeállítás GDB-vel

kezdjük azzal, hogy SZÉTSZERELÜNK egy programot GDB-vel, és megtanuljuk, hogyan kell olvasni a kimenetet. Írja be a következő programot egy szöveges fájlba, és mentse simple.cnéven:

int main(){ int a = 5; int b = a + 6; return 0;}

most fordítsd le hibakeresési szimbólumokkal, optimalizálás nélkül, majd futtasd a GDB-t:1

$ CFLAGS="-g -O0" make simplecc -g -O0 simple.c -o simple$ gdb simple

a GDB-n belül main – re lépünk, és addig futunk, amíg el nem érjük a return nyilatkozatot. A 2-es számot a next után adjuk meg, hogy kétszer akarjuk futtatni a next – et:

(gdb) break main(gdb) run(gdb) next 2

most használjuk a disassemble parancsot az aktuális funkció összeszerelési utasításainak megjelenítéséhez. A függvény nevét disassemble – re is átadhatja egy másik vizsgálandó függvény megadásához.

(gdb) disassembleDump of assembler code for function main:0x0000000100000f50 <main+0>: push %rbp0x0000000100000f51 <main+1>: mov %rsp,%rbp0x0000000100000f54 <main+4>: mov x0,%eax0x0000000100000f59 <main+9>: movl x0,-0x4(%rbp)0x0000000100000f60 <main+16>: movl x5,-0x8(%rbp)0x0000000100000f67 <main+23>: mov -0x8(%rbp),%ecx0x0000000100000f6a <main+26>: add x6,%ecx0x0000000100000f70 <main+32>: mov %ecx,-0xc(%rbp)0x0000000100000f73 <main+35>: pop %rbp0x0000000100000f74 <main+36>: retq End of assembler dump.

a disassembleparancs alapértelmezés szerint utasításokat ad ki az AT& T szintaxisban, amely ugyanaz a szintaxis, amelyet a GNU assembler használ.2 az AT&t szintaxis utasításai mnemonic source, destination formátumúak. A mnemonic az utasítás ember által olvasható neve. A forrás és a cél operandusok, és lehetnek azonnali értékek, regiszterek, memóriacímek vagy címkék. Az azonnali értékek állandók, és a $ előtaggal vannak ellátva. Például a x5 az 5-ös számot jelenti hexadecimálisan. A regiszternevek % előtaggal vannak ellátva.

regiszterek

érdemes egy gyors kitérőt tenni a regiszterek megértéséhez. A regiszterek adattárolási helyek közvetlenül a CPU-n. Néhány kivételtől eltekintve a CPU regisztereinek mérete vagy szélessége határozza meg annak architektúráját. Tehát ha 64 bites CPU-ja van, akkor a regiszterei 64 bit szélesek lesznek. Ugyanez vonatkozik a 32 bites CPU-kra (32 bites regiszterek), a 16 bites CPU-kra stb.A 3 regiszter nagyon gyorsan elérhető, és gyakran az aritmetikai és logikai műveletek operandusai.

az x86 család számos általános és speciális célú regiszterrel rendelkezik. Az általános célú regiszterek bármilyen művelethez használhatók, értéküknek nincs különösebb jelentése a CPU számára. Másrészt a CPU saját működéséhez speciális célú regiszterekre támaszkodik, és a benne tárolt értékeknek a regisztertől függően sajátos jelentése van. A fenti példánkban a %eax és %ecx általános célú regiszterek, míg a %rbp és %rsp speciális célú regiszterek. %rbp az alapmutató, amely az aktuális veremkeret alapjára mutat, %rsp pedig a veremmutató, amely az aktuális veremkeret tetejére mutat. Az %rbp értéke mindig magasabb, mint a %rsp, mivel a verem magas memóriacímről indul és lefelé növekszik. Ha nem ismeri a hívásköteget, talál egy jó bevezetést a Wikipédián.

az x86 család egyik furcsasága, hogy a visszamenőleges kompatibilitást egészen a 16 bites 8086 processzorig fenntartotta. Ahogy az x86 a 16 bitesről a 32 bitesre a 64 bitesre váltott, a regiszterek kibővültek, és új neveket kaptak, hogy ne törjék meg a régebbi, szűkebb CPU-khoz írt kóddal való visszafelé kompatibilitást.

Vegyük az AX általános célú regisztert, amely 16 bit széles. A magas bájt AH névvel, az alacsony bájt pedig AL névvel érhető el. Amikor a 32 bites 80386 megjelent, a kiterjesztett AX regiszter, vagy EAX, a 32 bites regiszterre utalt, míg az AX továbbra is egy 16 bites regiszterre hivatkozott, amely az EAX alsó felét alkotta. Hasonlóképpen, amikor az x86_64 architektúra megjelent, az” R ” előtagot használták, és az EAX tette ki a 64 bites Rax regiszter alsó felét. Az alábbi ábrát egy Wikipedia cikk alapján mellékeltem, hogy elősegítsem az általam leírt kapcsolatok megjelenítését:

|__64__|__56__|__48__|__40__|__32__|__24__|__16__|__8___||__________________________RAX__________________________||xxxxxxxxxxxxxxxxxxxxxxxxxxx|____________EAX____________||xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|_____AX______||xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|__AH__|__AL__|

vissza a kódhoz

ennek elegendő információnak kell lennie a szétszerelt programunk olvasásának megkezdéséhez.

0x0000000100000f50 <main+0>: push %rbp0x0000000100000f51 <main+1>: mov %rsp,%rbp

az első két utasítást prológnak vagy preambulumnak nevezzük. Először a régi alapmutatót toljuk a veremre, hogy később elmentsük. Ezután átmásoljuk a veremmutató értékét az alapmutatóra. Ezután %rbpmain veremkeretének alapjára mutat.

0x0000000100000f54 <main+4>: mov x0,%eax

ez az utasítás a 0-t a %eax – be másolja. Az x86 hívási konvenció azt diktálja, hogy egy függvény visszatérési értéke %eax – ben van tárolva, tehát a fenti utasítás beállítja, hogy a függvény végén 0-t adjunk vissza.

0x0000000100000f59 <main+9>: movl x0,-0x4(%rbp)

itt van valami, amivel még nem találkoztunk: -0x4(%rbp). A zárójelek tudatják velünk, hogy ez egy memóriacím. Itt az %rbp az alapregiszter, a -0x4 pedig az elmozdulás. Ez egyenértékű a %rbp + -0x4értékkel. Mivel a verem lefelé növekszik, a 4 kivonása az aktuális veremkeret alapjából magával az aktuális keretbe visz minket, ahol a helyi változók tárolódnak. Ez azt jelenti, hogy ez az utasítás a 0 értéket %rbp - 4 – nél tárolja. Eltartott egy ideig, amíg kitaláltam, mi ez a sor, de úgy tűnik, hogy a clang egy rejtett helyi változót oszt ki egy implicit visszatérési értékhez main.

azt is észreveheti, hogy az emlékeztető utótagja l. Ez azt jelenti, hogy az operandusok long (32 bit egész számokra). További érvényes utótagok: byte, short, word, quad és t hu. Ha olyan utasítást lát, amelynek nincs utótagja, akkor az operandusok méretét a forrás-vagy célregiszter méretéből következtetik ki. Például az előző sorban a %eax 32 bit széles, így a movutasításból movl következik.

0x0000000100000f60 <main+16>: movl x5,-0x8(%rbp)

most bekerülünk a hús a mi minta program! Az összeszerelés első sora a C első sora main – ben, és az 5-ös számot a következő elérhető helyi változó nyílásban tárolja (%rbp - 0x8), 4 bájttal lejjebb az utolsó helyi változónktól. Ez a a helye. A GDB segítségével ellenőrizhetjük ezt:

(gdb) x &a0x7fff5fbff768: 0x00000005(gdb) x $rbp - 80x7fff5fbff768: 0x00000005

vegye figyelembe, hogy a memóriacímek megegyeznek. Észre fogod venni, hogy a GDB változókat állít be a regisztereinkhez, de mint minden változó a GDB-ben, mi is $ előtaggal állítjuk elő, nem pedig az AT % – ban használt&t összeállítással.

0x0000000100000f67 <main+23>: mov -0x8(%rbp),%ecx0x0000000100000f6a <main+26>: add x6,%ecx0x0000000100000f70 <main+32>: mov %ecx,-0xc(%rbp)

ezután áthelyezzük a a – t a %ecx – be, az egyik általános célú regiszterünkbe, hozzáadjuk a 6-ot, és az eredményt %rbp - 0xc – ben tároljuk. Ez a C második sora main – ben. Talán rájöttél, hogy a %rbp - 0xc b, amit a GDB-ben ellenőrizhetünk:

(gdb) x &b0x7fff5fbff764: 0x0000000b(gdb) x $rbp - 0xc0x7fff5fbff764: 0x0000000b

a main többi része csak tisztítás, az úgynevezett funkció epilógus:

0x0000000100000f73 <main+35>: pop %rbp0x0000000100000f74 <main+36>: retq 

mi pop a régi alap mutató ki a verem és tárolja vissza %rbp majd retq ugrik vissza a feladó címet, amely szintén tárolja a verem keret.

eddig a GDB-t használtuk egy rövid C program szétszerelésére, áttekintettük, hogyan kell olvasni& T assembly szintaxis mellett, valamint lefedett regisztereket és memóriacím-operandusokat. A GDB-t arra is használtuk, hogy ellenőrizzük, hol vannak a helyi változóink a %rbp – hez viszonyítva. Most az újonnan megszerzett képességeinket fogjuk használni, hogy elmagyarázzuk, hogyan működnek a statikus helyi változók.

a statikus lokális változók megértése

a statikus lokális változók nagyon klassz tulajdonságai A C-nek.dióhéjban ezek olyan lokális változók, amelyek csak egyszer inicializálódnak, és értékeiket több hívás során megőrzik a függvényhez, ahol meg vannak határozva. A statikus helyi változók egyszerű felhasználási esete egy Python stílusú generátor. Itt van egy, amely az összes természetes számot generálja INT_MAX:

/* static.c */#include <stdio.h>int natural_generator(){ int a = 1; static int b = -1; b += 1; return a + b;}int main(){ printf("%d\n", natural_generator()); printf("%d\n", natural_generator()); printf("%d\n", natural_generator()); return 0;}

amikor lefordítják és futtatják, ez a program kiírja az első három természetes számot:

$ CFLAGS="-g -O0" make staticcc -g -O0 static.c -o static$ ./static123

de hogyan működik ez? Hogy megértsük a statikus helyieket, beugrunk a GDB-be, és megnézzük az összeszerelést. Eltávolítottam azokat a címadatokat, amelyeket a GDB hozzáad a szétszereléshez, hogy minden illeszkedjen a képernyőre:

$ gdb static(gdb) break natural_generator(gdb) run(gdb) disassembleDump of assembler code for function natural_generator:push %rbpmov %rsp,%rbpmovl x1,-0x4(%rbp)mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>add x1,%eaxmov %eax,0x16c(%rip) # 0x100001018 <natural_generator.b>mov -0x4(%rbp),%eaxadd 0x163(%rip),%eax # 0x100001018 <natural_generator.b>pop %rbpretq End of assembler dump.

az első dolog, amit meg kell tennünk, hogy kitaláljuk, milyen utasítást kapunk. Ezt megtehetjük az utasításmutató vagy a programszámláló vizsgálatával. Az utasításmutató egy regiszter, amely a következő utasítás memóriacímét tárolja. Az x86_64-en ez a regiszter %rip. Az utasításmutatót a $rip változóval érhetjük el, vagy alternatívaként használhatjuk az architektúrát független $pc:

(gdb) x/i $pc0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)

az utasításmutató mindig tartalmazza a következő futtatandó utasítás címét, ami azt jelenti, hogy a harmadik utasítás még nem futott, de hamarosan.

mivel a következő utasítás ismerete hasznos, a GDB-vel minden alkalommal megmutatjuk a következő utasítást, amikor a program leáll. GDB 7.0 vagy újabb, csak futtathatja a set disassemble-next-line on parancsot, amely megmutatja az összes utasítást, amely a forrás következő sorát alkotja, de Mac OS X-et használunk, amely csak a GDB 6.3-mal szállít, ezért a display parancshoz kell folyamodnunk. A display olyan, mint a x, kivéve, hogy minden alkalommal kiértékeli a kifejezését, amikor a program leáll:

(gdb) display/i $pc1: x/i $pc 0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)

most a GDB úgy van beállítva, hogy mindig megmutassa nekünk a következő utasítást, mielőtt megjelenítené a promptot.

már túl vagyunk a függvényprológuson, amelyet korábban lefedtünk, tehát a harmadik utasításnál kezdjük. Ez megfelel az első forrássornak, amely 1-et rendel a – hez. A next helyett, amely a következő forrássorra lép, a nexti – ot fogjuk használni, amely a következő assembly utasításra lép. Ezután megvizsgáljuk %rbp - 0x4 hipotézisünk igazolására, hogy a%rbp - 0x4 – nél van tárolva.

(gdb) nexti7 b += 1;1: x/i $pc mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>(gdb) x $rbp - 0x40x7fff5fbff78c: 0x00000001(gdb) x &a0x7fff5fbff78c: 0x00000001

ugyanazok, mint amire számítottunk. A következő utasítás érdekesebb:

mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>

ez az, ahol azt várnánk, hogy megtalálja a vonalat static int b = -1;, de úgy néz ki, lényegesen más, mint bármi, amit korábban láttunk. Egyrészt nincs utalás a veremkeretre, ahol általában elvárnánk a helyi változók megtalálását. Nincs még egy -0x1! Ehelyett van egy utasításunk, amely 0x100001018 – et tölt be, valahol az utasításmutató után, %eax – be. A GDB hasznos megjegyzést ad nekünk a memória operandus kiszámításának eredményével, valamint egy tippet, amely szerint az natural_generator.b ezen a címen van tárolva. Futtassuk le ezt az utasítást, és derítsük ki, mi folyik itt:

(gdb) nexti(gdb) p $rax = 4294967295(gdb) p/x $rax = 0xffffffff

annak ellenére, hogy a szétszerelés a %eax – et mutatja rendeltetési helyként, a $rax – et nyomtatjuk, mert a GDB csak a teljes szélességű regiszterekhez állít be változókat.

ebben a helyzetben emlékeznünk kell arra, hogy míg a változóknak vannak olyan típusai, amelyek meghatározzák, hogy aláírtak-e vagy sem, a regiszterek nem, így a GDB %rax aláíratlan értéket nyomtat. Próbáljuk meg újra, a %rax egy aláírt int:

(gdb) p (int)$rax = -1

úgy tűnik, hogy b – et találtunk. Ezt a x paranccsal ellenőrizhetjük:

(gdb) x/d 0x1000010180x100001018 <natural_generator.b>: -1(gdb) x/d &b0x100001018 <natural_generator.b>: -1

tehát nem csak a b tárolódik alacsony memóriacímen a veremen kívül, hanem inicializálódik -1-re is, mielőtt a natural_generator – et még meghívnák. Valójában, még akkor is, ha szétszerelte a teljes programot, nem talál olyan kódot, amely a b értéket -1-re állítja. Ez azért van, mert a b értéke a sample futtatható fájl egy másik részében van kódolva, és az operációs rendszer betöltője a folyamat indításakor betölti a memóriába az összes gépi kóddal együtt.4

ezzel az útból, a dolgok kezdenek több értelme. Miután b – et %eax – ben tároltuk, a forrás következő sorába lépünk, ahol b – et növelünk. Ez megfelel a következő két utasításnak:

add x1,%eaxmov %eax,0x16c(%rip) # 0x100001018 <natural_generator.b>

itt hozzáadjuk az 1-et a %eax – hez, és az eredményt visszahelyezzük a memóriába. Futtassuk le ezeket az utasításokat, és ellenőrizzük az eredményt:

(gdb) nexti 2(gdb) x/d &b0x100001018 <natural_generator.b>: 0(gdb) p (int)$rax = 0

a következő két utasítás arra késztetett minket, hogy visszatérjünk a + b:

mov -0x4(%rbp),%eaxadd 0x163(%rip),%eax # 0x100001018 <natural_generator.b>

itt töltjük be a a – t a %eax – be, majd adjuk hozzá a b – et. Ezen a ponton azt várnánk, hogy %eax 1 lesz. Ellenőrizzük:

(gdb) nexti 2(gdb) p $rax = 1

%eax a visszatérési érték natural_generator – től való tárolására szolgál, tehát mindannyian készen állunk az epilógusra, amely megtisztítja a veremet:

pop %rbpretq 

most már megértjük, hogyan inicializálódik a b, nézzük meg, mi történik, ha újra futtatjuk a natural_generator parancsot:

(gdb) continueContinuing.1Breakpoint 1, natural_generator () at static.c:55 int a = 1;1: x/i $pc 0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)(gdb) x &b0x100001018 <natural_generator.b>: 0

mivel a b nincs tárolva a veremben más helyi változókkal együtt, a natural_generator újbóli meghívásakor még mindig nulla. Nem számít, hányszor hívják meg generátorunkat, a b mindig megtartja korábbi értékét. Ez azért van, mert a veremen kívül tárolják és inicializálják, amikor a betöltő a programot a memóriába mozgatja, nem pedig bármelyik gépi kódunkkal.

következtetés

azzal kezdtük, hogy áttekintettük, hogyan kell olvasni az összeszerelést és hogyan kell szétszerelni egy programot a GDB-vel. Ezután kitértünk a statikus helyi változók működésére, amit nem tudtunk volna megtenni a futtatható fájl szétszerelése nélkül.

sok időt töltöttünk az összeszerelési utasítások olvasása és a hipotézisek GDB-ben történő ellenőrzése között. Ismétlődőnek tűnhet, de nagyon fontos oka van annak, hogy így csináljuk a dolgokat: a legjobb módja annak, hogy valami elvontat megtanuljunk, ha konkrétabbá tesszük, és az egyik legjobb módja annak, hogy valami konkrétabbat készítsünk, olyan eszközök használata, amelyek lehetővé teszik az absztrakció rétegeinek lehámozását. A legjobb módja annak, hogy megtanulják ezeket az eszközöket, hogy kényszerítse magát, hogy használja őket, amíg ők a második természet.

  1. észre fogja venni, hogy a Make-et használjuk az egyszerű felépítéshez.C ‘ makefile nélkül. Ezt azért tehetjük meg, mert a Make implicit szabályokat tartalmaz a végrehajtható fájlok C fájlokból történő felépítésére. További információ ezekről a szabályokról a (http://www.gnu.org/software/make/manual/make.html#Implicit-Rules). ↩

  2. rendelkezhet GDB kimeneti Intel szintaxissal is, amelyet a NASM, a MASM és más összeszerelők használnak, de ez kívül esik ezen a poszton. ↩

  3. a SIMD utasításkészletekkel rendelkező processzorok, mint az MMX és az SSE az x86-hoz és az AltiVec a PowerPC-hez gyakran tartalmaznak néhány regisztert, amelyek szélesebbek, mint a CPU architektúra.↩

  4. az objektumformátumok, a rakodók és a linkerek megvitatása a legjobb egy jövőbeli blogbejegyzéshez.↩

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.