В статье речь идёт о Go 1.13
Компилятор Go занимает важное место в экосистеме Go. Компиляция — необходимый этап создания исполняемого двоичного кода. Компилятор проходит долгий путь: его пишут на C, переносят на Go и постоянно оптимизируют.
Фазы компиляции
Компилирование Go состоит из четырёх фаз, которые можно объединить в два этапа:
- На первом выполняется анализ исходного кода и по мере синтаксического разбора создаётся абстрактная синтаксическая структура исходного кода, которая называется АСД (абстрактное синтаксическое дерево).
- На втором этапе вместе с многочисленными оптимизациями происходит трансформация представления исходного кода в машинный код.
Для лучшего понимания используем простую программу:
package main
func main() {
a := 1
b := 2
if true {
add(a, b)
}
}
func add(a, b int) {
println(a + b)
}
Синтаксический разбор
Первая фаза предельно проста. Её описание можно найти в README:
Лексический анализатор будет первым пакетом, запущенным для маркирования. Исходный код здесь. Ниже приведён результат:
После маркирования проводится синтаксический анализ и строится синтаксическое дерево.
Преобразование в АСД
Преобразование в абстрактное синтаксическое дерево можно вывести на экран командой go tool compile -W
:
На первой фазе также проводятся оптимизации. Например, встраивание. В нашем примере метод add
уже может быть встроенным, так как никаких инструкций CALLFUNC
с методом add
мы здесь не видим. Теперь снова выполним команду с флагом -l
для отключения встраивания:
АСД позволяет компилятору перейти к низкоуровневому промежуточному представлению SSA (Static Single Assignment — статическое одиночное присваивание).
Генерация SSA
Создание формы статистического одиночного присваивания — это фаза оптимизации: устранение мёртвого кода, замена выражений на константы и т.д. Код SSA можно вывести командой GOSSAFUNC=main go tool compile main.go && open ssa.html
, с помощью которой создаётся HTML-документ со всеми проходами, сделанными в пакете SSA:
SSA находится во вкладке start:
Переменные a
и b
здесь выделены, как и условие if
: позже можно отследить, как меняются эти строки. Код также показывает, каким образом компилятор управляет функцией println
, которая разбивается на четыре: printlock
, printint
, printnl
, printunlock
. Компилятор автоматически добавляет блокировку и, в зависимости от типа аргумента, вызывает соответствующий метод для корректного вывода.
В нашем примере a
и b
известны на стадии компиляции, поэтому компилятор может посчитать конечный результат и отметить переменные как ненужные. Проход opt
оптимизирует эту часть:
v11
здесь заменён результатом добавления v4
и v5
, обозначенных как мёртвый код. Проход opt deadcode
удалит его:
Что касается условия if
, проход opt
отмечает константу true
как мёртвый код, он будет удалён:
Затем другой проход упростит поток управления, отметив ненужный блок и условие как бесполезные. Эти блоки будут после удалены другим проходом, работающим с мёртвым кодом:
После всех проходов компилятор Go создаёт промежуточный код на ассемблере Go:
На следующей фазе создаётся машинный код для бинарного файла.
Создание машинного кода
Заключительный этап компиляции — это создание объектного файла (main.o
в нашем случае). Его можно дизассемблировать с помощью команды go tool objdump
. Ниже схема работы компилятора:
После создания объектного файла можно перейти непосредственно к компоновщику. Используйте команду go tool link
— и ваш двоичный код готов!
Читайте также:
- Как я встраивал ресурсы в Go
- Почему Go прекрасно подходит для DevOps
- Топ-10 самых распространенных ошибок в проектах Go. Часть 1
Перевод статьи Vincent Blanchon: Go: Overview of the Compiler