Прежде чем заняться реализацией runtime и изучением стандартной библиотеки, необходимо освоить абстрактный ассемблер Go. Надеюсь, это руководство поможет вам быстро овладеть нужными знаниями.
Содержание
- «Псевдоассемблер»
- Декомпозиция простой программы
- Пара слов о горутинах, стеках и разделениях
- Заключение
- Ссылки
Эта статья подразумевает, что у читателей есть базовые знания об ассемблере любого вида.
Когда речь будет идти об относящихся к архитектуре вопросах, всегда подразумевается использование linux/amd64.
Мы всегда будем работать с включёнными оптимизациями компилятора.
Все цитаты взяты из официальной документации и/или кодовой базы, если не указано иное.
«Псевдоассемблер»
Компилятор Go генерирует абстрактный, портируемый ассемблер, который не привязан к какому-либо оборудованию. Затем сборщик (assembler) Go использует этот псевдоассемблер для генерирования зависящих от конкретной машины инструкций для целевого оборудования.
Этот дополнительный «уровень» даёт немало преимуществ. Главное из них — лёгкое портирование Go под новую архитектуру. За подробностями отправляю вас к выступлению Роба Пайка “The Design of the Go Assembler”.
Самое важное, что нужно знать об ассемблере Go: это не прямое представление машины, лежащей в основе языка. Что-то сопоставляется напрямую с машиной, а что-то нет. Дело в том, что компилятору не нужно передавать ассемблер в обычный конвейер. Вместо этого компилятор оперирует полуабстрактным набором инструкций, которые частично выбираются после генерирования кода. Ассемблер работает в полуабстрактном виде, так что если вы видите инструкцию MOV, это ещё не означает, что инструментарий сгенерирует для этой операции инструкцию перемещения. Возможно, это будет инструкция очистки или загрузки. А может быть, сгенерированная инструкция будет в точности соответствовать машинной инструкции с таким же именем. В целом, машинно-специфические операции выглядят как есть, а более общие концепции, вроде перемещения памяти или подпрограммы вызова и возвращения, получаются более абстрактными. Подробности зависят от архитектуры, и мы приносим извинения за неточности, ситуация неопределённая.
Программа на ассемблере — это способ парсить описание этого набора полуабстрактных инструкций и превращать их в инструкции для передачи в линкер.
Декомпозиция простой программы
Рассмотрим этот код на Go (direct_topfunc_call.go):
//go:noinline
func add(a, b int32) (int32, bool) { return a + b, true }
func main() { add(10, 32) }
(Обратите внимание на директиву компилятора
//go:noinline
… Будьте аккуратны.)Давайте скомпилируем код в ассемблер:
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
0x0013 RET
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
0x002b PCDATA $0, $0
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
Мы построчно разложили две функции, чтобы понять, как работает компилятор.
Анализируем add
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000
: Смещение (offset) текущей инструкции относительно начала функции.TEXT "".add
: ДирективаTEXT
объявляет символ"".add
частью секции.text
(то есть исполняемого кода) и означает, что следующие за директивой инструкции являются телом функции.
Пустая строка""
в ходе компоновки будет заменена именем текущего пакета: например,"".add
после компоновки в финальный бинарник станетmain.add
.(SB)
:SB
— виртуальный регистр, содержащий «static-base» указатель, то есть адрес начала адресного пространства программы.
"".add(SB)
объявляет, что наш символ расположен по адресу с постоянным смещением от начала адресного пространства. Иными словами, это абсолютный прямой адрес, где записан символ глобальной функции. Это подтверждаетobjdump
:
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
Все пользовательские символы записаны в качестве смещений для псевдорегистров FP (аргументы и локальные переменные) и SB (глобальные переменные). Псевдорегистр SB можно рассматривать как источник памяти, так что символ
foo(SB)
— это имя foo в качестве адреса в памяти.NOSPLIT
говорит компилятору, что он НЕ должен вставлять преамбулу разделения стека (stack-split), проверяющую, нужно ли увеличивать текущий стек.
В случае с нашей функциейadd
компилятор сам установил этот флаг: он достаточно умён и сообразил, что разadd
не имеет локальных переменных и собственного фрейма стека, то она просто не может перерасти текущий стек. А значит выполнение проверок при каждом вызове — выброшенные на ветер циклы процессора.
"NOSPLIT"
: не вставляйте начальную проверку, если стек должен быть разделён. Фрейм для подпрограммы (routine), как и то, что она вызывает, должны помещаться в запасное пространство в начале сегмента стека. Используется для защиты подпрограмм, таких как сам код разбиения стека. В конце статьи мы немного поговорим о горутинах и разбиениях стека.$0-16: $0
— размер (в байтах) выделяемого в памяти фрейма стека.$16
— размер аргументов, передаваемых вызывающим.
В общем случае после размера фрейма идёт размер аргумента, отделяемый знаком минуса (это не вычитание, а дурацкий синтаксис). Размер фрейма
$24-8
означает, что у функции фрейм размером 24 байта, и она вызывается с 8-байтным аргументом, который находится во фрейме вызывающего. Если дляTEXT
не заданNOSPLIT
, то должен быть предоставлен размер аргумента. Для ассемблерных функций с Go-прототипамиgo vet
проверит правильность размера аргумента.
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
Директивы
FUNCDATA
и PCDATA
предоставляются компилятором и содержат информацию для сборщика мусора.Пока не углубляйтесь, мы вернёмся к этому в статье, где будет разбираться сборка мусора.
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Соглашение о вызове в Go предписывает передавать все аргументы в стек с использованием заранее зарезервированного места во фрейме стека вызывающего. Обязанность вызывающего — уменьшать и увеличивать стек таким образом, чтобы вызываемому можно было передавать аргументы, а вызывающему можно было возвращать значения.
Компилятор Go никогда не генерирует инструкции семейства PUSH/POP: размер стека меняется посредством декрементерирования или инкрементирования виртуального указателя стека оборудования SP (см. обсуждение issue #21: about SP register).
Псевдорегистр SP — виртуальный указатель стека, используемый для ссылок на локальные фреймовые переменные и аргументы, подготовленные для вызовов функций. Он указывает на начало локального фрейма стека, так что ссылки должны использовать отрицательное смещение в диапазоне [?framesize, 0]:x-8(SP)
,y-4(SP)
, и так далее.
Хотя в официальной документации сказано, что «Все пользовательские символы записываются в качестве смещений относительно псевдорегистра FP (аргументы и локальные переменные)», это верно лишь для кода, который вы пишете сами.
Как и большинство свежих компиляторов, инструментарий Go прямо в генерируемом коде всегда ссылается на аргументы и локальные переменные с помощью смещений от указателя стека. Это позволяет использовать стековый фрейм в роли регистра общего назначения на платформах с меньшим количеством регистров (например, х86).
Взгляните на «Схему фрейма стека на x86-64», если вам нравятся такие скучные подробности (также см. обсуждение issue #2: Frame pointer).
"".b+12(SP)
и "".a+8(SP)
ссылаются на адреса, расположенные на расстоянии 12 и 8 байтов от вершины стека (помните: стек растёт вниз!)..a
и .b
— произвольные алиасы для мест, на которые мы ссылаемся. Хотя у них нет абсолютно никакого семантического значения, их предписывается применять, когда используется относительная адресация на виртуальные регистры. Вот что сказано в документации про виртуальный указатель фрейма:Псевдорегистр FP — виртуальный указатель фрейма, используемый для ссылок на аргументы функций. Компиляторы поддерживают виртуальный указатель фрейма и ссылаются на аргументы в стеке как на смещения от псевдорегистра. Таким образом, 0(FP) — первый аргумент функции, 8(FP) — второй (на 64-битной машине), и так далее. Однако если вы будете таким образом ссылаться на аргументы функций, то необходимо в начале ставить имя, например: first_arg+0(FP) и second_arg+8(FP) (здесь смещение — от указателя фрейма — отличается от SB, у которого подразумевается смещение от символа). Ассемблер использует это соглашение принудительно, отклоняя простые 0(FP) и 8(FP). Реальное имя не соответствует семантически, но должно использоваться для документирования имени аргумента.
Наконец, нужно отметить ещё два важных момента:
- Первый аргумент
a
находится не в0(SP)
, а в8(SP)
, потому что вызывающий посредством псевдофункцииCALL
сохраняет свой адрес возврата в0(SP)
. - Аргументы передаются в обратном порядке. То есть первый аргумент будет ближе всего к вершине стека.
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL
складывает два Long-word (например, 4-байтные значения), лежащие в AX
и CX
, а результат записывает в AX
. Затем этот результат перемещается в "".~r2+16(SP)
, в стеке которого вызывающий предварительно зарезервировал место и будет там искать возвращаемые значения. Повторюсь: в данном случае "".~r2
не имеет семантического значения.Чтобы продемонстрировать, как Go обрабатывает несколько возвращаемых значений, мы вернём постоянное булево значение
true
. Механика точно такая же, как и в случае с первым возвращаемым значением, только смещение будет соответствовать изменениям SP
.0x0013 RET
Псевдоинструкция
RET
говорит ассемблеру Go вставить любую инструкцию, необходимую по соглашению о вызовах, используемому на целевой платформе, чтобы правильно вернуть результат из подпрограммы вызова. Это наверняка заставит код извлечь (pop off) адрес возврата, расположенный в 0(SP)
, а затем вернуться к нему.Последней инструкцией в блоке TEXT должен быть какой-нибудь переход, это обычно (псевдо)инструкция RET. Если это не так, линкер добавит инструкцию с переходом на саму себя (jump-to-itself). В блоках TEXT нет «проваливаний» (fall through).
Придётся усвоить сразу большое количество синтаксиса и семантики. Вот инлайненное краткое изложение вышеописанного:
;; Declare global function symbol "".add (actually main.add once linked)
;; Do not insert stack-split preamble
;; 0 bytes of stack-frame, 16 bytes of arguments passed in
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...omitted FUNCDATA stuff...
0x0000 MOVL "".b+12(SP), AX ;; move second Long-word (4B) argument from caller's stack-frame into AX
0x0004 MOVL "".a+8(SP), CX ;; move first Long-word (4B) argument from caller's stack-frame into CX
0x0008 ADDL CX, AX ;; compute AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;; move addition result (AX) into caller's stack-frame
0x000e MOVB $1, "".~r3+20(SP) ;; move `true` boolean (constant) into caller's stack-frame
0x0013 RET ;; jump to return address stored at 0(SP)
А вот визуальное представление содержимого стека после завершения исполнения
main.add
: | +-------------------------+ <-- 32(SP)
| | |
G | | |
R | | |
O | | main.main's saved |
W | | frame-pointer (BP) |
S | |-------------------------| <-- 24(SP)
| | [alignment] |
D | | "".~r3 (bool) = 1/true | <-- 21(SP)
O | |-------------------------| <-- 20(SP)
W | | |
N | | "".~r2 (int32) = 42 |
W | |-------------------------| <-- 16(SP)
A | | |
R | | "".b (int32) = 32 |
D | |-------------------------| <-- 12(SP)
S | | |
| | "".a (int32) = 10 |
| |-------------------------| <-- 8(SP)
| | |
| | |
| | |
\ | / | return address to |
\|/ | main.main + 0x30 |
- +-------------------------+ <-- 0(SP) (TOP OF STACK)
(diagram made with https://textik.com)
Анализируем main
Чтобы не пришлось листать статью, напомню, как выглядит наша функция
main
:0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0
Ничего нового:
"".main
(однажды залинкованныйmain.main
) — это символ глобальной функции в секции.text
, адрес которого является постоянным смещением от начала нашего адресного пространства.- Этот код размещает в памяти 24-байтный фрейм стека, не получает аргументы и не возвращает значения.
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
Как говорилось выше, соглашение о вызовах в Go предписывает, чтобы все аргументы передавались в стек.
Вызывающий —
main
— увеличивает свой фрейм стека на 24 байта (не забывайте, что стек увеличивается вниз, поэтому в данном случае SUBQ
увеличивает фрейм стека), декрементируя виртуальный указатель стека. Из чего состоят эти 24 байта:- 8 байтов (
16(SP)-24(SP)
) используются для хранения текущего значения указателя фрейма BP (настоящего!) для раскрутки стека (stack-unwinding) и упрощения отладки. - 1+3 байта (
12(SP)-16(SP)
) зарезервировано для второго возвращаемого значения (bool
) плюс 3 байта необходимого выравнивания на amd64. - 4 байта (
8(SP)-12(SP)
) зарезервированы для первого возвращаемого значения (int32
). - 4 байта (
4(SP)-8(SP)
) зарезервированы для значения аргумента b (int32
). - 4 байта (
0(SP)-4(SP)
) зарезервированы для значения аргумента a (int32
).
Наконец, после увеличения стека
LEAQ
вычисляет новый адрес указателя фрейма и сохраняет его в BP
.0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
Вызывающий берёт аргумент для вызываемого в виде Quad word (8-байтное значение) и помещает сверху стека, который только что увеличился.
Хотя на первый взгляд это может показаться случайным мусором, но на самом деле
137438953482
соответствует 4-байтным значениям 10
и 32
, которые соединены в одно 8-байтное значение:$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
32 10
0x002b CALL "".add(SB)
Мы применяем
CALL
к функции add
в виде смещения относительно static-base указателя. То есть это прямой переход по прямому адресу.Обратите внимание, что
CALL
также помещает адрес возврата (8-байтное значение) сверху стека. Поэтому каждая ссылка на SP
изнутри функции add
будет смещена на 8 байтов! Например, "".a
находится теперь не в 0(SP)
, а в 8(SP)
.0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
Наконец мы:
- Раскручиваем (unwind) указатель фрейма на один указатель стека (то есть «спускаемся» на один уровень).
- Уменьшаем стек на 24 байта, чтобы вернуть ранее занятое нами пространство.
- Просим ассемблер Go вставить подпрограмму возврата.
Пара слов о горутинах, стеках и разделениях
Сейчас не время и не место разбираться с потрохами горутин, но если начали погружаться в ассемблер, то очень быстро придётся освоиться с инструкциями, относящимися к управлению стеком.
Нужно уметь быстро распознавать эти паттерны и вообще понимать, что и как они делают.
Стеки
Поскольку количество горутин в Go-программе не определено и на практике может достигать нескольких миллионов, то чтобы избежать пожирания всей доступной памяти нужно в ходе runtime придерживаться консервативного способа выделения стека для горутин.
Таким образом, каждая новая горутина изначально получает в ходе runtime маленький стек на 2 КБ (на самом деле он находится в куче).
Во время своего исполнения горутина может перерасти начальное пространство стека (то есть будет переполнение стека). Чтобы этого не случилось, среда runtime при заполнении стека выделяет новый стек, вдвое больше старого, чьё содержимое копируется в новый стек.
Этот процесс известен как разделение стека (stack-split) и обеспечивает механизм динамического стека для горутин.
Разделения
Чтобы работал механизм разделения стека, компилятор вставляет новые инструкции в начало и конец каждой функции, которая может переполнить свой стек.
Чтобы избежать ненужных расходов, функции, которые вряд ли перерастут стек, помечаются
NOSPLIT
, что подсказывает компилятору не вставлять проверки.Давайте посмотрим нашу функцию main, но в этот раз не опуская преамбулу с разбиением стека:
0x0000 TEXT "".main(SB), $24-0
;; stack-split prologue
0x0000 MOVQ (TLS), CX
0x0009 CMPQ SP, 16(CX)
0x000d JLS 58
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; stack-split epilogue
0x003a NOP
;; ...omitted PCDATA stuff...
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
Как видите, преамбула разделена на пролог и эпилог:
- В прологе проверяется, переполнилось ли выделенное для горутины пространство, и если да, то выполнение переходит к эпилогу.
- Эпилог запускает механизм увеличения стека, а затем возвращается к прологу.
Возникает цикл обратной связи, который работает до тех пор, пока для «голодающей» горутины не будет выделен достаточно большой стек.
Пролог
0x0000 MOVQ (TLS), CX ;; store current *g in CX
0x0009 CMPQ SP, 16(CX) ;; compare SP and g.stackguard0
0x000d JLS 58 ;; jumps to 0x3a if SP <= g.stackguard0
TLS
— виртуальный регистр, поддерживаемый runtime-средой, содержащий указатель на текущий g
, то есть на структуру данных, отслеживающую всё состояние горутины.Давайте посмотрим на определение
g
в исходном коде runtime:type g struct {
stack stack // 16 bytes
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
stackguard0 uintptr
stackguard1 uintptr
// ...omitted dozens of fields...
}
16(CX)
соответствует g.stackguard0
, пороговому значению, поддерживаемому runtime-средой. Она сравнивает это значение с указателем стека и выясняет, близка ли горутина к исчерпанию стека. То есть пролог проверяет, текущее значение SP
меньше или равно stackguard0
(правильно, оно больше), и если нужно, то переходит к эпилогу.Эпилог
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
Тело эпилога простое: он вызывается в ходе runtime, которая и делает всю работу по увеличению стека, а затем переходит обратно к первой инструкции функции (то есть к прологу).
Инструкция
NOP
стоит перед CALL
так, что пролог не переходит напрямую к CALL
. На некоторых платформах это может привести к нехорошим последствиям. Поэтому прямо перед самим вызовом обычно вставляют пустую инструкцию (noop instruction) и приземляют на NOP
(также см. обсуждение issue #4: Clarify «nop before call» paragraph).Минус некоторые тонкости
Мы рассмотрели лишь вершину айсберга. Внутренняя механика увеличения стека имеет гораздо больше нюансов: процесс довольно сложный и требует отдельной статьи для подробного рассмотрения.
Заключение
По мере погружения в устройство Go в следующих статьях, ассемблер Go будет одним из самых важных инструментов для понимания внутренней механики и связей с не столь очевидными на первый взгляд вещами.
Ссылки
- [Официальная документация] Краткое руководство по ассемблеру Go
- [Официальная документация] Директивы компилятора Go
- [Официальная документация] Архитектура ассемблера Go
- [Официальная документация] Архитектура непрерывных стеков
- [Официальная документация] Константа _StackMin
- [Дискуссия] Issue #2: Frame pointer
- [Дискуссия] Issue #4: Clarify «nop before call» paragraph
- Экскурс в программирование на ассемблере Go
- Преобразование функций Go в ассемблер
- Для чего нужен регистр указателя фрейма EBP?
- Схема фрейма стека в x86-64
- Как в Go работают со стеками
- Почему стек увеличивается вниз
QtRoS
В статье не хватает вариантов и способов применения знания этого ассемблера.
ainu
По одной из ссылок статья, как человек ускорил выполнение функции за счёт переписывания на ассемблере, и оно сейчас работает в netflix.
ainu
https://blog.sgmansfield.com/2017/04/a-foray-into-go-assembly-programming/ с 17 до 12 наносекунд ускорил, и эта разница у него даже окупилась, с учётом затраченного времени.
homm
Интересно, сколько бы он сэкономил, если бы просто заменил
на
Возможно в Go это записывается иначе, суть в том, чтобы просто обнулить последний бит.
creker
Нисколько. Обе версии выполняются за одинаковое время. По крайней мере на моем haswell