Analýza runtime štruktúry spravovaných jazykov a assembly kódu AMD64 binárnych súborov Go

Čo sú spravované jazyky?
Spravované jazyky sú jazyky, ktoré na rozdiel od nespravovaných jazykov, t. j. jazykov, ktoré iba vykonávajú logiku napísanú programátorom bez väčšej odchýlky od nej, v runtime vykonávajú GC, runtime optimalizácie, zelené vlákna, spracovanie súbežnosti a ďalšie úlohy, a tým umožňujú používateľovi vyhnúť sa nebezpečnej nízkoúrovňovej správe pamäte a zdrojov. Pri takýchto jazykoch má vývojár tú výhodu, že sa môže sústrediť výlučne na biznis logiku a ponoriť sa do vývoja, avšak na druhej strane sa môže stať, že skutočné správanie programu sa líši od intuície programátora, a preto sú niekedy potrebné precízne úpravy runtime. Najprv sa pozrieme na jazyk Go, ktorý je medzi spravovanými jazykmi najvernejší minimalistickej filozofii a jeho assembly je priamočiare.
Binárna štruktúra jazyka Go
| .text | .data | .gopclntab, .typelink atď. |
|---|---|---|
| Strojový kód na vykonanie | Dáta na uloženie | Sekcie jazykového runtime |
Keďže jazyk Go neprekladá vstup používateľa do strojového kódu 1:1, logika v sekcii .text úzko súvisí aj so sekciami jazykového runtime. Okrem toho sa do assembly sekcie .text pridávajú funkcie, ktoré používateľ nenapísal, napríklad runtime.printnl(). Vďaka takémuto automatickému vkladaniu kódu Go pomáha vývojárovi oslobodiť sa od manuálnej správy.
Pohľad na funkciu main v jazyku Go
Najprv vytvoríme jednoduchý príkladový zdrojový súbor main.go a pozrieme sa na main na stroji AMD64.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Potom ho zostavíme takto.
1go build main.go
2
Go na jednoduché nízkoúrovňové ladenie podporuje nástroj go tool. Aby sme v nástroji go tool zobrazili iba assembly funkcie main z hlavného balíka, zadáme tento príkaz.
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
- Porovná sa stráž zásobníka v registri ovládacieho bloku goroutine (R14) s aktuálnym ukazovateľom zásobníka (SP) pomocou CMPQ, aby sa zistilo, či je dostatok miesta pre rámec zásobníka aktuálnej goroutine. Ak miesto nestačí, skok (JBE) smeruje na adresu 0x468f95, ktorá je vstupným bodom pre rozšírenie zásobníka.
- Pomocou PUSHQ BP sa na zásobník uloží predchádzajúci ukazovateľ bázy.
- Do registra ukazovateľa bázy (BP) sa skopíruje aktuálny ukazovateľ zásobníka (SP), čím sa pevne určí referenčný bod zásobníka na začiatku funkcie.
- Potom sa alokuje 16 bajtov priestoru pre lokálne premenné zásobníka (SUBQ $0x10, SP) a pomocou NOPL sa vyplnia virtuálne inštrukcie, aby sa zabezpečilo zarovnanie pre CPU cache.
- Na synchronizáciu štandardného výstupu reťazcov v runtime Go sa volaním runtime.printlock(SB) získa zámok.
- Pomocou inštrukcie LEAQ sa do registra AX, ktorý podľa ABI jazyka Go slúži ako prvý parameter, uloží začiatočná adresa konštantného reťazca ("Hello World").
- Následne sa dĺžka reťazca uloží do registra BX, ktorý slúži ako druhý parameter (MOVL $0xb, BX, t. j. v desiatkovej sústave 11).
- Volaním runtime.printstring(SB) sa na základe odovzdaných hodnôt AX (adresa dát) a BX (dĺžka) vypíše výstup do konzoly.
- Na spracovanie nového riadku sa volá runtime.printnl(SB).
- Keďže výstup je dokončený, zámok sa uvoľní volaním runtime.printunlock(SB).
- Pamäť zásobníka o veľkosti 16 bajtov alokovaná pomocou ADDQ $0x10, SP sa obnoví.
- Pomocou POPQ BP sa obnoví pôvodný ukazovateľ bázy.
- Pomocou RET sa riadenie vráti na miesto, odkiaľ bola funkcia volaná.
- Ak pri prvej kontrole zásobníka chýbal priestor, volá sa runtime.morestack_noctxt.abi0(SB) na adrese 0x468f95, ktorá dynamicky rozširuje zásobník runtime, ako je v spravovaných jazykoch zvykom.
- Po dokončení rozšírenia zásobníka sa skokom (JMP) vráti na vstupný bod main.main(SB). Ako vidno, assembly biznis logiky je pomerne jasné a pridáva sa k nemu iba tenká vrstva správy runtime.
Keď nie je optimalizácia
Vyššie uvedená podoba je výsledkom toho, že kompilátor Go automaticky inline-oval dve oddelené funkcie a optimalizoval ich. Na účely učenia však nechceme, aby sa sayHello inline-ovala, preto zdrojový kód skompilujeme s týmto príznakom.
1go build -gcflags="-l" main.go
2
Ak výsledok zobrazíme v shelli, nájdeme duplicitné assembly.
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
Pri vypnutí inline optimalizácie sa do inštrukcií MOVQ vkladajú operácie, ktoré uložia parametre (AX, BX) na offsety ukazovateľa zásobníka, napríklad 0x20(SP), aby sa zachovali podľa konvencie volania funkcie. Kompilátor teda optimalizuje práve takéto zbytočné presuny pamäte a réžiu volaní, čo sa tu potvrdilo.
Ďalšia časť
V ďalšej časti sa budeme venovať príkazom if a switch v jazyku Go. Ak budem mať čas, plánujem analyzovať aj sekcie runtime jazyka Go.