Главная » Хабрахабр » [Перевод] Руководство по ассемблеру Go

[Перевод] Руководство по ассемблеру Go

Надеюсь, это руководство поможет вам быстро овладеть нужными знаниями.
Содержание Прежде чем заняться реализацией 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) 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

    Псевдорегистр SB можно рассматривать как источник памяти, так что символ foo(SB) — это имя foo в качестве адреса в памяти. Все пользовательские символы записаны в качестве смещений для псевдорегистров FP (аргументы и локальные переменные) и SB (глобальные переменные).

  • NOSPLIT говорит компилятору, что он НЕ должен вставлять преамбулу разделения стека (stack-split), проверяющую, нужно ли увеличивать текущий стек.

    А значит выполнение проверок при каждом вызове — выброшенные на ветер циклы процессора. В случае с нашей функцией add компилятор сам установил этот флаг: он достаточно умён и сообразил, что раз add не имеет локальных переменных и собственного фрейма стека, то она просто не может перерасти текущий стек.

    Фрейм для подпрограммы (routine), как и то, что она вызывает, должны помещаться в запасное пространство в начале сегмента стека. "NOSPLIT": не вставляйте начальную проверку, если стек должен быть разделён. В конце статьи мы немного поговорим о горутинах и разбиениях стека. Используется для защиты подпрограмм, таких как сам код разбиения стека.

  • $0-16: $0 — размер (в байтах) выделяемого в памяти фрейма стека. $16 — размер аргументов, передаваемых вызывающим.

    Размер фрейма $24-8 означает, что у функции фрейм размером 24 байта, и она вызывается с 8-байтным аргументом, который находится во фрейме вызывающего. В общем случае после размера фрейма идёт размер аргумента, отделяемый знаком минуса (это не вычитание, а дурацкий синтаксис). Для ассемблерных функций с Go-прототипами go vet проверит правильность размера аргумента. Если для TEXT не задан NOSPLIT, то должен быть предоставлен размер аргумента.

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 предписывает передавать все аргументы в стек с использованием заранее зарезервированного места во фрейме стека вызывающего.

обсуждение issue #21: about SP register). Компилятор Go никогда не генерирует инструкции семейства PUSH/POP: размер стека меняется посредством декрементерирования или инкрементирования виртуального указателя стека оборудования SP (см.

Он указывает на начало локального фрейма стека, так что ссылки должны использовать отрицательное смещение в диапазоне [−framesize, 0]: x-8(SP), y-4(SP), и так далее. Псевдорегистр SP — виртуальный указатель стека, используемый для ссылок на локальные фреймовые переменные и аргументы, подготовленные для вызовов функций.

Хотя в официальной документации сказано, что «Все пользовательские символы записываются в качестве смещений относительно псевдорегистра FP (аргументы и локальные переменные)», это верно лишь для кода, который вы пишете сами.

Это позволяет использовать стековый фрейм в роли регистра общего назначения на платформах с меньшим количеством регистров (например, х86). Как и большинство свежих компиляторов, инструментарий Go прямо в генерируемом коде всегда ссылается на аргументы и локальные переменные с помощью смещений от указателя стека.

обсуждение issue #2: Frame pointer). Взгляните на «Схему фрейма стека на x86-64», если вам нравятся такие скучные подробности (также см.

"".b+12(SP) и "".a+8(SP) ссылаются на адреса, расположенные на расстоянии 12 и 8 байтов от вершины стека (помните: стек растёт вниз!).

Хотя у них нет абсолютно никакого семантического значения, их предписывается применять, когда используется относительная адресация на виртуальные регистры. .a и .b — произвольные алиасы для мест, на которые мы ссылаемся. Вот что сказано в документации про виртуальный указатель фрейма:

Компиляторы поддерживают виртуальный указатель фрейма и ссылаются на аргументы в стеке как на смещения от псевдорегистра. Псевдорегистр FP — виртуальный указатель фрейма, используемый для ссылок на аргументы функций. Однако если вы будете таким образом ссылаться на аргументы функций, то необходимо в начале ставить имя, например: first_arg+0(FP) и second_arg+8(FP) (здесь смещение — от указателя фрейма — отличается от SB, у которого подразумевается смещение от символа). Таким образом, 0(FP) — первый аргумент функции, 8(FP) — второй (на 64-битной машине), и так далее. Реальное имя не соответствует семантически, но должно использоваться для документирования имени аргумента. Ассемблер использует это соглашение принудительно, отклоняя простые 0(FP) и 8(FP).

Наконец, нужно отметить ещё два важных момента:

  1. Первый аргумент a находится не в 0(SP), а в 8(SP), потому что вызывающий посредством псевдофункции CALL сохраняет свой адрес возврата в 0(SP).
  2. Аргументы передаются в обратном порядке. То есть первый аргумент будет ближе всего к вершине стека.

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 не имеет семантического значения.

Механика точно такая же, как и в случае с первым возвращаемым значением, только смещение будет соответствовать изменениям SP. Чтобы продемонстрировать, как Go обрабатывает несколько возвращаемых значений, мы вернём постоянное булево значение true.

0x0013 RET

Псевдоинструкция RET говорит ассемблеру Go вставить любую инструкцию, необходимую по соглашению о вызовах, используемому на целевой платформе, чтобы правильно вернуть результат из подпрограммы вызова. Это наверняка заставит код извлечь (pop off) адрес возврата, расположенный в 0(SP), а затем вернуться к нему.

Если это не так, линкер добавит инструкцию с переходом на саму себя (jump-to-itself). Последней инструкцией в блоке TEXT должен быть какой-нибудь переход, это обычно (псевдо)инструкция RET. В блоках 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 предписывает, чтобы все аргументы передавались в стек.

Из чего состоят эти 24 байта: Вызывающий — main — увеличивает свой фрейм стека на 24 байта (не забывайте, что стек увеличивается вниз, поэтому в данном случае SUBQ увеличивает фрейм стека), декрементируя виртуальный указатель стека.

  • 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 указателя. То есть это прямой переход по прямому адресу.

Поэтому каждая ссылка на SP изнутри функции add будет смещена на 8 байтов! Обратите внимание, что CALL также помещает адрес возврата (8-байтное значение) сверху стека. Например, "".a находится теперь не в 0(SP), а в 8(SP).

0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET

Наконец мы:

  1. Раскручиваем (unwind) указатель фрейма на один указатель стека (то есть «спускаемся» на один уровень).
  2. Уменьшаем стек на 24 байта, чтобы вернуть ранее занятое нами пространство.
  3. Просим ассемблер 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. обсуждение issue #4: Clarify «nop before call» paragraph). Поэтому прямо перед самим вызовом обычно вставляют пустую инструкцию (noop instruction) и приземляют на NOP (также см.

Минус некоторые тонкости

Мы рассмотрели лишь вершину айсберга. Внутренняя механика увеличения стека имеет гораздо больше нюансов: процесс довольно сложный и требует отдельной статьи для подробного рассмотрения.

Заключение

По мере погружения в устройство Go в следующих статьях, ассемблер Go будет одним из самых важных инструментов для понимания внутренней механики и связей с не столь очевидными на первый взгляд вещами.

Ссылки


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

IT Релокация. Обзор плюсов и минусов жизни в Бангкоке год спустя

Сначала были простые интервью с аутсорсинг компаниями из Англии. Моя история началась где-то в октябре 2016 года когда в голове поселилась мысль «А почему бы не попробовать работать за рубежом?». Да, предлагали хорошие деньги, но душа просила переезда. Было очень ...

[Перевод] Ричард Хэмминг: Глава 12. Коды с коррекцией ошибок

«Цель этого курса — подготовить вас к вашему техническому будущему.» Привет, Хабр. Помните офигенную статью «Вы и ваша работа» (+219, 2442 в закладки, 394k прочтений)? Мы ее переводим, ведь мужик дело говорит. Так вот у Хэмминга (да, да, самоконтролирующиеся и ...