Felügyelt nyelvek futásidejű szerkezete és a Go bináris AMD64 assembly elemzése

Mi az a menedzselt nyelv?
A menedzselt nyelv olyan nyelv, amely a nem menedzselt nyelvekkel ellentétben, amelyek esetében a programozó által megírt logikát lényegében csak végrehajtják, a szemétgyűjtést (GC), a futásidejű optimalizálást, a zöld szálakat, a konkurens feldolgozást és hasonló feladatokat a futásidejű környezetben végzi el, így a felhasználónak nem kell veszélyes, alacsony szintű erőforrás-kezelést végeznie. Az ilyen nyelvek előnye, hogy a fejlesztő kizárólag az üzleti logikára koncentrálhat és teljesen elmerülhet a fejlesztésben, hátrányuk azonban, hogy a programozó elképzelése és a program tényleges működése eltérhet egymástól, ezért néha finomhangolt futásidejű beállításokra is szükség lehet. Először a menedzselt nyelvek közül azt a Go nyelvet vizsgáljuk meg, amely a legszigorúbban követi a minimalista filozófiát és az assembly kódja a leginkább őszinte.
A Go nyelv bináris szerkezete
| .text | .data | .gopclntab, .typelink stb. |
|---|---|---|
| Végrehajtandó gépi kód | Tárolandó adatok | Nyelvi futásidejű szekciók |
Mivel a Go nyelv nem fordítja le 1:1 arányban gépi kódra a felhasználó által megadott kódot, a .text szekció logikája szorosan összefügg a nyelvi futásidejű szekciókkal. Emellett a felhasználó által nem írt függvények, például a runtime.printnl(), is bekerülnek a .text szekció assembly kódjába. Ezen automatikus kódbeszúrás révén a Go nyelv segít abban, hogy a fejlesztő megszabaduljon a kézi erőforrás-kezeléstől.
Csak a main függvény vizsgálata Go-ban
Először írjunk egy egyszerű, main.go nevű példaforrást, és nézzük meg a main függvényt AMD64 gépen.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Ezután fordítsuk le a következő paranccsal.
1go build main.go
2
A Go az egyszerű alacsony szintű hibakereséshez a go tool eszközt biztosítja. A go toolban a főcsomag main függvényének megfelelő assembly kódrészlet megtekintéséhez a következő parancsot adjuk ki.
1go tool objdump -s "main\.main" ./main
2
Assembly
1TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
2 main.go:7 0x468f60 493b6610 CMPQ SP, 0x10(R14)
3 main.go:7 0x468f64 762f JBE 0x468f95
4 main.go:7 0x468f66 55 PUSHQ BP
5 main.go:7 0x468f67 4889e5 MOVQ SP, BP
6 main.go:7 0x468f6a 4883ec10 SUBQ $0x10, SP
7 main.go:8 0x468f6e 90 NOPL
8 main.go:4 0x468f6f e8cca3fcff CALL runtime.printlock(SB)
9 main.go:4 0x468f74 488d05da290100 LEAQ 0x129da(IP), AX
10 main.go:4 0x468f7b bb0b000000 MOVL $0xb, BX
11 main.go:4 0x468f80 e83bacfcff CALL runtime.printstring(SB)
12 main.go:4 0x468f85 e8f6a5fcff CALL runtime.printnl(SB)
13 main.go:4 0x468f8a e811a4fcff CALL runtime.printunlock(SB)
14 main.go:9 0x468f8f 4883c410 ADDQ $0x10, SP
15 main.go:9 0x468f93 5d POPQ BP
16 main.go:9 0x468f94 c3 RET
17 main.go:7 0x468f95 e8e6afffff CALL runtime.morestack_noctxt.abi0(SB)
18 main.go:7 0x468f9a ebc4 JMP main.main(SB)
19
- A CMPQ utasítással összehasonlítjuk a jelenlegi goroutine veremkeretének elegendő méretét a goroutine vezérlőblokk regiszterében (R14) lévő veremőr értékkel és a jelenlegi veremmutatóval (SP). Ha a hely nem elegendő, a JBE utasítással a 0x468f95 címre ugrunk, amely a verem bővítésének belépési pontja.
- A PUSHQ BP utasítással elmentjük a korábbi bázismutatót a verembe.
- A MOVQ SP, BP utasítással a bázismutató (BP) regiszterbe másoljuk a jelenlegi veremmutatót, így rögzítjük a függvény indulásakor érvényes veremalapot.
- Ezt követően 16 bájtnyi lokális változó-területet foglalunk le (SUBQ $0x10, SP), a NOPL utasítással pedig virtuális utasításokkal töltjük ki a helyet a CPU-gyorsítótár-igazítás érdekében.
- A Go futásidejű környezetében a belső karakterláncok szabványos kimenetének szinkronizálása érdekében a runtime.printlock(SB) meghívásával zárat helyezünk el.
- A LEAQ utasítással a konstansként megadott karakterlánc ("Hello World") kezdőcímét a Go ABI szabványa szerint az első paraméterül szolgáló AX általános célú regiszterbe töltjük.
- Ezután a karakterlánc hosszát jelölő értéket a második paraméterregiszterbe, BX-be írjuk. (MOVL $0xb, BX, azaz tízes számrendszerben 11)
- A runtime.printstring(SB) meghívásával az átadott AX (adatcím) és BX (hossz) információk alapján kiíratjuk a konzolra a szöveget.
- A sortörés kezelése érdekében meghívjuk a runtime.printnl(SB) függvényt.
- A kiírás befejeződött, ezért a runtime.printunlock(SB) hívással feloldjuk a zárat.
- Az ADDQ $0x10, SP utasítással visszaállítjuk a 16 bájtnyi lefoglalt veremterületet.
- A POPQ BP utasítással visszaállítjuk a korábbi bázismutatót.
- A RET utasítással a vezérlést visszaadjuk a függvényt hívó helyre.
- Ha az első veremellenőrzésnél nem volt elegendő hely, a 0x468f95 címen lévő runtime.morestack_noctxt.abi0(SB) függvényt hívjuk meg, amely a menedzselt nyelvekhez illő módon dinamikusan bővíti a verem futásidejű környezetét.
- A verem bővítésének befejezése után a JMP utasítással visszatérünk a main.main(SB) belépési pontjára. Amint látható, az üzleti logika assembly kódja meglehetősen egyértelmű, és csupán vékony futásidejű erőforrás-kezelés egészül ki vele.
Optimalizálás nélkül
A fenti forma annak az eredménye, hogy a Go fordító automatikusan beágyazta (inline) a két különálló függvényt. Mivel azonban a tanulás céljából nem szeretnénk, hogy ebben az esetben a sayHello függvény beágyazásra kerüljön, a forrást a következő kapcsolóval fordítjuk le.
1go build -gcflags="-l" main.go
2
A terminálon az eredményt megvizsgálva ismétlődő assembly kódrészleteket találunk.
1yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
2
3go tool objdump -s "main\.sayHello" ./main
4TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
5 main.go:3 0x468f60 493b6610 CMPQ SP, 0x10(R14)
6 main.go:3 0x468f64 7636 JBE 0x468f9c
7 main.go:3 0x468f66 55 PUSHQ BP
8 main.go:3 0x468f67 4889e5 MOVQ SP, BP
9 main.go:3 0x468f6a 4883ec10 SUBQ $0x10, SP
10 main.go:5 0x468f6e 4889442420 MOVQ AX, 0x20(SP)
11 main.go:5 0x468f73 48895c2428 MOVQ BX, 0x28(SP)
12 main.go:4 0x468f78 e8c3a3fcff CALL runtime.printlock(SB)
13 main.go:4 0x468f7d 488b442420 MOVQ 0x20(SP), AX
14 main.go:4 0x468f82 488b5c2428 MOVQ 0x28(SP), BX
15 main.go:4 0x468f87 e834acfcff CALL runtime.printstring(SB)
16 main.go:4 0x468f8c e8efa5fcff CALL runtime.printnl(SB)
17 main.go:4 0x468f91 e80aa4fcff CALL runtime.printunlock(SB)
18 main.go:5 0x468f96 4883c410 ADDQ $0x10, SP
19 main.go:5 0x468f9a 5d POPQ BP
20 main.go:5 0x468f9b c3 RET
21 main.go:3 0x468f9c 4889442408 MOVQ AX, 0x8(SP)
22 main.go:3 0x468fa1 48895c2410 MOVQ BX, 0x10(SP)
23 main.go:3 0x468fa6 e8d5afffff CALL runtime.morestack_noctxt.abi0(SB)
24 main.go:3 0x468fab 488b442408 MOVQ 0x8(SP), AX
25 main.go:3 0x468fb0 488b5c2410 MOVQ 0x10(SP), BX
26 main.go:3 0x468fb5 eba9 JMP main.sayHello(SB)
27
Ha kikapcsoljuk a beágyazást, a függvényhívási konvenciónak megfelelően a paraméterek (AX, BX) megőrzése érdekében olyan MOVQ műveletek kerülnek be, amelyek a 0x20(SP) stackmutató-eltolású helyekre töltik vissza az értékeket. Vagyis megállapítható, hogy a fordító optimalizációja éppen ezeknek a felesleges memóriamozgatási műveleteknek és hívási többletterhelésnek a kiküszöbölésére irányul.
Következő alkalom
A következő alkalommal a Go nyelv if és switch utasításaival foglalkozunk. Ha később marad rá idő, a Go futásidejű szekcióit is elemezni fogjuk.