GoSuda

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

By Lee Yunjin views ...

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 wykonaniaDane do zapisaniaSekcje ś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.