Analiza struktury runtime języków zarządzanych oraz assembly AMD64 w plikach binarnych Go

Czym jest język zarządzany?
Język zarządzany to język, który w przeciwieństwie do języków niezarządzanych, czyli języków, w których program wykonuje się w zasadzie zgodnie z logiką napisaną przez programistę, uruchamia w środowisku wykonawczym takie mechanizmy jak GC, optymalizacje w czasie działania, zielone wątki (green threads) oraz obsługę współbieżności, dzięki czemu użytkownik nie musi wykonywać niebezpiecznego zarządzania na niskim poziomie. W przypadku takich języków zaletą jest możliwość skupienia się na logice biznesowej i pełnego zaangażowania w rozwój oprogramowania, jednak z drugiej strony działanie programu może różnić się od intuicji programisty, dlatego czasami potrzebne jest precyzyjne dostrajanie środowiska wykonawczego. Na początek przyjrzyjmy się językowi Go, który spośród języków zarządzanych najwierniej realizuje minimalistyczną filozofię i w którym assembler jest przejrzysty.
Struktura binarna Go
| .text | .data | .gopclntab, .typelink itd. |
|---|---|---|
| Kod maszynowy do wykonania | Dane do zapisania | Sekcje środowiska wykonawczego języka |
Ponieważ Go nie tłumaczy kodu na maszynowy w relacji 1:1 względem tego, co wpisał użytkownik, logika sekcji .text jest ściśle powiązana z sekcjami środowiska wykonawczego języka. Ponadto do assemblera sekcji .text dodawane są funkcje, których użytkownik nie napisał, takie jak runtime.printnl(). Dzięki takiemu automatycznemu wstawianiu kodu Go pomaga programistom uwolnić się od ręcznego zarządzania pamięcią.
Oglądanie tylko funkcji main w Go
Najpierw utwórzmy prosty przykładowy plik źródłowy main.go i przyjrzyjmy się funkcji main, począwszy od niej, na maszynie AMD64.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Następnie budujemy program w ten sposób.
1go build main.go
2
Go udostępnia narzędzie go tool, które ułatwia niskopoziomowe debugowanie. Aby zobaczyć tylko assembler odpowiadający funkcji main w pakiecie main, wpisujemy następujące polecenie.
1go tool objdump -s "main\.main" ./main
2
Assembler
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
- Za pomocą CMPQ porównuje się wartość strażnika stosu znajdującą się w rejestrze bloku kontrolnego gorutyny (R14) z bieżącym wskaźnikiem stosu (SP), aby sprawdzić, czy przestrzeń ramki stosu gorutyny jest wystarczająca. Jeśli jest za mało miejsca, następuje skok (JBE) pod adres 0x468f95, który jest punktem wejścia do rozszerzenia stosu.
- Poprzedni wskaźnik bazowy zapisuje się na stosie instrukcją PUSHQ BP.
- Do rejestru wskaźnika bazowego (BP) kopiuje się bieżący wskaźnik stosu (SP), dzięki czemu ustala się punkt odniesienia stosu na początku funkcji.
- Następnie przydziela się 16 bajtów przestrzeni na zmienne lokalne (SUBQ $0x10, SP), a instrukcja NOPL wypełnia miejsce pustymi instrukcjami w celu wyrównania do linii pamięci podręcznej procesora.
- Aby zsynchronizować standardowe wyjście dla tekstu w środowisku wykonawczym Go, wywoływana jest runtime.printlock(SB), która zakłada blokadę.
- Instrukcja LEAQ zapisuje adres początkowy stałego łańcucha ("Hello World") w rejestrze AX, który zgodnie ze specyfikacją Go ABI służy jako pierwszy parametr.
- Następnie długość łańcucha zapisywana jest w rejestrze BX, drugim rejestrze parametru (MOVL $0xb, BX, czyli 11 w systemie dziesiętnym).
- Wywoływana jest runtime.printstring(SB), która na podstawie przekazanych AX (adres danych) i BX (długość) wypisuje tekst na konsoli.
- Do obsługi znaku nowej linii wywoływana jest runtime.printnl(SB).
- Ponieważ wyjście zostało zakończone, blokada zwalniana jest poprzez runtime.printunlock(SB).
- Instrukcja ADDQ $0x10, SP przywraca 16 bajtów pamięci stosu, które wcześniej przydzielono.
- Instrukcja POPQ BP przywraca poprzedni wskaźnik bazowy.
- Instrukcja RET zwraca sterowanie do miejsca wywołania funkcji.
- Jeśli przy pierwszym sprawdzeniu stosu zabrakło miejsca, wywoływana jest runtime.morestack_noctxt.abi0(SB) pod adresem 0x468f95, która dynamicznie rozszerza stos w sposób typowy dla języka zarządzanego.
- Po zakończeniu rozszerzania stosu następuje powrót (JMP) do punktu wejścia main.main(SB). Jak widać, assembler logiki biznesowej jest dość czytelny i obejmuje jedynie cienką warstwę zarządzania środowiskiem wykonawczym.
Gdy brak optymalizacji
Powyższa postać jest wynikiem automatycznego inline'owania dwóch osobnych funkcji przez kompilator Go. Jednak do celów edukacyjnych nie będziemy inline'ować sayHello. Aby to osiągnąć, kompilujemy kod z następującą flagą.
1go build -gcflags="-l" main.go
2
Po wyświetleniu wyniku w powłoce widać powtarzające się fragmenty assemblera.
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
Po wyłączeniu inline'owania, aby zachować parametry (AX, BX) zgodnie z konwencją wywoływania funkcji, do assemblera wstawiane są instrukcje MOVQ, które ponownie zapisują wartości pod przesunięciami wskaźnika stosu, takimi jak 0x20(SP). Oznacza to, że optymalizacja kompilatora dotyczy właśnie tych zbędnych operacji przenoszenia pamięci oraz narzutu wywołań, co zostało potwierdzone.
Następne zajęcia
Na następnych zajęciach omówimy instrukcje warunkowe if oraz switch w Go. Jeśli znajdziemy czas, przeanalizujemy również sekcje środowiska wykonawczego Go.