Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1
- пятница, 17 апреля 2026 г. в 00:00:11
Go-код никогда не исполняется напрямую.
Любая строка Go превращается в машинные инструкции, которые выполняет CPU.
В этой статье мы разберём:
1) Какие инструкции генерирует компилятор Go
2) Как выглядит Go assembler
3) И что на самом деле выполняет процессор
Думаю с обзором и общими положениями мы закончили, поэтому начнем с фундамента – что происходит в CPU когда мы запускаем наше GO-приложение. Конкретно в этой статье разберем основные инструкции, которые может выдать для нашего CPU компилятор Go, что они из себя представляют и как соотносятся с кодом Go. Сразу оговорюсь, что это НЕ гайд по Go Assembler, а разбор того, что из себя представляет Go End 2 End. Попытка докопаться до истины
Напомню, что сам CPU определяет:
Порядок выполнения инструкций
Допустимые переупорядочивания(reorder) операций чтения/записи
Работу процессорных кэшей
Атомарность инструкций
Барьеры памяти
Так что CPU – это конечная инстанция исполнения Go-кода
Reminder
Что вообще понимается под “конечной инстанцией”?
Как известно, процессор не умеет исполнять инструкции различных языков программирования будь то Go, JS, Java… Ему необходим машинный код, спецификация которого завязана на его архитектуру. Сейчас это мы рассматривать не будем, а лишь зафиксируем, что CPU умеет лишь исполнять
load/store
Арифметические/логические инструкции
branch/jump/call/return
атомарные инструкции
fence/barrier-подобные эффекты
системные переходы, если дошли до syscall/runtime
Будем разбирать это по порядку
Вводная
Пока нам нужно определение регистра
Регистр процессора — это небольшой набор ячеек хранения данных, находящийся непосредственно внутри процессорного ядра
Доступ к регистрам значительно быстрее доступа к памяти. Современные CPU выполняют операции с регистрами практически без задержек по сравнению с обращением к RAM.
Глобально, всё чем мы действительно можем управлять в рамках ASM – это регистры и их байты, а также поток выполнения инструкций. Никаких структур, типов данных и прочего
Поэтому имеем
Go
x := 1 x += 1 y = x
ASM
mov rax, 1 ; положить константу 1 в регистр AX add rax, 1 ; добавить константу 1 к значению в регистре AX mov [rip + y], rax ; записать содержимое регистра AX в переменную y
Чтобы посмотреть полный вывод можете написать что-нибудь в своем main.go например и использовать команду:
go tool compile -S main.go
Либо что-то в духе
go build -gcflags="-N -l" -o app main.go go tool objdump -s "main.main" ./app
Учтите, вы получите команды на Go Assembly
Если интересно разобраться, то вот ссылка(Не пугайтесь, что там http) на полный перечень команд для x86. Спасибо НГУ. А также серия гайдов по Go Assembler
Далее последуют примеры для Go Assembler! Это упрощенный код, а не гарантированный вывод компилятора
Go Assembler
Вообще, интересный факт про Go – он использует свой “сленг” Assembly, который называется Go Assembler
В чем это вообще выражается?
Это выражается в том, что Go не использует стандартный синтаксис ассемблера архитектуры (GAS/Intel), а вводит собственный слой абстракции над машинными инструкциями. Вот эти абстракции сверху вниз:
1) Псевдорегистры
В Go ASM используются специальные символы, которых нет в реальной ISA процессора
Go assembler скрывает реальные регистры для:
переносимости
стабильности ABI(Application Binary Interface)
работы линкера
генерации stack maps для GC
Конкретнее – вот они
Псевдорегистр | Назначение |
SB | symbol base. global/static symbols, туда идут глобальные переменные |
SP | stack pointer. Псевдорегистр, обозначающий вершину текущего stack frame |
FP | frame pointer. Псевдоуказатель на область аргументов функции) |
PC | program counter. Адрес следующей инструкции для выполнения |
А настоящие CPU-регистры:
AX BX CX DX SI DI BP R8–R15
То есть
MOVQ AX, main.y(SB) ; GoASM mov [rip+offset], rax ; x86 ASM
2) Модифицированная система адресации
В Go assembler используются формы:
name(SB)
x-8(SP)
arg+0(FP)
MOVQ arg+0(FP), AX ; Прочитать аргумент функции
3) Декларация функций
TEXT main.main(SB), ABIInternal, $0-8
То есть
функция main.main
ABIInternal – внутренний ABI компилятора Go. Он используется самим компилятором и runtime и может меняться между версиями языка.
stack frame size
4) Метаданные для runtime
Go assembler содержит директивы, которые вообще не имеют отношения к CPU, но нужны runtime.
Например:
FUNCDATA
PCDATA
5) Встроенный в синтаксис ABI
Например ABIInternal, то есть используется внутренний ABI Go
6) Архитектурная независимость и символы(например SB) вместо конкретных адресов
А зачем нам вообще Go Assembler?
Можно ограничиться общими словами про архитектурную независимость и интеграцию с GC, но посмотрим на это с практической стороны.
У Go есть runtime — большой системный слой, который управляет goroutine, стеком, GC и переходами в системные вызовы. Часть этого кода неизбежно пишется на ассемблере.
Если бы Go Assembler не существовал, то runtime пришлось бы писать отдельно под каждую архитектуру, используя её собственный синтаксис ассемблера (x86, ARM, RISC-V и т.д.). Это сильно усложнило бы сопровождение и поддержку новых платформ.
Go Assembler решает эту проблему, вводя единый синтаксический слой, который понимает компилятор и линкер. В нём используются абстракции вроде SB, SP, FP, а также директивы runtime (TEXT, FUNCDATA, PCDATA).
В итоге runtime может работать с логической моделью Go (stack frame, символы, метаданные GC), а архитектурно-зависимые детали изолируются в небольших asm-файлах для конкретных платформ.
Go Load-n-Store
Есть смысл начать с самого простого – чтения и записи переменных.
На самом деле, в ASM это происходит по очень незамысловатой схеме:
1) Использование регистров. О них мы знаем из спецификации конкретной архитектуры процессора, то есть "предустановлены"
2) Перемещение данных между регистрами и памятью
Соответственно, рассмотрим код
x := 1 y = x
После оптимизаций компилятор может упростить его до:
MOVQ $1, AX ; загрузить константу 1 в регистр AX MOVQ AX, main.y(SB); записать значение из AX в глобальную переменную y
Однако если отключить оптимизации (-N -l), код будет ближе к исходной логике программы:
MOVQ $1, main.x-8(SP) ; записать константу 1 в локальную переменную x MOVQ main.x-8(SP), AX ; load: прочитать значение x из памяти в регистр AX MOVQ AX, main.y(SB) ; store: записать значение регистра AX в переменную
Я думаю, что у человека это читающего возникнет вопрос:
MOVQ – это и есть load/store?
MOVQ — это инструкция перемещения данных.
Буква Q означает quadword (8 байт) — размер операнда.
Есть также B(1), W(2), L(4)
Ремарка
На 64-битных архитектурах (amd64) инструкция MOVQ копирует 8-байтовое значение за одну операцию.
На 32-битных архитектурах (386) такой инструкции нет, поэтому компилятор разбивает копирование 64-битного значения на две операции MOVL по 4 байта.
Размер инструкции соответствует размеру данных, с которыми работает процессор. Кстати, в перспективе мы сможем понять, откуда пошло выравнивание структур в том числе и с помощью этого
Сама инструкция MOV может выполнять разные виды копирования:
immediate -> register
register -> register
register -> memory
memory -> register
Load и Store – это более верхнеуровневые концепции, которые означают:
load = чтение из памяти в регистр
store = запись из регистра в память
Замечу, что с самом Go Load и Store трактуются наоборот, потому что мы рассматриваем эти операции не относительно CPU, а относительно самой программы
Но возникает закономерный вопрос: а что будет, если мы запишем, скажем, структуру на 16 байт, если у нас максимум только 8(MOVQ)?
type Point struct { x int y int } func main() { p1 := Point{1, 2} p2 := p1 } // int = 8 bytes // Point = 16 bytes
p1 := p2
Это копирование структуры!
p2.x = p1.x
p2.y = p1.y
То есть копируются все поля. И одной операцией MOVQ мы не обойдемся
Поэтому компилятор выдаст нам что-то в духе
MOVQ main.p1(SP), AX ; load в регистр AX p1.x MOVQ AX, main.p2(SP) ; store в память из AX p1.x(запись в p2.x) MOVQ main.p1+8(SP), AX ; load в регистр AX p1.y(отступ как раз 8 байт, то есть следующей записи в регистр) MOVQ AX, main.p2+8(SP) ; store в память в p2.y из AX(также с учетом отступа)
То есть 4 операции
И напоследок в этом разделе – как себя ведет запись/чтение указателей
Зафиксируем: указатель в Go — это просто адрес в памяти.
Поэтому операции с указателями на уровне ASM обычно сводятся к копированию адресов и разыменованию
Go:
x := 10 p := &x p2 := p *p = 20
ASM:
LEAQ main.x(SP), AX ; Load Effective Address x в регистр AX MOVQ AX, main.p(SP) ; store из AX в переменную p в памяти MOVQ main.p(SP), AX ; load p в регистр AX MOVQ AX, main.p2(SP) ; store из AX в p2 MOVQ main.p(SP), AX ; load указателя p в AX MOVQ $20, (AX) ; записать значение 20 по адресу, на который указывает p
Я думаю, с самым простым мы закончили
Go в арифметику(и логику)
Следующая великая миссия нашего железа – обработка данных, собственно не зря компьютер – это компьютер. В общем случае, набор арифметических и логических команд определяется ISA процессора. Но Go Assembler имеет те же арифметико-логические инструкции, что и x86
Давайте по порядку(примем допущения, что наши переменные x и y в регистрах для каждой отдельно операции)
Сложение и вычитание
Go:
z := x + y z1 := x - y
ASM:
ADDQ BX, AX ; AX = AX + BX (z) MOVQ AX, CX ; сохранить результат ; Представим что где вычитание не менялся AX SUBQ BX, AX ; AX = AX - BX (z1) MOVQ AX, DX ; сохраняем результат
Умножение и деление
Go:
z := x * y z1 := x / y
ASM:
IMULQ BX, AX ; AX = AX * BX MOVQ AX, CX ; store в z CQO ; расширить знак AX в DX, Convert Quadword to Octoword нужен для корректного деления знаковых чисел. ; В DX сохраняем -1, в AX наше число. Например-5 IDIVQ BX ; AX = (DX:AX) / BX, DX = остаток
В Go assembler знаковое деление на amd64 также опирается на правила x86-64. Деление в x86 отличается от других арифметических операций. Инструкция IDIV использует фиксированные регистры DX:AX как делимое. После выполнения деления частное записывается в AX, а остаток — в DX.
Да, и внимательный читатель мог заметить, что деление – дороговатая операция на самом деле, поэтому старайтесь избегать его по возможности, хотя в узких случая компилятор может его оптимизировать например до битового сдвига
AND, OR, XOR
Go:
z := x & y z1 := x | y z2 := x ^ y
ASM:
ANDQ BX, AX ; AX = AX & BX MOVQ AX, CX ; z ORQ BX, AX ; AX = AX | BX MOVQ AX, DX ; z1 XORQ BX, AX ; AX = AX ^ BX MOVQ AX, SI ; z28-
Сдвиги влево/вправо
Go:
z := y << x z1 := y >> x
ASM:
MOVQ AX, CL ; CL = количество сдвигов SHLQ CL, BX ; BX = BX << CL MOVQ BX, DX ; z SHRQ CL, BX ; BX = BX >> CL MOVQ BX, SI ; z1
Сравнение
Go:
x < y
ASM:
CMPQ BX, AX ; сравнить AX и BX JLT less ; перейти если AX < BX, JLT пока не трогаем
CMPQ не сохраняет результат сравнения в регистр, а только выставляет флаги процессора
Test
if x == 0
ASM:
TESTQ AX, AX ; проверить AX == 0 JEQ zero ; переход если ноль
Чтобы вас не путать объясню: TEST – это битовое AND значения с самим с собой, поэтому 0 тут может дать только… 0!
Помимо Q(8) все так же есть B(1), W(2), L(4)
Go управлять потоком
Итак, я в самом начале упоминал, что помимо байтов и регистров мы также можем управлять потоком выполнения. Вообще, что это значит?
Процессор выполняет какую-то последовательность инструкций, что, собственно и является потоком выполнения и задание этой самой последовательности и есть управление потоком выполнения.
В контексте управления потоком выполнения мы можем использовать следующие операции:
jump – безусловный переход(goto)
branch – условное изменение хода выполнения(аналог if)
call – вызов функции
return – возврат из функции
Всё это в конечном счёте сводится к изменению PC: процессор либо идёт к следующей инструкции, либо прыгает в другое место. В Go assembler это описывается теми же базовыми инструкциями перехода и вызова, что и в обычном asm для целевой архитектуры.
Jump
По факту это переход на определенную строчку кода. Я думаю, что уже вырисовывается картинка, как из этого может получиться цикл. JMP происходит к определенной метке в коде. Пример ниже
lbl: MOVQ $1, AX ; помещаем константу 1 в регистр AX ; условимся начать отсюда JMP lbl ; переход к lbl. ; PC = address(label), то есть ставим указатель следующей инструкции на метку lbl
По сути это основа для:
хвостов функций
циклов
пропуска кусков кода
переходов внутри runtime
Branch
branch — это условный переход.
Обычно он идёт после CMP или TEST, которые выставляют флаги CPU. Возьмем пример с CMPQ выше
CMPQ BX, AX ; сравнить AX и BX JLT less ; если AX < BX, перейти к метке less
И я уверен, что вы уже поняли, что с точки зрения Go Assembler представляют из себя ветвления!
Замечу, что J* — не одна конкретная инструкция, семейство инструкций. С ним вы подробнее можете ознакомиться в официальной документации
Call
Здесь уже несложно догадаться, что это вызов подпрограммы(функции)
Выглядит это в коде так
CALL ·panicUnaligned(SB)
Пример взял из этого файла
Но по сути означает следующее:
Сохранить адрес следующей инструкции
Перейти в начало вызываемой функции
В Go assembler вызов функции записывается через символ, например runtime·abort(SB), а реальный адрес подставляет линкер. Это обычный паттерн в исходниках runtime.
Отдельно отмечу, что
CALL -> сохраняет return address
JMP -> просто прыгает
Return
RET – возврат из функции. Думаю, все прекрасно представляют, что это может означать, но давайте разберемся. Работает это так:
Взять сохранённый адрес возврата
Передать управление обратно
Соответственно, CALL и RET – парные инструкции, так же как () и return
А теперь на последок в этой части давайте разберемся с этими инструкциями. Пусть есть код:
package main func add(a, b int) int { return a + b } func main() { x := add(2, 3) _ = x }
Как это понимать логически:
main.main
CALL main.add
получить результат
сохранить
main.add
загрузить аргументы
сложить
вернуть результат
Тогда на уровне компилятора получим примерно:
TEXT main.add(SB), ABIInternal, $0-24 ; TEXT - нотация для функций, а 0-24 – stack frame для двух аргументов и возврата. ABI я осознанно не трогаю MOVQ a+0(FP), AX ; AX = a MOVQ b+8(FP), BX ; BX = b ADDQ BX, AX ; AX = a + b MOVQ AX, ret+16(FP) ; вернуть результат RET TEXT main.main(SB), ABIInternal, $0-16 MOVQ $2, AX MOVQ AX, 0(SP) ; первый аргумент MOVQ $3, AX MOVQ AX, 8(SP) ; второй аргумент CALL main.add(SB) ; вызов функции MOVQ 16(SP), AX ; получить результат MOVQ AX, main.x(SB) RET
По итогу мы разобрали наверное самое базовое и, вероятно, неинтересное в глубинах Go, но это база для того, чтобы понимать происходящее далее в том числе атомарные операции и то, как вообще происходит исполнение Go
В заключение напомню – ЭТО НЕ ГАЙД ПО GO ASSEMBLER, а разбор того, что из себя представляет End 2 End язык Go.
Мы разобрали базовые типы инструкций, через которые CPU выполняет код: арифметику, сравнение, переходы и вызовы функций.
В следующей части посмотрим на более интересные вещи:
атомарные инструкции
fence/barrier-подобные эффекты
системные переходы
P.S. Я думаю, что нет смысл забуриваться далее и разбирать типы триггеров, используемых в CPU, RAM :)