Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Операнды, семейство mov и стек

middle-senior~150 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Операнды, семейство mov и стек

В предыдущем уроке ты научился получать листинг своей программы и узнавать в нём регистры. Теперь разберём, как инструкция называет данные, с которыми работает. У x86-64 одиннадцать форм операнда, и все они сводятся к одной формуле адреса. На этой формуле стоит вся работа с массивами и структурами, которую ты увидишь дальше. Заодно посмотрим на самую частую инструкцию процессора, mov, во всех её обличьях, и разберём по битам, как форма операнда записывается в байты инструкции.

Цели урока

  • Знать одиннадцать форм спецификатора операнда и уметь свести любую из них к формуле Imm + R[rb] + R[ri] * s.
  • По заданному состоянию регистров и памяти считать значение операнда любой формы в уме.
  • Понимать пять форм mov и почему шестой формы, из памяти в память, не существует.
  • Знать правило про непосредственное значение в памяти: только imm32 со знаковым расширением, а для остального есть movabsq.
  • Отличать movz от movs и помнить, что запись в 32-битный регистр обнуляет старшую половину, поэтому movzlq не нужен.
  • Видеть pushq и popq как пару обычных инструкций и понимать, зачем процессору отдельная короткая форма.
  • Читать байты mov вручную: разбирать ModRM и SIB, узнавать особые случаи rm=100 и адресацию относительно счётчика инструкций.
  • По ассемблерному листингу восстанавливать исходную функцию на Zig.

Идея: инструкция должна назвать свои данные

Инструкция это глагол, а операнды это его дополнения. mov значит “перенеси”, но откуда и куда, надо сказать отдельно. Возможностей ровно три: значение зашито прямо в инструкцию, значение лежит в регистре, значение лежит в памяти. Первые две простые, третья интересная: адрес в памяти редко бывает известен заранее, обычно он считается из чего-то.

Вспомни, как ты писал xs[i] в уроке про указатели и срезы. Чтобы получить этот элемент, надо взять адрес начала, прибавить i, умноженное на размер элемента. Разработчики 8086 заметили, что такая формула нужна постоянно, и встроили её прямо в процессор: адрес операнда считается сложением до трёх слагаемых, и это происходит внутри инструкции, без отдельных команд сложения и умножения.

Спецификатор операнда в общем виде выглядит как Imm(rb,ri,s) и означает адрес Imm + R[rb] + R[ri] * s. Здесь Imm это константа со знаком, rb базовый регистр, ri индексный, а s масштаб, который бывает только 1, 2, 4 или 8. Остальные десять форм это та же формула с выброшенными частями.

Одиннадцать форм операнда

Все формы в одной таблице. Слева синтаксис AT&T, справа то, что получает инструкция. Запись R[r] читается как “содержимое регистра r”, запись M[a] как “содержимое памяти по адресу a”.

ФормаСинтаксисЗначениеКак называется
Непосредственная$ImmImmконстанта внутри инструкции
РегистроваяrR[r]значение прямо из регистра
ПамятьImmM[Imm]абсолютная
Память(rb)M[R[rb]]косвенная
ПамятьImm(rb)M[Imm + R[rb]]база плюс смещение
Память(rb,ri)M[R[rb] + R[ri]]индексная
ПамятьImm(rb,ri)M[Imm + R[rb] + R[ri]]индексная со смещением
Память(,ri,s)M[R[ri] * s]масштабированная индексная
ПамятьImm(,ri,s)M[Imm + R[ri] * s]масштабированная со смещением
Память(rb,ri,s)M[R[rb] + R[ri] * s]база плюс масштабированный индекс
ПамятьImm(rb,ri,s)M[Imm + R[rb] + R[ri] * s]полная форма

Две первые строки к памяти не обращаются вовсе, девять остальных обращаются ровно один раз. Обрати внимание на две вещи, о которые спотыкаются все.

Первая: $0x108 и 0x108 это разные операнды. Со знаком доллара это число сто восемь, без доллара это содержимое ячейки с адресом сто восемь. Доллар в синтаксисе AT&T ставится именно для того, чтобы отличить одно от другого.

Вторая: масштаб применяется только к индексному регистру и никогда к базовому. (%rax,%rdx,4) это R[%rax] + R[%rdx] * 4, а не (R[%rax] + R[%rdx]) * 4. Позиция запятых определяет роль: первый регистр в скобках всегда база, второй всегда индекс.

Считаем на конкретном состоянии

Возьмём машину, у которой в памяти лежат четыре восьмибайтовых значения, а в регистрах три числа.

АдресЗначение
0x1000xFF
0x1040xAB
0x1080x13
0x10C0x11
РегистрЗначение
%rax0x100
%rcx0x1
%rdx0x3

Разберём три операнда руками, остальные оставим тебе и калькулятору.

Операнд 9(%rax,%rdx). Формы с двумя регистрами и без явного масштаба означают масштаб 1. Адрес равен 9 + R[%rax] + R[%rdx] * 1, то есть 9 + 0x100 + 3. Девять это 0x9, поэтому сумма 0x10C. По этому адресу лежит 0x11, это и есть значение операнда.

Операнд 260(%rcx,%rdx). Смещение записано в десятичной системе, и это первое, на чём легко ошибиться: 260 это 0x104. Адрес равен 0x104 + 1 + 3, то есть 0x108, а там лежит 0x13.

Операнд 0xFC(,%rcx,4). Базы нет, есть только масштабированный индекс: адрес равен 0xFC + R[%rcx] * 4, то есть 0xFC + 4, то есть 0x100. Значение 0xFF. Заметь, что пустое место перед первой запятой это не опечатка, а способ сказать “базы нет”.

Ту же арифметику можно записать программой. Тип операнда как размеченное объединение, вычисление адреса одной функцией, и таблица из девяти проверок:

const std = @import("std");

/// Состояние машины: несколько восьмибайтовых ячеек и три регистра.
const State = struct {
    mem: std.AutoHashMapUnmanaged(u64, u64),
    rax: u64,
    rcx: u64,
    rdx: u64,

    fn load(self: State, addr: u64) u64 {
        return self.mem.get(addr) orelse 0;
    }
};

/// Спецификатор операнда: ровно те формы, что перечислены в таблице.
const Operand = union(enum) {
    imm: u64,
    reg: *const u64,
    /// Imm(rb, ri, s): адрес = Imm + rb + ri * s.
    mem: struct {
        disp: i64 = 0,
        base: ?*const u64 = null,
        index: ?*const u64 = null,
        scale: u64 = 1,
    },
};

fn address(disp: i64, base: ?*const u64, index: ?*const u64, scale: u64) u64 {
    var addr: u64 = @bitCast(disp);
    if (base) |b| addr +%= b.*;
    if (index) |i| addr +%= i.* *% scale;
    return addr;
}

fn value(state: State, op: Operand) u64 {
    return switch (op) {
        .imm => |v| v,
        .reg => |r| r.*,
        .mem => |m| state.load(address(m.disp, m.base, m.index, m.scale)),
    };
}

test "девять операндов на одном состоянии" {
    var mem: std.AutoHashMapUnmanaged(u64, u64) = .empty;
    defer mem.deinit(std.testing.allocator);
    try mem.put(std.testing.allocator, 0x100, 0xFF);
    try mem.put(std.testing.allocator, 0x104, 0xAB);
    try mem.put(std.testing.allocator, 0x108, 0x13);
    try mem.put(std.testing.allocator, 0x10C, 0x11);

    const st: State = .{ .mem = mem, .rax = 0x100, .rcx = 0x1, .rdx = 0x3 };
    const rax = &st.rax;
    const rcx = &st.rcx;
    const rdx = &st.rdx;

    // %rax: регистровый операнд, значение прямо из регистра.
    try std.testing.expectEqual(@as(u64, 0x100), value(st, .{ .reg = rax }));
    // 0x104: абсолютный адрес.
    try std.testing.expectEqual(@as(u64, 0xAB), value(st, .{ .mem = .{ .disp = 0x104 } }));
    // $0x108: константа, к памяти не обращаемся.
    try std.testing.expectEqual(@as(u64, 0x108), value(st, .{ .imm = 0x108 }));
    // (%rax): косвенная адресация.
    try std.testing.expectEqual(@as(u64, 0xFF), value(st, .{ .mem = .{ .base = rax } }));
    // 4(%rax): база плюс смещение.
    try std.testing.expectEqual(@as(u64, 0xAB), value(st, .{ .mem = .{ .disp = 4, .base = rax } }));
    // 9(%rax,%rdx): база плюс индекс плюс смещение, масштаб 1.
    try std.testing.expectEqual(@as(u64, 0x11), value(st, .{ .mem = .{ .disp = 9, .base = rax, .index = rdx } }));
    // 260(%rcx,%rdx): 260 это 0x104.
    try std.testing.expectEqual(@as(u64, 0x13), value(st, .{ .mem = .{ .disp = 260, .base = rcx, .index = rdx } }));
    // 0xFC(,%rcx,4): базы нет, только масштабированный индекс.
    try std.testing.expectEqual(@as(u64, 0xFF), value(st, .{ .mem = .{ .disp = 0xFC, .index = rcx, .scale = 4 } }));
    // (%rax,%rdx,4): база плюс индекс с масштабом 4, смещения нет.
    try std.testing.expectEqual(@as(u64, 0x11), value(st, .{ .mem = .{ .base = rax, .index = rdx, .scale = 4 } }));
}
$ zig test operands.zig
1/1 operands.test.девять операндов на одном состоянии...OK
All 1 tests passed.

Три вещи в этом коде стоят внимания.

  1. Поля base и index опциональные, а disp и scale имеют значения по умолчанию. Именно поэтому все одиннадцать форм записываются одной структурой: недостающие части просто не заданы.
  2. address складывает через +%, а disp переводит в беззнаковое через @bitCast. Адрес это 64-битное число по модулю два в шестьдесят четвёртой, отрицательное смещение работает как вычитание, и переполнение здесь нормальная ситуация, а не ошибка.
  3. Функция value не знает про регистры и память отдельно: она получает состояние и операнд, и всё различие между формами живёт в switch по тегу объединения. Ровно так же устроен настоящий декодер инструкций, к которому мы придём в конце урока.

Покрути калькулятор

Ниже тот же расчёт, но живой. Значения регистров и ячеек памяти можно менять, поле операнда принимает синтаксис AT&T. Начни с девяти кнопок над полем: это те самые формы из таблицы. Потом попробуй сломать калькулятор: напиши масштаб 3, убери оба регистра из скобок, укажи регистр, которого нет в таблице. Обрати внимание, как меняется строка формы: одна и та же полная формула превращается в разные названия, когда части исчезают.

Затем поставь %rdx в 0x2 и посмотри на (%rax,%rdx,4). Адрес уедет с 0x10C на 0x108, а значение с 0x11 на 0x13. Это ровно то, что происходит, когда в Zig ты меняешь индекс в xs[i].

Главный вывод из калькулятора: адрес это не то, что лежит в регистре, а то, что посчитано из регистров. Инструкция movq (%rax), %rdx не копирует %rax, она читает память по адресу, который в %rax записан. Разница между %rax и (%rax) это разница между указателем и разыменованием, только записанная скобками.

Семейство mov

Самая частая инструкция процессора называется mov и копирует данные из источника в приёмник. В AT&T источник пишется первым: movq %rdi, %rax значит “положи %rdi в %rax”. У инструкции четыре размера, и суффикс говорит, сколько байт переносится: movb один, movw два, movl четыре, movq восемь.

Возьмём семь функций на Zig, каждая из которых даёт ровно одну форму, и снимем с них ассемблер.

// Пять форм mov: регистр в регистр, константа в регистр,
// константа в память, память в регистр, регистр в память.

export fn movRegReg(x: u64) u64 {
    return x;
}

export fn movImm32Reg() u64 {
    return 0x42;
}

export fn movImm64Reg() u64 {
    return 0x0102030405060708;
}

export fn movImmMem(p: *u64) void {
    p.* = 0x42;
}

export fn movBigImmMem(p: *u64) void {
    p.* = 0x0102030405060708;
}

export fn movMemReg(p: *const u64) u64 {
    return p.*;
}

export fn movRegMem(p: *u64, v: u64) void {
    p.* = v;
}

Слово export нужно, чтобы оптимизатор не встроил функцию в вызывающую и не выкинул её целиком: экспортированную функцию видно снаружи объектного файла, поэтому компилятор обязан сгенерировать её тело. Типы тоже подобраны так, чтобы у них было представление в C: указатель и целое, никаких срезов.

Собираем объектный файл и читаем его дизассемблером. Ассемблер, который печатает сам компилятор по флагу -femit-asm, использует синтаксис Intel, а нам нужен AT&T, как в книге, поэтому листинги весь урок снимаем через objdump. Он же кладёт рядом с каждой инструкцией её байты, и это нам скоро пригодится:

$ zig build-obj movforms.zig -O ReleaseFast -target x86_64-linux \
      -fomit-frame-pointer -femit-bin=movforms.o
$ objdump -d movforms.o
0000000000000000 <movforms.movRegMem>:
   0:   48 89 37                movq   %rsi, (%rdi)
   3:   c3                      retq

0000000000000010 <movforms.movMemReg>:
  10:   48 8b 07                movq   (%rdi), %rax
  13:   c3                      retq

0000000000000020 <movforms.movBigImmMem>:
  20:   48 b8 08 07 06 05 04 03 02 01   movabsq $0x102030405060708, %rax
  2a:   48 89 07                        movq    %rax, (%rdi)
  2d:   c3                              retq

0000000000000030 <movforms.movImmMem>:
  30:   48 c7 07 42 00 00 00    movq   $0x42, (%rdi)
  37:   c3                      retq

0000000000000040 <movforms.movImm64Reg>:
  40:   48 b8 08 07 06 05 04 03 02 01   movabsq $0x102030405060708, %rax
  4a:   c3                              retq

0000000000000050 <movforms.movImm32Reg>:
  50:   b8 42 00 00 00          movl   $0x42, %eax
  55:   c3                      retq

0000000000000060 <movforms.movRegReg>:
  60:   48 89 f8                movq   %rdi, %rax
  63:   c3                      retq

Разбери листинг по функциям.

  • movRegMem и movMemReg это две зеркальные формы: регистр в память и память в регистр. Аргументы пришли в %rdi и %rsi, результат уезжает в %rax. Порядок аргументов по регистрам мы разберём подробно в уроке про вызовы функций, пока достаточно знать, что первые шесть целочисленных аргументов идут в %rdi, %rsi, %rdx, %rcx, %r8, %r9, а результат возвращается в %rax.
  • movRegReg это форма регистр в регистр, одна инструкция на всю функцию.
  • movImm32Reg возвращает 0x42, и компилятор кладёт его инструкцией movl в %eax, а не movq в %rax. Почему четырёхбайтовая форма годится для восьмибайтового результата, разберём через два раздела.
  • movImmMem кладёт константу прямо в память одной инструкцией.
  • movBigImmMem и movImm64Reg для той же задачи с большой константой берут movabsq.

Шестой формы, из памяти в память, у x86-64 нет. Инструкция обращается к памяти не больше одного раза, поэтому перенос между двумя ячейками это всегда две инструкции с регистром посередине. Именно это ты видишь в movBigImmMem: константа сначала попадает в %rax, и только потом в память.

Константа в память: только четыре байта со знаком

Отдельное правило, которое стоит запомнить дословно: в память константу можно положить только как imm32 со знаковым расширением. Проверим на трёх константах.

// Три константы в 64-битную ячейку памяти: помещается ли она в imm32 со знаком.

export fn storeSmall(p: *u64) void {
    p.* = 0x42;
}

export fn storeNegative(p: *i64) void {
    p.* = -1;
}

export fn storeAtBoundary(p: *u64) void {
    p.* = 0x8000_0000;
}
0000000000000000 <storeAtBoundary>:
   0:   b8 00 00 00 80          movl   $0x80000000, %eax
   5:   48 89 07                movq   %rax, (%rdi)
   8:   c3                      retq

0000000000000010 <storeNegative>:
  10:   48 c7 07 ff ff ff ff    movq   $-0x1, (%rdi)
  17:   c3                      retq

0000000000000020 <storeSmall>:
  20:   48 c7 07 42 00 00 00    movq   $0x42, (%rdi)
  27:   c3                      retq

Три случая, три разных исхода.

  1. 0x42 укладывается в четыре байта: в инструкции лежит 42 00 00 00, процессор расширяет их со знаком до 0x0000000000000042. Одна инструкция.
  2. -1 тоже укладывается: ff ff ff ff расширяется со знаком до 0xFFFFFFFFFFFFFFFF. Одна инструкция, хотя записанное в память число огромное по модулю. Знаковое расширение работает в нашу пользу.
  3. 0x80000000 не укладывается. Байты 00 00 00 80 расширились бы со знаком до 0xFFFFFFFF80000000, а нам нужно 0x0000000080000000. Компилятор берёт обходной путь: сначала movl в %eax, потом movq из регистра в память. Две инструкции.

Обрати внимание на третий случай: movl $0x80000000, %eax занимает пять байт, а не десять, как movabsq. Компилятор выбрал 32-битную форму, потому что она короче и делает ровно то, что нужно. Почему делает, объясняет следующий раздел.

movabsq: единственный способ положить в регистр все восемь байт

Инструкция movabsq это mov, у которого непосредственное значение занимает целых восемь байт. Она умеет только одно: положить константу в регистр. В память ей нельзя, и второй операнд у неё всегда регистр.

  40:   48 b8 08 07 06 05 04 03 02 01   movabsq $0x102030405060708, %rax

Десять байт: 48 это префикс, b8 опкод, и восемь байт самой константы в порядке от младшего к старшему. Ту же константу через обычный movq записать нельзя, потому что у обычного mov поле непосредственного значения не больше четырёх байт.

Четыре ширины одной инструкции

Суффикс mov выбирает, сколько байт переносится. Возьмём одну и ту же запись в память для четырёх типов.

// Один и тот же mov в четырёх размерах: b, w, l, q.

export fn storeB(p: *u8, v: u8) void {
    p.* = v;
}

export fn storeW(p: *u16, v: u16) void {
    p.* = v;
}

export fn storeL(p: *u32, v: u32) void {
    p.* = v;
}

export fn storeQ(p: *u64, v: u64) void {
    p.* = v;
}
0000000000000000 <widths.storeQ>:
   0:   48 89 37                movq   %rsi, (%rdi)
   3:   c3                      retq

0000000000000010 <widths.storeL>:
  10:   89 37                   movl   %esi, (%rdi)
  12:   c3                      retq

0000000000000020 <widths.storeW>:
  20:   66 89 37                movw   %si, (%rdi)
  23:   c3                      retq

0000000000000030 <widths.storeB>:
  30:   40 88 37                movb   %sil, (%rdi)
  33:   c3                      retq

Опкод почти один и тот же, меняется обвязка. У movl два байта, это базовая форма. У movq спереди появился 48, это префикс REX с установленным битом W, то есть просьба работать с восемью байтами. У movw спереди 66, это старый префикс размера операнда, доставшийся от 16-битных времён. У movb другой опкод, 88 вместо 89, и префикс 40, у которого не выставлен ни один бит.

Последний случай стоит объяснить. Префикс 40 ничего не меняет в размерах, он нужен только чтобы имя %sil означало младший байт %rsi. Без REX номер 6 в поле байтового регистра означал бы %dh, старший байт %rdx, как это было на 8086. Наличие любого REX переключает кодировку байтовых регистров на новую схему, поэтому компилятор ставит пустой префикс просто ради этого побочного эффекта.

movz, movs и правило про 32 бита

Когда узкое значение кладут в широкий регистр, надо решить, чем заполнить старшие биты. Вариантов два: нулями или копией знакового бита. У x86-64 для этого два семейства инструкций с суффиксами из двух букв: первая буква размер источника, вторая размер приёмника.

// Расширение узкого значения до широкого регистра.

export fn zeroByteToWord(p: *const u8) u32 {
    return p.*;
}

export fn zeroByteToQuad(p: *const u8) u64 {
    return p.*;
}

export fn zeroHalfToWord(p: *const u16) u32 {
    return p.*;
}

export fn zeroWordToQuad(p: *const u32) u64 {
    return p.*;
}

export fn signByteToWord(p: *const i8) i32 {
    return p.*;
}

export fn signByteToQuad(p: *const i8) i64 {
    return p.*;
}

export fn signHalfToQuad(p: *const i16) i64 {
    return p.*;
}

export fn signWordToQuad(p: *const i32) i64 {
    return p.*;
}
0000000000000000 <signWordToQuad>:
   0:   48 63 07                movslq (%rdi), %rax
   3:   c3                      retq

0000000000000010 <signHalfToQuad>:
  10:   48 0f bf 07             movswq (%rdi), %rax
  14:   c3                      retq

0000000000000020 <signByteToQuad>:
  20:   48 0f be 07             movsbq (%rdi), %rax
  24:   c3                      retq

0000000000000030 <signByteToWord>:
  30:   0f be 07                movsbl (%rdi), %eax
  33:   c3                      retq

0000000000000040 <zeroWordToQuad>:
  40:   8b 07                   movl   (%rdi), %eax
  42:   c3                      retq

0000000000000050 <zeroHalfToWord>:
  50:   0f b7 07                movzwl (%rdi), %eax
  53:   c3                      retq

0000000000000060 <zeroByteToQuad>:
  60:   0f b6 07                movzbl (%rdi), %eax
  63:   c3                      retq

0000000000000070 <zeroByteToWord>:
  70:   0f b6 07                movzbl (%rdi), %eax
  73:   c3                      retq

Три наблюдения, и второе из них главное в этом уроке.

Первое. Функции со знаковыми типами получили movs, с беззнаковыми movz. В Zig это следует из типа: присвоение i8 в i64 расширяет со знаком, u8 в u64 нулями, и компилятор просто выбирает соответствующую инструкцию.

Второе, и это правило надо выучить. Посмотри на zeroByteToQuad и zeroByteToWord: они возвращают значения разной ширины, u64 и u32, но код у них совпадает байт в байт. Обе используют movzbl в %eax, а не movzbq в %rax. Причина в том, что любая запись в 32-битный регистр обнуляет старшую половину соответствующего 64-битного. Записал в %eax, и старшие тридцать два бита %rax стали нулями сами. Поэтому movzbq не нужен, и его в системе команд просто нет: movzbl делает то же самое и занимает на байт меньше.

Третье следует из второго. У zeroWordToQuad вообще нет расширяющей инструкции: movl (%rdi), %eax загружает четыре байта в %eax, и старшая половина %rax обнуляется по тому же правилу. Инструкции movzlq не существует, потому что она была бы синонимом обычного movl. А вот movslq существует и нужна: знаковое расширение бесплатным не бывает, копию знакового бита кто-то должен размножить.

Правило работает и когда значение уже в регистре:

// Расширение значения, которое уже лежит в регистре.

export fn widenSignedWord(w: i32) i64 {
    return @as(i64, w);
}

export fn dropUpperHalf(x: u64) u64 {
    const low: u32 = @truncate(x);
    return @as(u64, low);
}
0000000000000000 <dropUpperHalf>:
   0:   89 f8                   movl   %edi, %eax
   2:   c3                      retq

0000000000000010 <widenSignedWord>:
  10:   48 63 c7                movslq %edi, %rax
  13:   c3                      retq

Функция dropUpperHalf берёт восьмибайтовое число, отрезает младшие четыре байта через @truncate и снова расширяет до восьми. Казалось бы, тут нужна маска 0xFFFFFFFF и две инструкции. На деле хватило одной: movl %edi, %eax копирует младшую половину и обнуляет старшую. Так @truncate в u32 с последующим расширением превращается в один movl.

Функция widenSignedWord делает то же самое для знакового типа, и там уже нужна настоящая инструкция movslq. Разница ровно одна: @as(u64, low) бесплатно, @as(i64, w) стоит инструкцию.

push и pop: две инструкции в одной

Стек это область памяти, которая растёт вниз, в сторону меньших адресов, а регистр %rsp всегда показывает на её вершину. Положить туда значение можно двумя способами, и первый ты уже знаешь: подвинуть %rsp и записать по новому адресу.

Проверим, что короткая форма и длинная делают одно и то же. Напишем оба варианта руками и посмотрим на байты:

        .text
        .globl  push_one
push_one:
        pushq   %rbx
        ret

        .globl  push_manual
push_manual:
        subq    $8, %rsp
        movq    %rbx, (%rsp)
        ret

        .globl  pop_one
pop_one:
        popq    %rbx
        ret

        .globl  pop_manual
pop_manual:
        movq    (%rsp), %rbx
        addq    $8, %rsp
        ret
$ zig cc -c -target x86_64-linux pushpop.s -o pushpop.o
$ objdump -d pushpop.o
0000000000000000 <push_one>:
   0:   53                      pushq  %rbx
   1:   c3                      retq

0000000000000002 <push_manual>:
   2:   48 83 ec 08             subq   $0x8, %rsp
   6:   48 89 1c 24             movq   %rbx, (%rsp)
   a:   c3                      retq

000000000000000b <pop_one>:
   b:   5b                      popq   %rbx
   c:   c3                      retq

000000000000000d <pop_manual>:
   d:   48 8b 1c 24             movq   (%rsp), %rbx
  11:   48 83 c4 08             addq   $0x8, %rsp
  15:   c3                      retq

Поведение одинаковое, размер разный: один байт против восьми. Вот зачем процессору отдельная инструкция для операции, которую можно выразить через две существующие. Стек трогают в каждом прологе и эпилоге каждой функции, и экономия семи байт на операцию складывается в заметный размер кода.

Порядок внутри пары важен и в разные стороны разный. pushq сначала уменьшает %rsp, потом пишет по новому адресу. popq сначала читает по текущему адресу, потом увеличивает %rsp. Если перепутать порядок, значение уедет не в ту ячейку.

Где push и pop появляются сами

Компилятор ставит их там, где надо сохранить callee-saved регистры. Напишем функцию, которая держит значение через вызов чужой функции, и посмотрим на её пролог.

// Компилятору нужно сохранить значение через вызов чужой функции,
// поэтому он берёт callee-saved регистр и возвращает его владельцу.

extern fn step(x: u64) u64;

export fn twoSteps(x: u64) u64 {
    const first = step(x);
    return first +% step(x +% 1);
}
0000000000000000 <twoSteps>:
   0:   41 56                   pushq  %r14
   2:   53                      pushq  %rbx
   3:   50                      pushq  %rax
   4:   48 89 fb                movq   %rdi, %rbx
   7:   e8 00 00 00 00          callq  <step>
   c:   49 89 c6                movq   %rax, %r14
   f:   48 83 c3 01             addq   $0x1, %rbx
  13:   48 89 df                movq   %rbx, %rdi
  16:   e8 00 00 00 00          callq  <step>
  1b:   4c 01 f0                addq   %r14, %rax
  1e:   48 83 c4 08             addq   $0x8, %rsp
  22:   5b                      popq   %rbx
  23:   41 5e                   popq   %r14
  25:   c3                      retq

Разбери пролог и эпилог по шагам.

  1. Аргумент x нужен и до, и после первого вызова step. Держать его в %rdi нельзя: step имеет право испортить этот регистр. Поэтому компилятор перекладывает x в %rbx, который вызываемая функция портить не смеет.
  2. Раз %rbx теперь занят нашим значением, его прежнее содержимое надо вернуть тому, кто нас позвал. Отсюда pushq %rbx в начале и popq %rbx в конце. То же с %r14, куда уехал результат первого вызова.
  3. Третий pushq %rax странно смотрится: %rax никто не сохраняет. Это не сохранение, а выравнивание. Перед инструкцией call указатель стека обязан быть кратен шестнадцати, а два предыдущих push сдвинули его на шестнадцать байт от границы, которую задал вход в функцию. Третий push добавляет восемь и возвращает выравнивание. В эпилоге эти восемь байт снимаются не через pop, а через addq $0x8, %rsp: значение никому не нужно, важен только указатель.
  4. Эпилог идёт в обратном порядке относительно пролога, иначе значения попали бы не в те регистры. Стек это последним пришёл, первым ушёл, и здесь это видно буквально.

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

Как форма операнда превращается в байты

Теперь самое интересное. Все листинги выше печатали байты слева от мнемоники, и пора научиться читать их самому. Дизассемблер, который ты начал писать в предыдущем уроке, дальше будет делать ровно это.

Инструкция с операндами в памяти состоит из частей в фиксированном порядке: необязательные префиксы, опкод, байт ModRM, необязательный байт SIB, необязательное смещение, необязательное непосредственное значение.

Байт ModRM устроен так:

  7   6   5   4   3   2   1   0
+---+---+---+---+---+---+---+---+
|  mod  |    reg    |     rm    |
+---+---+---+---+---+---+---+---+

Поле mod говорит, где искать второй операнд и есть ли смещение:

modЧто означает
00операнд в памяти по адресу из регистра, смещения нет
01операнд в памяти, дальше идёт однобайтовое смещение со знаком
10операнд в памяти, дальше идёт четырёхбайтовое смещение со знаком
11операнд прямо в регистре, к памяти не обращаемся

Поле reg это чаще всего номер второго операнда, всегда регистра. Иногда оно не регистр, а продолжение опкода, и тогда в справочниках пишут /0, /1 и так далее. Поле rm это номер регистра, который служит базой адреса (или сам операнд, если mod равен 11).

Номера регистров одинаковы во всех трёх полях:

Номер01234567
без REX%rax%rcx%rdx%rbx%rsp%rbp%rsi%rdi
с битом REX%r8%r9%r10%r11%r12%r13%r14%r15

Какой именно бит REX добавляется, зависит от поля: REX.R расширяет reg, REX.B расширяет rm и поле базы в SIB, REX.X расширяет поле индекса в SIB. Отсюда и буквы: R для reg, B для base, X для index.

Читаем шесть инструкций по байтам

Соберём руками файл со всеми интересными случаями и посмотрим, что выдал ассемблер.

        .text
        .globl  modrm_demo
modrm_demo:
        movq    %rax, %rdx
        movq    (%rdi), %rax
        movq    8(%rdi), %rax
        movq    800(%rdi), %rax
        movq    (%rdi,%rsi,8), %rax
        movq    16(%rdi,%rsi,8), %rax
        movq    (%rsp), %rbx
        movq    (%rbp), %rbx
        movq    (%r12), %rbx
        movq    (%r13), %rbx
        movq    (%rdi,%rsi,1), %rax
        movq    (%rdi,%rsi,4), %rax
        movq    %r8, (%rdi)
        movq    (%rdi), %r9
        ret
$ zig cc -c -target x86_64-linux modrm.s -o modrm.o
$ objdump -d modrm.o
0000000000000000 <modrm_demo>:
   0:   48 89 c2                movq   %rax, %rdx
   3:   48 8b 07                movq   (%rdi), %rax
   6:   48 8b 47 08             movq   0x8(%rdi), %rax
   a:   48 8b 87 20 03 00 00    movq   0x320(%rdi), %rax
  11:   48 8b 04 f7             movq   (%rdi,%rsi,8), %rax
  15:   48 8b 44 f7 10          movq   0x10(%rdi,%rsi,8), %rax
  1a:   48 8b 1c 24             movq   (%rsp), %rbx
  1e:   48 8b 5d 00             movq   (%rbp), %rbx
  22:   49 8b 1c 24             movq   (%r12), %rbx
  26:   49 8b 5d 00             movq   (%r13), %rbx
  2a:   48 8b 04 37             movq   (%rdi,%rsi), %rax
  2e:   48 8b 04 b7             movq   (%rdi,%rsi,4), %rax
  32:   4c 89 07                movq   %r8, (%rdi)
  35:   4c 8b 0f                movq   (%rdi), %r9
  38:   c3                      retq

Разберём первые шесть строк подробно. Опкод 8b означает “загрузить в регистр из r/m”, опкод 89 означает “записать в r/m из регистра”. Байт 48 это REX с битом W, то есть работаем с восемью байтами.

Строка 48 89 c2. ModRM это c2, в двоичном виде 11 000 010. Поле mod равно 11, значит оба операнда регистры, к памяти не обращаемся. Поле reg равно 000, это %rax. Поле rm равно 010, это %rdx. Опкод 89 пишет из reg в rm, получается movq %rax, %rdx.

Строка 48 8b 07. ModRM это 07, в двоичном виде 00 000 111. Поле mod равно 00, операнд в памяти без смещения. Поле reg равно 000, это %rax, приёмник. Поле rm равно 111, это %rdi, база адреса. Опкод 8b читает в reg, получается movq (%rdi), %rax.

Строка 48 8b 47 08. ModRM это 47, то есть 01 000 111. Поле mod стало 01, значит после ModRM идёт один байт смещения со знаком, и это 08. Получается movq 0x8(%rdi), %rax.

Строка 48 8b 87 20 03 00 00. ModRM это 87, то есть 10 000 111. Поле mod равно 10, значит смещение занимает четыре байта, и лежат они от младшего к старшему: 20 03 00 00 это 0x320, то есть восемьсот. Получается movq 0x320(%rdi), %rax.

Особый случай первый: rm равно 100

Строка 48 8b 04 f7. ModRM это 04, то есть 00 000 100. Поле rm равно 100, и по таблице регистров это %rsp. Но адрес (%rsp) тут ни при чём: комбинация rm=100 при mod не равном 11 означает, что за ModRM идёт ещё один байт, SIB. Именно так в инструкцию попадают индексный регистр и масштаб.

SIB устроен по той же схеме:

  7   6   5   4   3   2   1   0
+---+---+---+---+---+---+---+---+
| scale |   index   |    base   |
+---+---+---+---+---+---+---+---+

Поле scale кодирует множитель: 00 это 1, 01 это 2, 10 это 4, 11 это 8. Вот почему масштаб бывает только степенью двойки до восьми: на него отведено два бита.

Возвращаемся к строке. SIB это f7, в двоичном виде 11 110 111. Поле scale равно 11, множитель восемь. Поле index равно 110, это %rsi. Поле base равно 111, это %rdi. Собираем: movq (%rdi,%rsi,8), %rax.

Следующая строка 48 8b 44 f7 10 отличается только полем mod: 44 это 01 000 100, значит после SIB идёт байт смещения 10, то есть шестнадцать. Получается movq 0x10(%rdi,%rsi,8), %rax.

Теперь понятно, почему %rsp не может быть индексным регистром: номер 100 в поле index зарезервирован под значение “индекса нет”. Посмотри на строку 48 8b 1c 24. Здесь SIB это 24, то есть 00 100 100: масштаб один, индекс 100 означает “нет индекса”, база 100 означает %rsp. Чтобы обратиться по (%rsp), процессор вынужден потратить лишний байт SIB, потому что прямо в поле rm номер четыре занят под другой смысл.

Строки 48 8b 04 37 и 48 8b 04 b7 показывают ту же схему с другими масштабами: SIB 37 это 00 110 111, масштаб один, и объектный дизассемблер печатает такую форму без третьего аргумента, как (%rdi,%rsi). SIB b7 это 10 110 111, масштаб четыре.

Особый случай второй: mod равно 00, rm равно 101

Строка 48 8b 5d 00 выглядит подозрительно: ассемблеру написали (%rbp), а он выдал смещение 00, то есть mod равен 01 вместо ожидаемого 00. Байт потрачен на нулевое смещение.

Причина в том, что комбинация mod=00, rm=101 тоже занята. Она означает не (%rbp), а адрес относительно счётчика инструкций: после ModRM идёт четырёхбайтовое смещение, и адрес считается от адреса следующей инструкции. Поэтому (%rbp) без смещения закодировать невозможно, и ассемблер молча дописывает нулевой байт.

Ту самую занятую комбинацию видно на глобальной переменной:

const std = @import("std");

var counter: u64 = 0;

export fn bump(delta: u64) void {
    counter +%= delta;
}

export fn readCounter() u64 {
    return counter;
}

pub fn main(init: std.process.Init) !void {
    bump(7);
    var buf: [64]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    try w.interface.print("counter = {d}\n", .{readCounter()});
    try w.interface.flush();
}
$ zig build-exe globalmain.zig -O ReleaseFast -target x86_64-linux-musl \
      -fomit-frame-pointer -femit-bin=globalmain
$ objdump -d globalmain
000000000106f030 <readCounter>:
 106f030:  48 8b 05 d1 0f 01 00    movq   0x10fd1(%rip), %rax   # 0x1080008
 106f037:  c3                      retq

000000000106f040 <globalmain.bump>:
 106f040:  48 01 3d c1 0f 01 00    addq   %rdi, 0x10fc1(%rip)   # 0x1080008

ModRM здесь 05, то есть 00 000 101. Поле mod равно 00, поле rm равно 101, значит дальше четыре байта смещения: d1 0f 01 00 это 0x10FD1. Адрес считается от следующей инструкции, а она начинается на 0x106F037. Складываем: 0x106F037 + 0x10FD1 даёт 0x1080008, и это адрес переменной counter, что дизассемблер и подписал в комментарии справа.

Такая адресация называется относительной, и у неё есть практический смысл: инструкция не содержит абсолютного адреса, поэтому весь кусок кода можно загрузить по любому адресу, и он продолжит работать. Это то, на чём стоит рандомизация адресного пространства, о которой пойдёт речь в уроке про переполнение буфера.

Проверим, что программа действительно работает, а не только красиво дизассемблируется. Запускаем x86-64 бинарник в контейнере, потому что рабочая машина у нас на другой архитектуре:

$ docker run --rm --platform linux/amd64 -v "$PWD":/work -w /work \
      ghcr.io/bondiano/runner-zig:dev-amd64 ./globalmain
counter = 7

Два регистра из старшей восьмёрки

Последняя пара строк листинга показывает, как работают биты REX. Строка 4c 89 07: префикс 4c это 0100 1100, где бит W равен единице, а бит R тоже единице. Бит R добавляет старший разряд к полю reg, поэтому reg=000 означает не %rax, а %r8. Получается movq %r8, (%rdi).

Строка 4c 8b 0f: тот же префикс, ModRM 0f это 00 001 111, поле reg равно 001 плюс бит R даёт %r9. Получается movq (%rdi), %r9.

Та же логика объясняет пару строк выше: 49 8b 1c 24 это (%r12), где префикс 49 содержит бит B, расширяющий поле базы в SIB. И заодно объясняет, почему (%r12) кодируется через SIB, а (%r13) через нулевое смещение: младшие три бита у %r12 это 100, у %r13 это 101, то есть они попадают ровно в те два особых случая, что мы только что разобрали. Обе ловушки достались этим регистрам по наследству от %rsp и %rbp.

Реверс: восстанови исходник по листингу

Проверим, как всё сложилось вместе. Вот функция, снятая с настоящего Zig-кода. Тебе известно только то, что она объявлена как export fn, принимает три указателя и ничего не возвращает. Прочитай листинг и попробуй понять, что она делает, прежде чем читать дальше.

0000000000000000 <decode1.decode1>:
   0:   48 8b 07                movq   (%rdi), %rax
   3:   48 8b 0e                movq   (%rsi), %rcx
   6:   4c 8b 02                movq   (%rdx), %r8
   9:   48 89 06                movq   %rax, (%rsi)
   c:   48 89 0a                movq   %rcx, (%rdx)
   f:   4c 89 07                movq   %r8, (%rdi)
  12:   c3                      retq

Разбор построчно.

  1. Три указателя пришли в %rdi, %rsi и %rdx. Назовём их xp, yp и zp, в порядке аргументов.
  2. Первые три инструкции это чтения: содержимое по xp уходит в %rax, по yp в %rcx, по zp в %r8. Суффикс q говорит, что значения восьмибайтовые, значит указатели ведут на 64-битные целые.
  3. Следующие три инструкции это записи, причём ни одна из них не читает память заново. Значение из %rax, то есть старое x, ложится по адресу yp. Значение из %rcx, старое y, ложится по zp. Значение из %r8, старое z, ложится по xp.
  4. Ключевая деталь: все чтения идут до первой записи. Если бы функция писала и читала вперемешку, результат был бы другим, потому что второе чтение увидело бы уже перезаписанное значение. Компилятор сохранил порядок именно таким, потому что таким он был в исходнике.

Получается сдвиг тройки по кругу: x уезжает на место y, y на место z, z на место x. Вот исходник:

export fn decode1(xp: *i64, yp: *i64, zp: *i64) void {
    const x = xp.*;
    const y = yp.*;
    const z = zp.*;
    yp.* = x;
    zp.* = y;
    xp.* = z;
}

Обрати внимание, что регистр %r8 в листинге появился не случайно. Первые два значения удалось положить в %rax и %rcx, регистры, которые функция вправе портить, а для третьего понадобился ещё один такой же. Компилятор взял %r8, потому что он тоже caller-saved и его не надо сохранять на стеке. Если бы значений было больше, чем свободных регистров, часть уехала бы на стек, и в листинге появились бы обращения к (%rsp).

Проверим поведение тестом, чтобы разбор не остался предположением:

const std = @import("std");

export fn decode1(xp: *i64, yp: *i64, zp: *i64) void {
    const x = xp.*;
    const y = yp.*;
    const z = zp.*;
    yp.* = x;
    zp.* = y;
    xp.* = z;
}

test "decode1 сдвигает тройку по кругу" {
    var x: i64 = 1;
    var y: i64 = 2;
    var z: i64 = 3;
    decode1(&x, &y, &z);
    try std.testing.expectEqual(@as(i64, 3), x);
    try std.testing.expectEqual(@as(i64, 1), y);
    try std.testing.expectEqual(@as(i64, 2), z);
}
$ zig test decode_test.zig
1/1 decode_test.test.decode1 сдвигает тройку по кругу...OK
All 1 tests passed.

Шаг проекта: zt учится читать адреса

В предыдущем уроке твой дизассемблер разбирал только инструкции, у которых операндов нет вовсе или номер регистра сидит прямо в опкоде: ret, nop, leave, hlt, push и pop. Всё остальное он честно печатал как (bad) и шёл дальше по одному байту. Сегодняшний шаг самый большой в блоке: после него zt умеет читать адрес в любой из одиннадцати форм и разбирать всё семейство mov.

Механика отдельно от таблицы

Первое решение шага не про инструкции, а про структуру кода. Чтение байтов и знание мнемоник это две разные заботы, и дальше они растут с разной скоростью: таблица опкодов будет пополняться каждый урок, а механика чтения уже почти готова. Поэтому Prefixes и Cursor уезжают из decoder.zig в новый файл src/x86/cursor.zig, и там же поселяется всё новое.

Первая половина файла это префиксы и типы. Байт REX разложен на четыре флага, и у каждого своя работа:

//! Механика чтения инструкции: префиксы, байт ModRM, байт SIB, смещения
//! и непосредственные значения.
//!
//! Здесь нет ни одной мнемоники. Всё, что знает этот файл, это как устроена
//! инструкция x86-64 физически: сначала префиксы, потом опкод, потом
//! необязательный ModRM, за ним необязательный SIB, потом смещение, потом
//! непосредственное значение. Таблица опкодов живёт отдельно, в `decoder.zig`.

const std = @import("std");

const instruction = @import("instruction.zig");

pub const Instruction = instruction.Instruction;
pub const Memory = instruction.Memory;
pub const Operand = instruction.Operand;
pub const Register = instruction.Register;
pub const Size = instruction.Size;

/// Префиксы, которые встретились перед опкодом.
pub const Prefixes = struct {
    /// Байт REX целиком, или null, если его не было. Наличие важно само по
    /// себе: без REX номера с 4 по 7 в байтовых операндах значат ah, ch, dh, bh.
    rex: ?u8 = null,
    /// 0x66: смена ширины операнда на 16 бит, а в SSE часть опкода.
    operand_size: bool = false,
    /// 0xF2 и 0xF3: в SSE часть опкода, в целочисленной части повторители.
    repne: bool = false,
    repe: bool = false,
    /// 0x3E. LLVM ставит его перед косвенным jmp по таблице переходов
    /// и при печати не показывает.
    notrack: bool = false,

    pub fn rexW(prefixes: Prefixes) bool {
        return prefixes.rex != null and prefixes.rex.? & 0b1000 != 0;
    }

    /// REX.R расширяет поле reg байта ModRM.
    pub fn rexR(prefixes: Prefixes) u4 {
        return if (prefixes.rex != null and prefixes.rex.? & 0b0100 != 0) 8 else 0;
    }

    /// REX.X расширяет поле index байта SIB.
    pub fn rexX(prefixes: Prefixes) u4 {
        return if (prefixes.rex != null and prefixes.rex.? & 0b0010 != 0) 8 else 0;
    }

    /// REX.B расширяет поле r/m, поле base байта SIB и номер регистра в опкоде.
    pub fn rexB(prefixes: Prefixes) u4 {
        return if (prefixes.rex != null and prefixes.rex.? & 0b0001 != 0) 8 else 0;
    }

    /// Ширина целого операнда по умолчанию: REX.W даёт 64 бита,
    /// префикс 0x66 даёт 16, иначе 32.
    pub fn operandWidth(prefixes: Prefixes) Size {
        if (prefixes.rexW()) return .qword;
        if (prefixes.operand_size) return .word;
        return .dword;
    }
};

/// Разобранный байт ModRM вместе с тем, что за ним последовало.
pub const ModRm = struct {
    /// Поле reg, расширенное битом REX.R: номер второго регистра.
    reg: u4,
    /// Поле reg без расширения. У групп опкодов это номер операции, `/digit`.
    digit: u3,
    /// Операнд, описанный полями mod и r/m: регистр или адрес в памяти.
    rm: Operand,
};

Обрати внимание на поле digit рядом с reg. Это одно и то же трёхбитное поле байта ModRM, но прочитанное двумя способами. Когда поле означает регистр, к нему надо приклеить бит REX.R и получить reg. Когда поле означает продолжение опкода, никакого REX к нему приклеивать нельзя, и нужен исходный digit. Держать оба варианта проще, чем каждый раз вспоминать, какой случай сейчас.

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

/// Состояние разбора одной инструкции. Живёт ровно один вызов `decode`.
pub const Cursor = struct {
    bytes: []const u8,
    address: u64,
    pos: usize = 0,
    prefixes: Prefixes = .{},

    pub fn next(cursor: *Cursor) ?u8 {
        if (cursor.pos >= cursor.bytes.len) return null;
        const byte = cursor.bytes[cursor.pos];
        cursor.pos += 1;
        return byte;
    }

    pub fn peek(cursor: Cursor) ?u8 {
        if (cursor.pos >= cursor.bytes.len) return null;
        return cursor.bytes[cursor.pos];
    }

    /// Префиксы идут перед опкодом в любом порядке, но REX обязан стоять
    /// последним: сразу за ним читается опкод.
    pub fn readPrefixes(cursor: *Cursor) void {
        while (cursor.peek()) |byte| {
            switch (byte) {
                0x66 => cursor.prefixes.operand_size = true,
                0xf2 => cursor.prefixes.repne = true,
                0xf3 => cursor.prefixes.repe = true,
                0x3e => cursor.prefixes.notrack = true,
                0x40...0x4f => {
                    cursor.prefixes.rex = byte;
                    cursor.pos += 1;
                    return;
                },
                else => return,
            }
            cursor.pos += 1;
        }
    }

    pub fn register(cursor: Cursor, index: u4, size: Size) Register {
        return .{ .index = index, .size = size, .rex_present = cursor.prefixes.rex != null };
    }

    /// Ширина целого операнда с учётом префиксов.
    pub fn width(cursor: Cursor) Size {
        return cursor.prefixes.operandWidth();
    }

    /// Инструкция, у которой всё получилось: длина берётся из позиции курсора.
    pub fn done(cursor: Cursor, mnemonic: []const u8, suffix: ?u8) Instruction {
        return .{
            .bytes = cursor.bytes[0..cursor.pos],
            .address = cursor.address,
            .mnemonic = mnemonic,
            .suffix = suffix,
        };
    }

    /// Читает байт ModRM, а за ним, если надо, SIB и смещение.
    /// `size` это ширина операнда, когда поле r/m описывает регистр.
    pub fn readModRm(cursor: *Cursor, size: Size) ?ModRm {
        const byte = cursor.next() orelse return null;
        const mod: u2 = @truncate(byte >> 6);
        const reg: u3 = @truncate(byte >> 3);
        const rm: u3 = @truncate(byte);

        const reg_index: u4 = @as(u4, reg) | cursor.prefixes.rexR();

        // mod == 3 значит, что r/m это регистр, а не адрес.
        if (mod == 3) {
            const index: u4 = @as(u4, rm) | cursor.prefixes.rexB();
            return .{
                .reg = reg_index,
                .digit = reg,
                .rm = .{ .register = cursor.register(index, size) },
            };
        }

        const memory = cursor.readMemory(mod, rm) orelse return null;
        return .{ .reg = reg_index, .digit = reg, .rm = .{ .memory = memory } };
    }

    /// Адрес по полям mod и r/m. Два значения r/m особенные: 4 означает,
    /// что дальше идёт байт SIB, а 5 при mod == 0 означает адресацию
    /// относительно RIP.
    fn readMemory(cursor: *Cursor, mod: u2, rm: u3) ?Memory {
        var memory: Memory = .{};

        if (rm == 4) {
            if (!cursor.readSib(mod, &memory)) return null;
        } else if (rm == 5 and mod == 0) {
            // Смещение считается от конца инструкции, а её длина станет
            // известна позже. Пока помечаем адрес нулём, декодер поправит.
            memory.rip_target = 0;
            memory.displacement = cursor.readSigned(4) orelse return null;
            return memory;
        } else {
            memory.base = cursor.register(@as(u4, rm) | cursor.prefixes.rexB(), .qword);
        }

        memory.displacement += switch (mod) {
            1 => cursor.readSigned(1) orelse return null,
            2 => cursor.readSigned(4) orelse return null,
            else => 0,
        };
        return memory;
    }

    /// Байт SIB: масштаб, индекс и база. Индекс 4 без REX.X означает,
    /// что индекса нет: номер rsp в этом поле занят под такой признак.
    fn readSib(cursor: *Cursor, mod: u2, memory: *Memory) bool {
        const sib = cursor.next() orelse return false;
        const scale_bits: u2 = @truncate(sib >> 6);
        const index: u3 = @truncate(sib >> 3);
        const base: u3 = @truncate(sib);

        const index_full: u4 = @as(u4, index) | cursor.prefixes.rexX();
        if (index_full != 4) {
            memory.index = cursor.register(index_full, .qword);
            memory.scale = @as(u8, 1) << scale_bits;
        }

        // База 5 при mod == 0 означает, что базы нет, а дальше идёт disp32.
        if (base == 5 and mod == 0) {
            memory.displacement = cursor.readSigned(4) orelse return false;
            return true;
        }
        memory.base = cursor.register(@as(u4, base) | cursor.prefixes.rexB(), .qword);
        return true;
    }

    /// Читает знаковое число из указанного числа байтов.
    pub fn readSigned(cursor: *Cursor, comptime size: usize) ?i64 {
        if (cursor.pos + size > cursor.bytes.len) return null;
        const slice = cursor.bytes[cursor.pos..][0..size];
        cursor.pos += size;
        return switch (size) {
            1 => std.mem.readInt(i8, slice, .little),
            2 => std.mem.readInt(i16, slice, .little),
            4 => std.mem.readInt(i32, slice, .little),
            8 => std.mem.readInt(i64, slice, .little),
            else => @compileError("непонятная ширина непосредственного значения"),
        };
    }

    /// Непосредственное значение по ширине операнда. Для 64 бит в машинном
    /// коде лежит 32 бита, которые расширяются знаком: восьмибайтная
    /// константа бывает только у movabs.
    pub fn readImmediate(cursor: *Cursor, size: Size) ?i64 {
        return switch (size) {
            .byte => cursor.readSigned(1),
            .word => cursor.readSigned(2),
            .dword, .qword => cursor.readSigned(4),
            .xmm => null,
        };
    }
};

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

  1. readModRm начинается с трёх сдвигов: byte >> 6 даёт mod, byte >> 3 даёт reg, сам байт даёт rm. Тип @truncate до u2 и u3 отрезает лишнее, поэтому маски не нужны.
  2. Ветка mod == 3 уходит в сторону сразу: раз операнд регистр, ни SIB, ни смещения быть не может.
  3. В readMemory два особых случая стоят раньше обычного пути, и это не стиль, а необходимость: rm равное 4 и rm равное 5 при mod равном нулю не означают регистры, поэтому обычную ветку с базой они пройти не должны.
  4. В readSib условие index_full != 4 отражает то же правило про отсутствующий индекс. Заметь, что сравнивается расширенный номер: с битом REX.X номер 4 превращается в 12, то есть в настоящий %r12, и он индексом быть вполне может. Отсутствие индекса это ровно номер 4 и ровно без REX.X.
  5. Масштаб получается сдвигом: @as(u8, 1) << scale_bits превращает биты 00, 01, 10 и 11 в множители 1, 2, 4 и 8.

Заканчивается файл своими тестами, и каждый из них это одна ловушка кодирования:

test "ModRM с mod равным 3 это регистр" {
    var cursor: Cursor = .{ .bytes = &.{0xc1}, .address = 0 };
    const modrm = cursor.readModRm(.qword).?;
    try std.testing.expectEqual(@as(u4, 0), modrm.reg);
    try std.testing.expectEqualStrings("rcx", modrm.rm.register.name());
}

test "ModRM с disp8 и базой" {
    // 8b 45 f0: mod = 01, reg = rax, r/m = rbp, disp8 = -0x10.
    var cursor: Cursor = .{ .bytes = &.{ 0x45, 0xf0 }, .address = 0 };
    const modrm = cursor.readModRm(.qword).?;
    try std.testing.expectEqualStrings("rbp", modrm.rm.memory.base.?.name());
    try std.testing.expectEqual(@as(i64, -0x10), modrm.rm.memory.displacement);
}

test "SIB даёт базу, индекс и масштаб" {
    // mod = 00, r/m = 100 (SIB), SIB = scale 4, index rcx, base rax.
    var cursor: Cursor = .{ .bytes = &.{ 0x04, 0x88 }, .address = 0 };
    const modrm = cursor.readModRm(.qword).?;
    const memory = modrm.rm.memory;
    try std.testing.expectEqualStrings("rax", memory.base.?.name());
    try std.testing.expectEqualStrings("rcx", memory.index.?.name());
    try std.testing.expectEqual(@as(u8, 4), memory.scale);
}

test "индекс 4 без REX.X означает отсутствие индекса" {
    // SIB с index = 100 это признак отсутствия индекса, а не регистр rsp.
    var cursor: Cursor = .{ .bytes = &.{ 0x04, 0x24 }, .address = 0 };
    const modrm = cursor.readModRm(.qword).?;
    try std.testing.expectEqualStrings("rsp", modrm.rm.memory.base.?.name());
    try std.testing.expectEqual(@as(?Register, null), modrm.rm.memory.index);
}

test "обрезанные байты не приводят к чтению за концом" {
    var cursor: Cursor = .{ .bytes = &.{0x45}, .address = 0 };
    try std.testing.expectEqual(@as(?ModRm, null), cursor.readModRm(.qword));
}

Последний тест стоит отдельного слова. Проверка “хватает ли байтов” живёт ровно в одном месте, в методах next и readSigned, и обе возвращают null на обрезанном входе. Вся цепочка разбора собрана на orelse return null, поэтому декодер физически не может прочитать за концом буфера, даже если ему подсунули половину инструкции. Для программы, которая читает чужие файлы, это не мелочь.

Таблица опкодов и пять новых функций

Файл src/x86/decoder.zig после переезда стал таблицей и почти ничем больше. Вот его новые строки:

/// Таблица однобайтных опкодов. `null` означает, что байт не наш:
/// вызывающий превратит это в `(bad)`.
fn decodeOpcode(cursor: *Cursor, opcode: u8) ?Instruction {
    return switch (opcode) {
        // Знаковое расширение четырёх байт в восемь: movslq.
        0x63 => extend(cursor, "movsl", .dword, .qword),

        // Однобайтные push и pop: номер регистра сидит в трёх младших битах
        // самого опкода, а REX.B добавляет к нему четвёртый бит.
        0x50...0x57 => pushPop(cursor, "push", opcode - 0x50),
        0x58...0x5f => pushPop(cursor, "pop", opcode - 0x58),

        // Семейство mov между регистром и памятью.
        0x88 => moveRegisterToRm(cursor, .byte),
        0x89 => moveRegisterToRm(cursor, cursor.width()),
        0x8a => moveRmToRegister(cursor, .byte),
        0x8b => moveRmToRegister(cursor, cursor.width()),

        // mov с непосредственным значением прямо в регистр из опкода.
        0xb0...0xb7 => moveImmediateToRegister(cursor, opcode - 0xb0, .byte),
        0xb8...0xbf => moveImmediateToRegister(cursor, opcode - 0xb8, cursor.width()),

        // mov с непосредственным значением в r/m: группа с одной операцией.
        0xc6 => moveImmediateToRm(cursor, .byte),
        0xc7 => moveImmediateToRm(cursor, cursor.width()),

        0x90 => cursor.done("nop", null),
        0xc3 => cursor.done("ret", 'q'),
        0xc9 => cursor.done("leave", null),
        0xf4 => cursor.done("hlt", null),

        0x0f => decodeTwoByte(cursor),
        else => null,
    };
}

/// Опкоды с ведущим байтом 0x0F.
fn decodeTwoByte(cursor: *Cursor) ?Instruction {
    const opcode = cursor.next() orelse return null;
    return switch (opcode) {
        // Расширение узкого значения до широкого: нулями и знаком.
        0xb6 => extend(cursor, "movzb", .byte, cursor.width()),
        0xb7 => extend(cursor, "movzw", .word, cursor.width()),
        0xbe => extend(cursor, "movsb", .byte, cursor.width()),
        0xbf => extend(cursor, "movsw", .word, cursor.width()),

        else => null,
    };
}

/// Опкод формы MR: поле reg это источник, r/m это приёмник.
/// В AT&T источник печатается первым, так что порядок совпадает.
fn moveRegisterToRm(cursor: *Cursor, size: Size) ?Instruction {
    const modrm = cursor.readModRm(size) orelse return null;
    var result = cursor.done("mov", size.suffix());
    result.operands[0] = .{ .register = cursor.register(modrm.reg, size) };
    result.operands[1] = modrm.rm;
    result.operand_count = 2;
    return result;
}

/// Опкод формы RM: r/m это источник, поле reg это приёмник.
fn moveRmToRegister(cursor: *Cursor, size: Size) ?Instruction {
    const modrm = cursor.readModRm(size) orelse return null;
    var result = cursor.done("mov", size.suffix());
    result.operands[0] = modrm.rm;
    result.operands[1] = .{ .register = cursor.register(modrm.reg, size) };
    result.operand_count = 2;
    return result;
}

/// Опкоды 0xB0 и 0xB8: номер регистра в опкоде, значение сразу за ним.
/// При REX.W значение занимает все восемь байтов, и это единственный
/// способ положить в регистр произвольную 64-битную константу.
fn moveImmediateToRegister(cursor: *Cursor, low_bits: u8, size: Size) ?Instruction {
    const index: u4 = @as(u4, @truncate(low_bits)) | cursor.prefixes.rexB();
    const wide = size == .qword;
    const value = (if (wide) cursor.readSigned(8) else cursor.readImmediate(size)) orelse return null;

    var result = cursor.done(if (wide) "movabs" else "mov", size.suffix());
    result.operands[0] = .{ .immediate = value };
    result.operands[1] = .{ .register = cursor.register(index, size) };
    result.operand_count = 2;
    return result;
}

/// Опкоды 0xC6 и 0xC7: непосредственное значение в r/m.
/// Поле reg байта ModRM здесь обязано быть нулём.
fn moveImmediateToRm(cursor: *Cursor, size: Size) ?Instruction {
    const modrm = cursor.readModRm(size) orelse return null;
    if (modrm.digit != 0) return null;
    const value = cursor.readImmediate(size) orelse return null;

    var result = cursor.done("mov", size.suffix());
    result.operands[0] = .{ .immediate = value };
    result.operands[1] = modrm.rm;
    result.operand_count = 2;
    return result;
}

/// Расширение узкого значения до широкого. Мнемоника несёт обе ширины сразу:
/// movzbl это расширение нулями из байта в четыре байта.
fn extend(cursor: *Cursor, mnemonic: []const u8, from: Size, to: Size) ?Instruction {
    const modrm = cursor.readModRm(from) orelse return null;
    var result = cursor.done(mnemonic, to.suffix());
    result.operands[0] = modrm.rm;
    result.operands[1] = .{ .register = cursor.register(modrm.reg, to) };
    result.operand_count = 2;
    return result;
}

Сверь эту таблицу с тем, что разобрали выше по байтам, и всё сойдётся.

  • Опкоды 0x88 и 0x89 это форма “источник в поле reg”, а 0x8a и 0x8b это “приёмник в поле reg”. Именно поэтому 48 89 37 оказалось записью в память, а 48 8b 07 чтением из неё: разница в одном бите опкода.
  • Младший бит опкода выбирает ширину: чётный байт значит один байт данных, нечётный значит ширину по префиксам. Отсюда пары 0x88 и 0x89, 0x8a и 0x8b, 0xc6 и 0xc7.
  • В moveImmediateToRm стоит проверка modrm.digit != 0. Это тот самый случай, когда поле reg не регистр, а продолжение опкода: у 0xc7 разрешено только значение ноль, всё прочее это другая инструкция или мусор. Мы не имеем права молча напечатать mov, если там была единица.
  • В moveImmediateToRegister ветка wide читает восемь байт вместо четырёх и меняет мнемонику на movabs. Один опкод 0xb8, а инструкции получаются разные, и различает их только префикс REX.W.
  • Функция extend берёт две ширины сразу: откуда и куда. Из них собирается имя, поэтому movzb плюс суффикс от ширины приёмника даёт movzbl или movzbq.

Фикстура и тесты шага

Фикстура собирается из настоящего ассемблера через asm volatile в функции с соглашением naked, чтобы компилятор не добавил ни пролога, ни эпилога и в секции лежали ровно те инструкции, которые мы написали:

//! Фикстура шага 10: ModRM, SIB, смещения и семейство mov.
//!
//! Собирается так:
//!   zig build-obj fixtures/step_10.zig -O ReleaseFast \
//!       -target x86_64-linux-musl -femit-bin=fixtures/step_10.o

/// Все способы записать адрес, которые даёт пара ModRM и SIB,
/// плюс mov во всех четырёх ширинах и оба расширения узкого в широкое.
export fn ztAddressing() callconv(.naked) void {
    asm volatile (
        \\movq %rdi, %rax
        \\movl %esi, %ecx
        \\movw %si, %ax
        \\movb %dil, %al
        \\movb %ah, %bl
        \\movq (%rax), %rbx
        \\movq 8(%rax), %rbx
        \\movq -16(%rbp), %rbx
        \\movq 0x1234(%rax), %rbx
        \\movq (%rax,%rcx), %rbx
        \\movq (%rax,%rcx,2), %rbx
        \\movq 8(%rax,%rcx,4), %rbx
        \\movq 0x1234(%rax,%rcx,8), %rbx
        \\movq (,%rcx,8), %rbx
        \\movq (%rsp), %rbx
        \\movq 8(%rsp), %rbx
        \\movq (%r12), %rbx
        \\movq (%r13), %rbx
        \\movq (%r8,%r9,4), %r10
        \\movq 0x10(%rip), %rbx
        \\movq %rax, 24(%rsp)
        \\movl %eax, (%rdx,%rsi,4)
        \\movl $1, %eax
        \\movl $0x1234, %eax
        \\movq $-1, %rax
        \\movb $7, %cl
        \\movabsq $0x1122334455667788, %rax
        \\movl $0x2a, 8(%rsp)
        \\movb $5, (%rdi)
        \\movzbl %al, %ecx
        \\movzwl %ax, %ecx
        \\movzbq %al, %rcx
        \\movzwq %ax, %rcx
        \\movsbl %al, %ecx
        \\movswl %ax, %ecx
        \\movsbq %al, %rcx
        \\movswq %ax, %rcx
        \\movslq %eax, %rcx
        \\movslq (%rsp), %rcx
        \\ret
    );
}

Приём тот же, что и на прошлом шаге: пишем ассемблер, собираем объектник, снимаем эталонный objdump -d, и тест сверяет вывод zt с этим эталоном построчно. Заодно фикстура служит списком того, что шаг обязан уметь.

Тесты шага проверяют и общий прогон по фикстуре, и каждую ловушку отдельно:

test "формы адреса от простой к самой полной" {
    try expectOne(&.{ 0x48, 0x8b, 0x18 }, "movq\t(%rax), %rbx");
    try expectOne(&.{ 0x48, 0x8b, 0x58, 0x08 }, "movq\t0x8(%rax), %rbx");
    try expectOne(&.{ 0x48, 0x8b, 0x5d, 0xf0 }, "movq\t-0x10(%rbp), %rbx");
    try expectOne(&.{ 0x48, 0x8b, 0x98, 0x34, 0x12, 0x00, 0x00 }, "movq\t0x1234(%rax), %rbx");
    try expectOne(&.{ 0x48, 0x8b, 0x1c, 0x08 }, "movq\t(%rax,%rcx), %rbx");
    try expectOne(&.{ 0x48, 0x8b, 0x1c, 0x48 }, "movq\t(%rax,%rcx,2), %rbx");
    try expectOne(&.{ 0x48, 0x8b, 0x5c, 0x88, 0x08 }, "movq\t0x8(%rax,%rcx,4), %rbx");
}

test "rsp в поле r/m требует байта SIB, а r13 требует смещения" {
    // Номер rsp в поле r/m занят под признак того, что дальше идёт SIB.
    try expectOne(&.{ 0x48, 0x8b, 0x1c, 0x24 }, "movq\t(%rsp), %rbx");
    // Номер rbp при mod равном нулю занят под адресацию от RIP,
    // поэтому для rbp и r13 компилятор пишет нулевое смещение явно.
    try expectOne(&.{ 0x49, 0x8b, 0x5d, 0x00 }, "movq\t(%r13), %rbx");
    try expectOne(&.{ 0x49, 0x8b, 0x1c, 0x24 }, "movq\t(%r12), %rbx");
}

test "REX.R, REX.X и REX.B расширяют три разных поля" {
    // 4f это REX с сразу тремя единицами: reg, index и base из старшей восьмёрки.
    try expectOne(&.{ 0x4f, 0x8b, 0x14, 0x88 }, "movq\t(%r8,%r9,4), %r10");
}

test "movabs единственный способ положить в регистр восемь байтов сразу" {
    const bytes = [_]u8{ 0x48, 0xb8, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11 };
    try expectOne(&bytes, "movabsq\t$0x1122334455667788, %rax");
    try std.testing.expectEqual(@as(usize, 10), decode(&bytes, 0).length());
}

test "обрезанная инструкция не читает за концом буфера" {
    // ModRM обещает disp32, но байтов нет: декодер обязан сказать bad.
    try std.testing.expect(decode(&.{ 0x48, 0x8b, 0x98, 0x34 }, 0).bad);
    try std.testing.expect(decode(&.{ 0x48, 0xb8, 0x00 }, 0).bad);
}

Прогон

Тесты шага и всех предыдущих:

$ zig build test --summary all
Build Summary: 11/11 steps succeeded; 53/53 tests passed
test success
+- run test 17 pass (17 total) 1s MaxRSS:2M     тесты внутри модулей
+- run test 5 pass (5 total) 663ms MaxRSS:2M    шаг 06
+- run test 7 pass (7 total) 357ms MaxRSS:2M    шаг 07
+- run test 10 pass (10 total) 1s MaxRSS:2M     шаг 09
+- run test 14 pass (14 total) 968ms MaxRSS:2M  шаг 10

И сам дизассемблер на фикстуре:

$ zig build && ./zig-out/bin/zt disasm fixtures/step_10.o
       0: 48 89 f8                     	movq	%rdi, %rax
       3: 89 f1                        	movl	%esi, %ecx
       5: 66 89 f0                     	movw	%si, %ax
       8: 40 88 f8                     	movb	%dil, %al
       b: 88 e3                        	movb	%ah, %bl
       d: 48 8b 18                     	movq	(%rax), %rbx
      10: 48 8b 58 08                  	movq	0x8(%rax), %rbx
      14: 48 8b 5d f0                  	movq	-0x10(%rbp), %rbx
      18: 48 8b 98 34 12 00 00         	movq	0x1234(%rax), %rbx
      1f: 48 8b 1c 08                  	movq	(%rax,%rcx), %rbx
...
      49: 49 8b 5d 00                  	movq	(%r13), %rbx
      4d: 4f 8b 14 88                  	movq	(%r8,%r9,4), %r10
      51: 48 8b 1d 10 00 00 00         	movq	0x10(%rip), %rbx  # 0x68
      58: 48 89 44 24 18               	movq	%rax, 0x18(%rsp)
      5d: 89 04 b2                     	movl	%eax, (%rdx,%rsi,4)
      60: b8 01 00 00 00               	movl	$0x1, %eax
      65: b8 34 12 00 00               	movl	$0x1234, %eax
      6a: 48 c7 c0 ff ff ff ff         	movq	$-0x1, %rax

Сравнение с эталоном показывает, где zt пока отличается от objdump:

$ objdump -d fixtures/step_10.o | tail -n +6 > od10.txt
$ ./zig-out/bin/zt disasm fixtures/step_10.o > zt10.txt
$ diff od10.txt zt10.txt
1d0
< 0000000000000000 <ztAddressing>:
21c20
<       51: 48 8b 1d 10 00 00 00     	movq	0x10(%rip), %rbx   # 0x68 <ztAddressing+0x68>
---
>       51: 48 8b 1d 10 00 00 00     	movq	0x10(%rip), %rbx  # 0x68

Расходятся ровно две вещи, и обе ожидаемы. Заголовок с именем функции требует таблицы символов, а её zt научится читать вместе с ELF гораздо позже. Комментарий у относительной адресации objdump дополняет именем ближайшего символа, а zt печатает только посчитанный адрес, и это ровно тот адрес, который мы с тобой считали вручную несколькими разделами выше.

Обрати внимание на строку 51. Всё, что происходит в fixupRip, нужно именно из-за неё: смещение отсчитывается от конца инструкции, а длина инструкции становится известна только после того, как прочитано непосредственное значение. Поэтому декодер сначала кладёт в операнд заглушку, дочитывает инструкцию до конца, и только последним действием подставляет настоящий адрес.

Практика

Задача даёт тебе два листинга и просит восстановить обе функции. Первая это decode1 из разбора выше, только теперь без подсказок: смотри на порядок чтений и записей и решай сам. Вторая интереснее, там четыре указателя разной ширины, а в листинге встречаются movslq, movswl и movsbl, то есть придётся вспомнить, какой суффикс что означает и куда какое значение переливается. Оба листинга сняты с настоящих Zig-функций тем же способом, что и все листинги урока, так что задача имеет ровно одно правильное поведение.

Тесты гоняют твои функции на нескольких наборах значений, включая границы узких типов и случай, когда все три ячейки одинаковые. Ничего печатать не нужно.

Упражнения

Итоги

  • Спецификатор операнда бывает трёх видов: непосредственное значение, регистр, память. Девять форм обращения к памяти это одна формула Imm + R[rb] + R[ri] * s с выброшенными частями.
  • Знак доллара отличает константу от адреса: $0x108 это число, 0x108 это содержимое ячейки.
  • Масштаб применяется только к индексному регистру, бывает 1, 2, 4 или 8, и кодируется двумя битами в байте SIB.
  • У mov пять форм и нет шестой: перенос из памяти в память это всегда две инструкции с регистром посередине.
  • Непосредственное значение в память кладётся только как четыре байта со знаковым расширением. Для полных восьми байт есть movabsq, и она умеет писать лишь в регистр.
  • Суффикс mov выбирает ширину: b, w, l, q. У байтовой формы префикс REX появляется даже пустым, чтобы имена вроде %sil означали младший байт, а не старший байт другого регистра.
  • Любая запись в 32-битный регистр обнуляет старшую половину 64-битного. Поэтому movzbq и movzlq не существуют, а movslq существует и нужна.
  • pushq это subq $8, %rsp плюс movq в один байт вместо восьми, popq это movq плюс addq $8, %rsp. Компилятор ставит их в прологе и эпилоге, чтобы сохранить callee-saved регистры.
  • Байт ModRM разбит на поля mod, reg и rm: mod задаёт наличие и размер смещения, rm задаёт базу, а значение 11 в mod означает работу без памяти.
  • Значение 100 в поле rm означает не %rsp, а наличие байта SIB. Значение 101 в rm при mod равном 00 означает адресацию относительно счётчика инструкций, поэтому (%rbp) и (%r13) кодируются с лишним нулевым смещением.
  • Биты REX добавляют старший разряд к номерам регистров: R расширяет reg, B расширяет rm и базу SIB, X расширяет индекс SIB.
  • Чтобы восстановить исходник по листингу, читай порядок обращений к памяти: все чтения до первой записи означают, что в исходнике сначала были объявлены переменные, а потом шли присваивания.

Дальше

Теперь ты знаешь, как инструкция называет свои данные, и умеешь разбирать её байты вручную. Следующий шаг это глаголы вместо существительных: арифметика. Разберём унарные и бинарные операции, сдвиги, умножение на 128 бит через пару регистров и деление, которое требует отдельного ритуала. Отдельно посмотрим на инструкцию lea, которая формально считает адрес, а на деле служит компилятору калькулятором: ту самую формулу Imm + R[rb] + R[ri] * s из этого урока он приспособил считать обычную арифметику, и умножение на константу часто превращается в пару инструкций без единого imul.

домашка

Домашка