Машинный уровень: ассемблер и дизассемблер
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Машинный уровень: ассемблер и дизассемблер
Нулевая цена абстракций это не лозунг, а проверяемое утверждение: открываем дизассемблер и смотрим, во что на самом деле компилируется Rust. Сегодня учимся читать листинги: регистры, флаги, соглашение о вызовах, стековые кадры, и во что превращаются цикл,
matchи итераторная цепочка.
Идея
В уроке про итераторы остался незакрытый спор: что быстрее, цепочка итераторов или ручной цикл с индексами. Аргументы вида «итераторы это лишние вызовы функций» против «компилятор всё соптимизирует» можно гонять по кругу бесконечно, потому что обе стороны спорят о вере. Закрывается такой спор одним способом: посмотреть глазами, во что обе версии компилируются.
pub fn sum_iter(v: &[i32]) -> i32 {
v.iter().sum()
}
pub fn sum_index(v: &[i32]) -> i32 {
let mut total = 0;
for i in 0..v.len() {
total += v[i];
}
total
}
Спойлер: к концу урока ты вставишь обе функции в godbolt и увидишь, что листинги совпадают с точностью до имён меток. Вызовы next, замыкания и проверка границ среза исчезли без следа. Это и называется нулевой ценой абстракций, и теперь у тебя будет инструмент, чтобы проверять такие утверждения самому, а не верить на слово.
Цель урока не научиться писать на ассемблере: руками его сегодня пишут авторы криптопримитивов, кодеков и ядер ОС. Цель научиться его читать. Чтение дизассемблера превращает разговоры о производительности из веры в наблюдение: инлайн виден, лишняя проверка видна, исчезнувшая после оптимизации работа тоже видна. А ещё это фундамент на вырост: дальше в разделе мы напишем эмулятор процессора, и понимать, что именно эмулируешь, придётся на этом уровне.
Из чего сделан машинный код
Процессор не знает ни про Rust, ни про функции, ни про типы. Он исполняет машинный код: поток байтов, в котором закодированы инструкции. Вот четыре байта из памяти и их смысл:
48 0f af c7 это imul rax, rdi
Слева то, что физически лежит в памяти и что процессор читает. Справа те же байты в человеческой записи: мнемоника imul и два операнда. Ассемблер это не отдельный язык со своей семантикой, а текстовая нотация для машинного кода, почти один к одному. Программа-ассемблер переводит текст в байты, дизассемблер переводит байты обратно в текст.
Rust попадает в эту картину так: rustc разбирает исходник и строит промежуточное представление, передаёт его LLVM, а LLVM после всех оптимизаций печатает машинный код под целевую архитектуру. Когда ты просишь компилятор показать ассемблер, он отдаёт финальный текст ровно перед превращением в байты.
Последняя вводная: для одних и тех же инструкций есть две текстовые записи. Синтаксис Intel пишет приёмник первым: mov rax, 5 значит «положи 5 в rax». Синтаксис AT&T пишет наоборот и обвешивает операнды сигилами: movq $5, %rax. godbolt по умолчанию показывает Intel, классическая литература вроде CS:APP использует AT&T. Мы везде читаем Intel: меньше шума, а порядок операндов как в присваивании.
Минимум x86-64
В x86-64 сотни инструкций, но для чтения листингов хватает небольшого словарного запаса. Сначала про то, где лежат данные.
Регистров общего назначения шестнадцать, каждый по 64 бита, и у каждого за десятилетия сложилась роль:
| Регистр | Роль по соглашению |
|---|---|
rax | результат функции, аккумулятор |
rdi, rsi, rdx, rcx, r8, r9 | первые шесть целочисленных аргументов |
rsp | вершина стека |
rbp | база текущего стекового кадра |
rbx, r10 и далее до r15 | общего назначения |
К каждому регистру есть окна меньшей ширины: eax это младшие 32 бита rax, ax младшие 16, al младший байт. Важная деталь: запись в 32-битное окно обнуляет старшую половину, поэтому xor eax, eax обнуляет весь rax, это стандартная идиома «положи ноль». Отдельно живут rip, адрес текущей инструкции, и регистр флагов, о нём через минуту.
Теперь словарь инструкций, которого хватает на девяносто процентов листингов:
| Группа | Мнемоники |
|---|---|
| пересылка | mov, lea, push, pop |
| арифметика и биты | add, sub, imul, neg, xor, and, or, shl, shr |
| сравнение | cmp, test |
| переходы | jmp, je, jne, jl, jg, jb, ja |
| вызовы | call, ret |
Операнд это регистр, число-литерал или ячейка памяти. Память записывается в квадратных скобках, и внутри допустима арифметика вида база плюс индекс с масштабом плюс смещение:
mov rax, qword ptr [rdi] ; прочитать 8 байт по адресу из rdi
mov eax, dword ptr [rdi + 4*rcx] ; элемент массива: база + индекс * размер
lea rax, [rdi + rdi + 1] ; lea считает адрес, но в память не лезет
Вторая строка должна выглядеть знакомо: это в точности v[i] для массива четырёхбайтовых элементов, один доступ к памяти со встроенным умножением индекса. Приставка перед скобками уточняет ширину: byte ptr один байт, word ptr два, dword ptr четыре, qword ptr восемь.
И флаги. cmp a, b вычитает b из a, выбрасывает сам результат и оставляет только сводку о нём в регистре флагов: ZF результат ноль, SF результат отрицательный, CF был заём, OF знаковое переполнение. CF и OF это те же перенос и переполнение из урока про целые, просто теперь видно, где они живут физически. Условные переходы читают флаги: je прыгает при ZF=1, jl по знаковому «меньше», jb по беззнаковому. Пара из cmp и условного перехода это машинная форма любого if и любого условия цикла.
Инструменты: godbolt и cargo-show-asm
Compiler Explorer, он же godbolt, главный инструмент урока: слева Rust, справа живой дизассемблер, обновляется на каждое нажатие. Три правила, чтобы он показывал правду:
- Функция должна быть
pub: иначе компилятор имеет право выкинуть её как мёртвый код, и смотреть будет не на что. - В опциях компилятора укажи
-C opt-level=3: без этого ты смотришь debug-сборку. - Наводи курсор на строку Rust: godbolt подсветит соответствующие инструкции цветом. После оптимизаций сопоставление приблизительное, но направление показывает.
Проверим на функции из одной строки:
pub fn double_plus_one(x: u64) -> u64 {
x * 2 + 1
}
double_plus_one:
lea rax, [rdi + rdi + 1]
ret
Аргумент пришёл в rdi, результат должен уехать в rax, так велит соглашение о вызовах, до которого мы дойдём через раздел. А вся математика свернулась в одну инструкцию lea: она задумывалась как вычислитель адресов, но адрес это просто число, и компилятор пользуется ей как бесплатным сложением с умножением. Ни умножения, ни сложения в листинге нет. Это первый практический урок чтения: компилятор не переводит код построчно, он переписывает его.
Для сравнения переключи opt-level на 0: та же функция раздуется до десятка инструкций, которые честно кладут x на стек, читают обратно, умножают и снова кладут. Debug-сборка хранит каждую переменную в памяти, чтобы отладчик мог её показать. Поэтому замеры производительности и чтение листингов имеют смысл только в release.
Локально то же самое умеет cargo-show-asm: команда cargo asm --lib mycrate::double_plus_one печатает листинг конкретной функции твоего крейта, со всеми зависимостями и фичами, как в настоящей сборке.
Соглашение о вызовах и стековый кадр
Откуда в прошлом разделе взялись rdi и rax: это соглашение о вызовах. По System V AMD64 первые шесть целочисленных аргументов передаются в rdi, rsi, rdx, rcx, r8, r9, результат возвращается в rax, остальное при необходимости через стек. Rust для внутренних функций не обязан следовать этому соглашению и иногда отступает ради скорости, но картина в godbolt почти всегда совпадает с System V, а на границе extern "C" совпадает обязательно.
Вторая половина механики вызова это стек. Он растёт вниз, от старших адресов к младшим, и rsp всегда указывает на вершину. push сдвигает rsp на 8 и кладёт значение, pop читает и сдвигает обратно. call кладёт на стек адрес следующей инструкции и прыгает на функцию; ret снимает этот адрес и прыгает по нему. Между прологом и эпилогом функция владеет куском стека, своим кадром: там лежат сохранённый rbp вызывающего, локальные переменные и всё, что не поместилось в регистры.
Посмотри на это в движении:
#[inline(never)]
fn square(x: i64) -> i64 {
x * x
}
fn main() {
let n = 5;
let s = square(n);
}
Листинг в виджете соответствует сборке без оптимизаций, где структура кадра видна целиком: пролог из push rbp и mov rbp, rsp, локальные по смещениям от rbp, эпилог зеркально. Release-сборка обычно обходится без rbp и адресуется прямо от rsp, а такие маленькие функции вообще растворяет: атрибут #[inline(never)] стоит в коде именно затем, чтобы square пережила оптимизатор.
«Растворяет» это и есть инлайн: вместо call square компилятор подставляет тело функции прямо в вызывающий код. Исчезает вся обвязка: call, пролог, эпилог, ret, прогулка аргументов и результата через регистры. Подставленное тело оптимизируется дальше вместе с окружением: square(5) превратится в константу 25 ещё на этапе компиляции. Поэтому маленькие функции в Rust бесплатны: геттер, тонкая обёртка, next итератора после инлайна стоят ровно ноль. Распознать инлайн в листинге просто: инструкции call нет, а при агрессивном инлайне исчезает и сам символ функции.
Цикл, сравнение и флаги
Теперь конструкция, в которой сходятся флаги и переходы. В исходнике цикл, а в машине цикла нет: есть метка, сравнение и два прыжка.
pub fn sum_to_three() -> u64 {
let mut sum = 0;
let mut i = 0;
while i != 3 {
sum += i;
i += 1;
}
sum
}
Обрати внимание на разделение труда: cmp только выставляет флаги, je только читает их. Всё условие while i != 3 свелось к одному биту ZF, а слова while в машине не существует.
И маленькое признание: настоящий rustc с -C opt-level=3 этот цикл вообще не оставит. LLVM распознаёт суммирование прогрессии и заменяет цикл готовой формулой, а здесь, где границы известны на этапе компиляции, просто вернёт константу: mov eax, 3 и ret. Проверь в godbolt. Циклы, которые пережили оптимизатор, часто выглядят страшнее учебного: компилятор разворачивает их по несколько итераций за прыжок и векторизует, обрабатывая по 4 или 8 элементов одной инструкцией SIMD. Читать такое построчно не нужно: достаточно узнавать паттерн, широкие регистры xmm и ymm плюс маленький хвост для остатка, и понимать, что это твой цикл после развёртки.
Во что компилируются конструкции
if без прыжков
Не каждый if становится переходом. Когда обе ветки просто выбирают значение, компилятор предпочитает условную пересылку:
pub fn max(a: i64, b: i64) -> i64 {
if a > b { a } else { b }
}
max:
cmp rdi, rsi
cmovg rsi, rdi ; если a больше, скопировать a поверх b
mov rax, rsi
ret
cmov копирует значение только при выполнении условия по флагам и никуда не прыгает. Зачем избегать прыжков: процессор исполняет инструкции конвейером и угадывает направление переходов заранее, это работа предсказателя переходов. Предсказуемый переход почти бесплатен, непредсказуемый стоит сброса конвейера. cmov убирает саму возможность промаха, поэтому для выбора из двух значений он обычно выгоднее ветвления.
match: дерево или таблица
match на пару вариантов компилируется в цепочку cmp и переходов, дерево сравнений. Когда вариантов много и значения идут плотно, компилятор строит таблицу переходов: значение становится индексом в массиве адресов, и выбор ветки занимает один косвенный прыжок хоть на 3 варианта, хоть на 300. А если все ветки возвращают константы, код превращается в данные: компилятор складывает константы в массив, и весь match становится одним чтением из памяти.
pub fn note_name(n: u8) -> char {
match n % 7 {
0 => 'C', 1 => 'D', 2 => 'E', 3 => 'F',
4 => 'G', 5 => 'A', _ => 'B',
}
}
Вставь в godbolt: вместо семи сравнений увидишь немного арифметики и одно чтение из таблицы данных. Для enum ничего не меняется: match читает дискриминант, число, на которое мы внимательно посмотрим в следующем уроке, и дальше работает та же механика. И заметь родство с уроком про трейт-объекты: динамическая диспетчеризация это тоже косвенный прыжок по адресу из таблицы, только таблица называется vtable и привязана к типу, а не к значению.
Проверка границ и где она исчезает
Цена безопасности Rust в листинге выглядит так:
pub fn get(v: &[i32], i: usize) -> i32 {
v[i]
}
get:
cmp rdx, rsi ; i против длины среза
jae .panic ; беззнаковое больше-или-равно
mov eax, dword ptr [rdi + 4*rdx] ; само чтение
ret
.panic:
call panic_bounds_check ; имя упрощено
Тут видно сразу два факта. Срез приехал в функцию в двух регистрах: в rdi указатель на данные, в rsi длина. Вот что такое «указатель плюс длина» из урока про срезы на уровне машины. И перед чтением стоит пара из cmp и jae: одно сравнение и один переход, который в здоровой программе никогда не берётся.
Это дёшево, но в горячем цикле бывает заметно: проверка на каждой итерации мешает векторизации. Хорошая новость: компилятор убирает проверку всюду, где может доказать, что индекс в границах. Итератор несёт это знание по построению, поэтому sum_iter из начала урока компилируется без единой проверки. В sum_index LLVM тоже справился: видит for i in 0..v.len() и доказывает безопасность v[i]. Сломать доказательство легко, достаточно чуть запутать связь индекса с длиной. Тогда выручают либо итераторы, либо подсказка вида assert!(n <= v.len()) перед циклом: одна проверка снаружи вместо проверки на каждой итерации.
Это и есть финальная точка спора про итераторы. Цепочка не просто не медленнее ручного цикла: она систематически даёт компилятору больше фактов для доказательств. Удобный высокоуровневый код и быстрый машинный код в Rust обычно один и тот же код, и теперь ты умеешь это проверять.
Стратегия чтения длинного листинга
Реальный листинг на пару сотен строк не читают подряд. Рабочий порядок такой. Сначала найди все call: это границы того, что не заинлайнилось, и среди имён сразу видны паники, аллокации и дорогие вызовы. Потом найди метки, на которые прыгают снизу вверх: это циклы, самые горячие места программы. Внутри циклов смотри на доступы к памяти и на регистры xmm и ymm: есть векторизация или нет. Построчное чтение оставь для коротких функций и мест, где уже нашлось что-то подозрительное.
ДЗ
Дальше
Сегодня мы смотрели на код: инструкции, регистры, переходы. Следующий урок про вторую половину машинного мира, про данные: как структуры и enum раскладываются по байтам, что такое выравнивание и padding, зачем нужны repr(C) и repr(packed) и почему Option<Box<T>> весит ровно столько же, сколько Box<T>.