Анализ структуры времени выполнения управляемых языков и ассемблерного кода AMD64 в бинарных файлах Go

Что такое управляемый язык?
Управляемый язык (managed language) — это язык, который, в отличие от неуправляемого языка, то есть языка, выполняющего логику, написанную программистом, практически без отклонений, берёт на себя выполнение сборки мусора (GC), runtime-оптимизаций, зелёных потоков, обработку конкурентности и других задач на уровне среды выполнения, благодаря чему пользователю не требуется выполнять опасное низкоуровневое управление ресурсами. Для таких языков характерно преимущество, заключающееся в том, что разработчик может сосредоточиться исключительно на бизнес-логике и полностью погрузиться в разработку; вместе с тем интуиция программиста может расходиться с фактическим поведением программы, поэтому иногда требуется тонкая настройка runtime. Прежде всего рассмотрим Go, наиболее близкий к минималистской философии среди управляемых языков и язык с прозрачным ассемблерным кодом.
Структура бинарного файла Go
| .text | .data | .gopclntab, .typelink и др. |
|---|---|---|
| Машинный код для исполнения | Данные для хранения | Секции языкового runtime |
Поскольку Go не выполняет прямую поэлементную трансляцию в машинный код в том виде, в котором пользователь его написал, логика секции .text тесно связана с секцией языкового runtime. Кроме того, в ассемблерный код секции .text добавляются функции, которые пользователь не писал, например runtime.printnl(). Благодаря такому автоматическому внедрению кода Go помогает освободить разработчика от ручного управления памятью.
Рассмотрение только функции main в Go
Сначала создадим простой пример исходного файла main.go и посмотрим на функцию main на машине AMD64.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Затем выполним сборку:
1go build main.go
2
Для удобной низкоуровневой отладки Go предоставляет go tool. Чтобы вывести только ассемблерный код функции main пакета main, введём следующую команду:
1go tool objdump -s "main\.main" ./main
2
Ассемблер
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
- Сравнивается значение защиты стека во внутреннем управляющем блоке горутины (регистр R14) с текущим указателем стека (SP) с помощью CMPQ; если места для кадра стека недостаточно, выполняется переход (JBE) на точку входа 0x468f95 для расширения стека.
- Для сохранения предыдущего указателя базы выполняется PUSHQ BP, помещающий его в стек.
- Текущий указатель стека (SP) копируется в регистр базы (BP), фиксируя базовую точку стека на момент начала функции.
- Затем выделяется 16 байт локального пространства стека (SUBQ $0x10, SP), а с помощью NOPL заполняются пустые инструкции для выравнивания по кэш-линии процессора.
- Для синхронизации стандартного вывода строк во внутреннем runtime Go вызывается runtime.printlock(SB), который устанавливает блокировку.
- Инструкцией LEAQ адрес начала строковой константы ("Hello World") помещается в регистр AX, который согласно соглашению Go ABI используется как первый параметр.
- Затем длина строки помещается в регистр BX, являющийся вторым параметром (MOVL $0xb, BX, то есть 11 в десятичной системе).
- Вызывается runtime.printstring(SB), который на основе переданных AX (адрес данных) и BX (длина) выводит строку в консоль.
- Для перевода строки вызывается runtime.printnl(SB).
- Поскольку вывод завершён, блокировка снимается вызовом runtime.printunlock(SB).
- Инструкцией ADDQ $0x10, SP восстанавливается 16-байтная область стека, выделенная ранее.
- Инструкцией POPQ BP восстанавливается прежний указатель базы.
- Инструкция RET возвращает управление в точку вызова функции.
- Если при первоначальной проверке стека места оказалось недостаточно, вызывается runtime.morestack_noctxt.abi0(SB) по адресу 0x468f95, который динамически расширяет стек, как и подобает управляемому языку.
- После расширения стека выполняется возврат (JMP) к точке входа main.main(SB). Как видно, ассемблерный код бизнес-логики достаточно понятен и дополнен лишь тонким слоем управления runtime.
Случай без оптимизаций
Приведённая выше картина является результатом того, что компилятор Go автоматически встроил (инлайнинг) две отдельные функции и оптимизировал их. Однако для учебных целей мы запретим инлайнинг функции sayHello. Для этого скомпилируем исходный код со следующим флагом:
1go build -gcflags="-l" main.go
2
Если вывести результат в терминале, будет видно повторяющийся ассемблерный код.
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
При отключении инлайнинга в соответствии с соглашением о вызовах в стек добавляются инструкции MOVQ, которые заново сохраняют параметры (AX, BX) по смещениям указателя стека, например 0x20(SP). Таким образом, установлено, что объектом оптимизации компилятора являются именно такие лишние операции перемещения данных в памяти и накладные расходы на вызов функций.
Следующая часть
В следующей части мы рассмотрим операторы if и switch в Go. Если появится время, планируем также разобрать секции runtime Go.