78 KiB
Методичка по языку ассемблера x86-64
Семинар 2: Функции, стек, конечные автоматы
Оглавление
- Механизм работы стека
- Определение и вызов функций
- Функции с аргументами и возвращаемые значения
- Соглашение вызова (Calling Convention)
- Использование стека для локальных переменных
- Фрейм стека и выравнивание
- Конечные автоматы (Finite State Machines)
- Автоматы и регулярные выражения
- Кодирование автоматов на ассемблере
- Типичные ошибки
- Практические задания
- Справочник: часто используемые конструкции
1. Механизм работы стека
Что такое стек?
Стек (stack) — это область памяти, организованная по принципу LIFO (Last In, First Out: последним пришёл — первым ушёл). Стек используется для:
- Сохранения адресов возврата при вызовах функций
- Хранения локальных переменных функций
- Временного сохранения значений регистров
- Передачи аргументов функций (свыше 6 аргументов)
Регистр RSP (Stack Pointer)
RSP (Stack Pointer) — регистр, указывающий на вершину стека (самый последний добавленный элемент).
Важно: В x86-64 Linux стек растёт вниз (к меньшим адресам):
Высокие адреса (0x7FFFFFFFFFFF)
↑
|
[Пустое пространство]
|
↓ ← RSP указывает сюда (вершина стека)
[Последний добавленный элемент]
[Предыдущий элемент]
[Ещё более ранний элемент]
|
↓
Низкие адреса
Инструкции работы со стеком
PUSH — положить на стек
push rax
Эквивалентно:
sub rsp, 8 ; Сдвинуть вершину стека на 8 байт вниз
mov [rsp], rax ; Записать значение rax по адресу вершины
POP — снять со стека
pop rax
Эквивалентно:
mov rax, [rsp] ; Прочитать значение с вершины стека
add rsp, 8 ; Сдвинуть вершину стека на 8 байт вверх
Визуализация работы стека
graph TD
A[Начальное состояние<br/>RSP = 0x7FFFFFFFE000]
A --> B[push rax где rax=0x42]
B --> C[RSP = 0x7FFFFFFFDFF8<br/>память 0x...DFF8: 0x42]
C --> D[push rbx где rbx=0x100]
D --> E[RSP = 0x7FFFFFFFDFF0<br/>память 0x...DFF0: 0x100<br/>память 0x...DFF8: 0x42]
E --> F[pop rcx]
F --> G[RCX = 0x100<br/>RSP = 0x7FFFFFFFDFF8]
style A fill:#334155
style C fill:#475569
style E fill:#64748b
style G fill:#475569
Пошаговый пример
Предположим, RSP = 0x7FFFFFFFE000 перед началом.
push rax ; rax = 0x1234567890ABCDEF
push rbx ; rbx = 0xAABBCCDDEEFF0011
pop rcx ; rcx = ?
pop rdx ; rdx = ?
| Шаг | Инструкция | RSP до | RSP после | Действие | rcx | rdx |
|---|---|---|---|---|---|---|
| 0 | — | 0x7FFFFFFFE000 |
— | Начало | — | — |
| 1 | push rax |
0x7FFFFFFFE000 |
0x7FFFFFFFDFF8 |
[0x...DFF8] = 0x1234567890ABCDEF |
— | — |
| 2 | push rbx |
0x7FFFFFFFDFF8 |
0x7FFFFFFFDFF0 |
[0x...DFF0] = 0xAABBCCDDEEFF0011 |
— | — |
| 3 | pop rcx |
0x7FFFFFFFDFF0 |
0x7FFFFFFFDFF8 |
rcx = [0x...DFF0] |
0xAABBCCDDEEFF0011 |
— |
| 4 | pop rdx |
0x7FFFFFFFDFF8 |
0x7FFFFFFFE000 |
rdx = [0x...DFF8] |
0xAABBCCDDEEFF0011 |
0x1234567890ABCDEF |
Результат: rcx = 0xAABBCCDDEEFF0011, rdx = 0x1234567890ABCDEF, RSP вернулся к исходному значению.
Важное правило
Стек должен быть сбалансирован: если вы добавили N элементов с помощью push, вы должны снять N элементов с помощью pop (или скорректировать rsp напрямую).
Несбалансированный стек приводит к:
- Неправильным адресам возврата (
retвернётся не туда) - Segmentation fault
- Непредсказуемому поведению программы
2. Определение и вызов функций
Что такое функция в ассемблере?
Функция (подпрограмма, subroutine) — это именованный блок инструкций, который можно вызвать из разных мест программы и который возвращает управление обратно вызывающему коду.
В ассемблере функция = метка + код + инструкция ret.
Инструкция CALL
call function_name
Действия call:
- Сохранить адрес возврата на стек:
pushадрес следующей инструкции послеcall - Перейти к функции: установить
RIP = адрес метки function_name
Эквивалентно:
push rip_next ; (это псевдокод, rip нельзя использовать напрямую)
jmp function_name
rip_next: ; адрес следующей инструкции
Инструкция RET
ret
Действия ret:
- Снять адрес возврата со стека:
popзначение вRIP - Перейти по этому адресу: продолжить выполнение
Эквивалентно:
pop rip ; (псевдокод)
Визуализация call и ret
sequenceDiagram
participant C as Вызывающий код
participant S as Стек
participant F as Функция
C->>C: call my_func (адрес 0x401010)
C->>S: push 0x401015 (адрес возврата)
C->>F: jmp my_func (0x401080)
Note over F: Выполнение тела функции
F->>S: pop адрес (0x401015)
F->>C: jmp 0x401015
C->>C: Продолжить выполнение
Пример: простая функция
section .data
msg db "Hello from function!", 0xA
msg_len equ $ - msg
section .text
global _start
; Функция печати сообщения
print_hello:
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
mov rsi, msg
mov rdx, msg_len
syscall
ret ; Вернуться к вызывающему коду
_start:
call print_hello ; Вызов функции
call print_hello ; Вызов ещё раз
; Выход
mov rax, 60
xor rdi, rdi
syscall
Пошаговая трассировка выполнения
Предположим:
_startнаходится по адресу0x401000print_helloнаходится по адресу0x401020RSPизначально0x7FFFFFFFE000
| RIP | Инструкция | RSP до | RSP после | Стек | Комментарий |
|---|---|---|---|---|---|
0x401000 |
call print_hello |
0x7FFFFFFFE000 |
0x7FFFFFFFDFF8 |
[...FF8]=0x401005 |
Сохранить 0x401005 (адрес после call) |
0x401020 |
mov rax, 1 |
0x7FFFFFFFDFF8 |
0x7FFFFFFFDFF8 |
[...FF8]=0x401005 |
Начало функции |
| ... | (syscall и др.) | 0x7FFFFFFFDFF8 |
0x7FFFFFFFDFF8 |
[...FF8]=0x401005 |
Тело функции |
0x40102E |
ret |
0x7FFFFFFFDFF8 |
0x7FFFFFFFE000 |
— | Снять 0x401005 и прыгнуть туда |
0x401005 |
call print_hello |
0x7FFFFFFFE000 |
0x7FFFFFFFDFF8 |
[...FF8]=0x40100A |
Второй вызов |
0x401020 |
mov rax, 1 |
0x7FFFFFFFDFF8 |
0x7FFFFFFFDFF8 |
[...FF8]=0x40100A |
Снова в функции |
0x40102E |
ret |
0x7FFFFFFFDFF8 |
0x7FFFFFFFE000 |
— | Вернуться к 0x40100A |
0x40100A |
mov rax, 60 |
0x7FFFFFFFE000 |
0x7FFFFFFFE000 |
— | Продолжить _start |
CALL vs JMP
| Инструкция | Сохраняет адрес возврата? | Можно ли вернуться? | Использование |
|---|---|---|---|
call |
Да (на стек) | Да (ret) |
Вызов функций, которые должны вернуться |
jmp |
Нет | Нет | Безусловные переходы, tail calls, exit |
Пример tail call:
my_function:
; ... какая-то работа ...
jmp other_function ; Tail call: не создаём новый фрейм стека
Это эквивалентно:
my_function:
; ... какая-то работа ...
call other_function
ret
Но jmp эффективнее: не тратит место на стеке и не добавляет лишний ret.
Вложенные вызовы функций
section .text
global _start
func_c:
; Самая внутренняя функция
ret
func_b:
call func_c ; Вызвать func_c
ret ; Вернуться в func_a
func_a:
call func_b ; Вызвать func_b
ret ; Вернуться в _start
_start:
call func_a ; Вызвать func_a
; Продолжить
Стек при вложенных вызовах:
1. _start вызывает func_a:
Стек: [адрес возврата в _start]
2. func_a вызывает func_b:
Стек: [адрес возврата в func_a]
[адрес возврата в _start]
3. func_b вызывает func_c:
Стек: [адрес возврата в func_b]
[адрес возврата в func_a]
[адрес возврата в _start]
4. func_c делает ret:
Стек: [адрес возврата в func_a]
[адрес возврата в _start]
5. func_b делает ret:
Стек: [адрес возврата в _start]
6. func_a делает ret:
Стек: (пустой)
3. Функции с аргументами и возвращаемые значения
Передача аргументов через регистры
В System V ABI (Linux x86-64) первые 6 целочисленных/указательных аргументов передаются через регистры:
| Порядок аргумента | Регистр | Размер | Примечание |
|---|---|---|---|
| 1-й | rdi |
64 бита | Может использоваться edi, di, dil для меньших размеров |
| 2-й | rsi |
64 бита | — |
| 3-й | rdx |
64 бита | — |
| 4-й | rcx |
64 бита | Внимание: разрушается при syscall! |
| 5-й | r8 |
64 бита | — |
| 6-й | r9 |
64 бита | — |
Аргументы 7+ передаются через стек (справа налево).
Возвращаемое значение
| Тип результата | Регистр |
|---|---|
| Целочисленное/указатель (до 64 бит) | rax |
| Дополнительное значение (128 бит) | rdx:rax (старшие 64 бита в rdx) |
| Числа с плавающей точкой | xmm0, xmm1 |
| Структуры (маленькие) | rax, rdx |
| Структуры (большие) | Указатель передаётся как скрытый первый аргумент |
Пример: функция сложения
; Функция: сложить два числа
; Аргументы: rdi = a, rsi = b
; Возвращает: rax = a + b
add_numbers:
mov rax, rdi ; rax = a
add rax, rsi ; rax = a + b
ret
; Вызов:
_start:
mov rdi, 10 ; Первый аргумент
mov rsi, 32 ; Второй аргумент
call add_numbers ; Результат в rax
; rax теперь содержит 42
Пример: функция с 3 аргументами
; Функция: вычислить (a + b) * c
; Аргументы: rdi = a, rsi = b, rdx = c
; Возвращает: rax = (a + b) * c
calculate:
add rdi, rsi ; rdi = a + b
mov rax, rdi ; rax = a + b
imul rax, rdx ; rax = (a + b) * c
ret
; Вызов:
_start:
mov rdi, 5 ; a = 5
mov rsi, 3 ; b = 3
mov rdx, 7 ; c = 7
call calculate ; rax = (5+3)*7 = 56
Функция string_length
Вычисляет длину null-terminated строки.
; Аргумент: rdi = указатель на строку
; Возвращает: rax = длина строки (без null-terminator)
string_length:
xor rax, rax ; rax = 0 (счётчик)
.loop:
cmp byte [rdi + rax], 0 ; Проверить текущий байт
je .done ; Если 0, выйти из цикла
inc rax ; rax++
jmp .loop
.done:
ret
; Использование:
section .data
my_string db "Hello, World!", 0
section .text
_start:
mov rdi, my_string ; Передать адрес строки
call string_length ; rax = 13
Функция print_string
Выводит null-terminated строку в stdout.
; Аргумент: rdi = указатель на строку
; Возвращает: ничего (может вернуть количество байт в rax)
print_string:
push rdi ; Сохранить rdi (аргумент)
call string_length ; rax = длина строки
pop rsi ; rsi = адрес строки (восстановить из стека)
mov rdx, rax ; rdx = длина
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
syscall ; Вывести строку
ret
; Использование:
_start:
mov rdi, my_string
call print_string
Функция с несколькими возвращаемыми значениями
; Функция: разделить число на целую и дробную части (упрощённый пример)
; Аргумент: rdi = число вида XY, где X - десятки, Y - единицы
; Возвращает: rax = X (десятки), rdx = Y (единицы)
split_number:
mov rax, rdi ; rax = число
xor rdx, rdx ; rdx = 0
mov rcx, 10
div rcx ; rax = rax / 10, rdx = rax % 10
ret
; Вызов:
_start:
mov rdi, 47 ; Число 47
call split_number
; rax = 4 (десятки), rdx = 7 (единицы)
Пример: print_hex
Выводит 64-битное число в шестнадцатеричном формате.
section .data
hex_digits db '0123456789ABCDEF'
section .text
global _start
; Аргумент: rdi = число для вывода
print_hex:
mov rcx, 64 ; Количество бит
.loop:
push rax ; Сохранить rax
sub rcx, 4 ; Обработать следующие 4 бита
mov rax, rdi ; rax = число
shr rax, cl ; Сдвинуть вправо на cl бит
and rax, 0xF ; Оставить младшие 4 бита
lea rsi, [hex_digits + rax] ; rsi = адрес соответствующей цифры
mov rax, 1 ; sys_write
mov rdx, 1 ; Длина = 1 байт
push rdi
push rcx
mov rdi, 1 ; stdout
syscall
pop rcx
pop rdi
pop rax
test rcx, rcx
jnz .loop
ret
_start:
mov rdi, 0x1234ABCD ; Число для вывода
call print_hex
; Вывод переноса строки
mov rax, 1
mov rdi, 1
mov rsi, newline
mov rdx, 1
syscall
mov rax, 60
xor rdi, rdi
syscall
section .data
newline db 0xA
4. Соглашение вызова (Calling Convention)
Что такое Calling Convention?
Соглашение вызова (calling convention) — это набор правил, определяющих:
- Как передаются аргументы (регистры, стек, порядок)
- Какой регистр используется для возвращаемого значения
- Какие регистры должны сохраняться функцией (callee-saved)
- Какие регистры может свободно изменять функция (caller-saved)
- Как выравнивается стек
- Кто очищает стек после вызова
System V AMD64 ABI (Linux, BSD, macOS)
Это стандартное соглашение для Unix-подобных систем на x86-64.
Классификация регистров
graph TD
A[Регистры x86-64]
A --> B[Caller-saved<br/>Вызывающий сохраняет]
A --> C[Callee-saved<br/>Вызываемая функция сохраняет]
B --> D[rax - возвращаемое значение]
B --> E[rdi, rsi, rdx, rcx, r8, r9 - аргументы]
B --> F[r10, r11 - временные]
C --> G[rbx]
C --> H[rbp - frame pointer]
C --> I[r12, r13, r14, r15]
C --> J[rsp - stack pointer<br/>всегда сохраняется!]
style B fill:#ef4444
style C fill:#22c55e
style D fill:#fca5a5
style E fill:#fca5a5
style F fill:#fca5a5
style G fill:#86efac
style H fill:#86efac
style I fill:#86efac
style J fill:#86efac
Таблица классификации регистров
| Регистр | Тип | Назначение | Сохраняется? |
|---|---|---|---|
| rax | Caller-saved | Возвращаемое значение, syscall номер | ❌ Нет |
| rbx | Callee-saved | Общего назначения | ✅ Да |
| rcx | Caller-saved | 4-й аргумент | ❌ Нет (разрушается при syscall!) |
| rdx | Caller-saved | 3-й аргумент, 2-е возвращаемое значение | ❌ Нет |
| rsi | Caller-saved | 2-й аргумент | ❌ Нет |
| rdi | Caller-saved | 1-й аргумент | ❌ Нет |
| rbp | Callee-saved | Frame pointer (база стека) | ✅ Да |
| rsp | Специальный | Stack pointer (вершина стека) | ✅ Всегда |
| r8 | Caller-saved | 5-й аргумент | ❌ Нет |
| r9 | Caller-saved | 6-й аргумент | ❌ Нет |
| r10 | Caller-saved | Временный | ❌ Нет |
| r11 | Caller-saved | Временный | ❌ Нет (разрушается при syscall!) |
| r12-r15 | Callee-saved | Общего назначения | ✅ Да |
Что означает "Caller-saved" и "Callee-saved"?
Caller-saved (сохраняет вызывающий)
Если функция A вызывает функцию B, и функция A использует регистры rax, rdi, rsi и т.д., то:
- Функция A должна сохранить эти регистры перед вызовом B (если значения нужны после вызова)
- Функция B может свободно изменять эти регистры без сохранения
Пример:
function_a:
mov rax, 42 ; Используем rax
; Хотим вызвать function_b, но нужно сохранить rax
push rax ; Сохранить rax (caller-saved)
call function_b ; function_b может изменить rax
pop rax ; Восстановить rax
; Теперь rax снова содержит 42
ret
function_b:
mov rax, 999 ; Можем свободно изменять rax
ret
Callee-saved (сохраняет вызываемая функция)
Если функция B хочет использовать регистры rbx, r12-r15, rbp, то:
- Функция B должна сохранить эти регистры в начале и восстановить перед
ret - Функция A может не беспокоиться об этих регистрах — они останутся неизменными после вызова B
Пример:
function_a:
mov rbx, 100 ; Используем rbx
call function_b ; function_b сохранит rbx внутри себя
; rbx всё ещё содержит 100
ret
function_b:
push rbx ; Сохранить rbx (callee-saved)
mov rbx, 777 ; Используем rbx для своих нужд
; ... работа с rbx ...
pop rbx ; Восстановить rbx
ret
Почему такое разделение?
Производительность и оптимизация:
- Caller-saved регистров больше → функции могут использовать много регистров для аргументов и временных значений без накладных расходов
- Callee-saved регистров меньше, но они гарантируют стабильность → удобно для переменных, которые должны "пережить" вызовы функций
- Компилятор может оптимизировать, сохраняя только действительно используемые регистры
Сравнение: функция VS системный вызов
| Аспект | Функция (System V ABI) | Системный вызов (syscall) |
|---|---|---|
| Номер операции | — | rax |
| Аргумент 1 | rdi |
rdi |
| Аргумент 2 | rsi |
rsi |
| Аргумент 3 | rdx |
rdx |
| Аргумент 4 | rcx |
r10 (не rcx!) |
| Аргумент 5 | r8 |
r8 |
| Аргумент 6 | r9 |
r9 |
| Возвращаемое значение | rax |
rax |
| Разрушаемые регистры | Caller-saved | rcx, r11 (всегда!) |
| Callee-saved | rbx, rbp, r12-r15 |
rbx, rbp, r12-r15 |
Важно: Системный вызов всегда разрушает rcx и r11, даже если они не используются для аргументов!
Пример: сохранение регистров
my_function:
; Хотим использовать rbx, r12, r13 (callee-saved)
push rbx
push r12
push r13
mov rbx, rdi ; Сохранить 1-й аргумент
mov r12, rsi ; Сохранить 2-й аргумент
mov r13, rdx ; Сохранить 3-й аргумент
; Вызов другой функции (может разрушить caller-saved)
call some_other_function
; rbx, r12, r13 всё ещё содержат исходные значения
mov rax, rbx
add rax, r12
add rax, r13
; Восстановить callee-saved регистры
pop r13
pop r12
pop rbx
ret
5. Локальные переменные на стеке
Зачем нужен фрейм стека?
Когда функции требуются локальные переменные, массивы или буферы, их размещают на стеке. Для управления локальными данными используется фрейм стека (stack frame) — структура, ограниченная двумя регистрами:
rbp(base pointer) — указатель на базу текущего фрейма (фиксированная точка отсчёта)rsp(stack pointer) — указатель на вершину стека (динамически меняется при push/pop)
Структура фрейма стека
graph TD
A["Высокие адреса"] --> B["[arg 8] ← rbp + 24"]
B --> C["[arg 7] ← rbp + 16"]
C --> D["[return address] ← rbp + 8"]
D --> E["[старый rbp] ← rbp (saved frame pointer)"]
E --> F["[локальная переменная 1] ← rbp - 8"]
F --> G["[локальная переменная 2] ← rbp - 16"]
G --> H["[временные данные] ← rsp (текущая вершина стека)"]
H --> I["Низкие адреса"]
style E fill:#ffd700
style D fill:#ff6b6b
style F fill:#4ecdc4
style G fill:#4ecdc4
Ключевые особенности:
- Аргументы 1-6 передаются в регистрах, аргументы 7+ — на стеке (выше return address)
- Return address сохраняется автоматически инструкцией
call - Старый rbp сохраняется в прологе функции для восстановления фрейма вызывающей функции
- Локальные переменные располагаются ниже rbp (отрицательные смещения)
- rbp остаётся неизменным в течение выполнения функции, что упрощает адресацию локальных переменных
Пролог и эпилог функции
Стандартный пролог:
push rbp ; Сохранить старый базовый указатель
mov rbp, rsp ; Установить rbp на текущую позицию стека
sub rsp, N ; Выделить N байт для локальных переменных
Стандартный эпилог:
mov rsp, rbp ; Освободить локальные переменные (восстановить rsp)
pop rbp ; Восстановить старый базовый указатель
ret ; Вернуться (pop return address в rip)
Альтернативные инструкции:
enter N, 0— эквивалент пролога (медленнее, редко используется)leave— эквивалентmov rsp, rbp; pop rbp(часто используется)
Пример: функция с локальным массивом
; Функция, которая создаёт локальный массив из 5 qword
; и заполняет его квадратами чисел 0-4
; Возвращает сумму элементов в rax
square_sum:
; === ПРОЛОГ ===
push rbp ; Сохранить старый rbp
mov rbp, rsp ; rbp теперь указывает на сохранённый rbp
sub rsp, 40 ; Выделить 40 байт (5 qword по 8 байт)
; После пролога:
; rbp+8 → return address
; rbp+0 → старый rbp
; rbp-8 → array[0]
; rbp-16 → array[1]
; rbp-24 → array[2]
; rbp-32 → array[3]
; rbp-40 → array[4] ← rsp
; === ТЕЛО ФУНКЦИИ ===
; Заполнить массив квадратами
xor rcx, rcx ; Счётчик (i = 0)
.fill_loop:
cmp rcx, 5
je .fill_done
mov rax, rcx ; rax = i
imul rax, rax ; rax = i * i
; Вычислить адрес array[i] = rbp - 8*(i+1)
lea rdx, [rcx + 1] ; rdx = i + 1
shl rdx, 3 ; rdx = (i + 1) * 8
neg rdx ; rdx = -(i + 1) * 8
mov [rbp + rdx], rax ; array[i] = i²
inc rcx
jmp .fill_loop
.fill_done:
; Вычислить сумму элементов
xor rax, rax ; sum = 0
xor rcx, rcx ; i = 0
.sum_loop:
cmp rcx, 5
je .sum_done
lea rdx, [rcx + 1]
shl rdx, 3
neg rdx
add rax, [rbp + rdx] ; sum += array[i]
inc rcx
jmp .sum_loop
.sum_done:
; rax содержит сумму 0² + 1² + 2² + 3² + 4² = 30
; === ЭПИЛОГ ===
mov rsp, rbp ; Освободить локальные переменные
pop rbp ; Восстановить старый rbp
ret ; Вернуться
; Альтернативный эпилог:
; leave ; Эквивалентно mov rsp, rbp; pop rbp
; ret
Пошаговая трассировка создания фрейма
Предположим, функция square_sum вызвана из адреса 0x401234. Проследим изменения стека:
| Шаг | Инструкция | rsp | rbp | Стек (содержимое по адресам) |
|---|---|---|---|---|
| 0 | call square_sum |
0x7fff0000 |
0x7fff0100 |
[0x7fff0000] = 0x401239 (return addr) |
| 1 | push rbp |
0x7ffefff8 |
0x7fff0100 |
[0x7ffefff8] = 0x7fff0100 (старый rbp) |
| 2 | mov rbp, rsp |
0x7ffefff8 |
0x7ffefff8 |
rbp теперь указывает на новый фрейм |
| 3 | sub rsp, 40 |
0x7ffeFFd0 |
0x7ffefff8 |
Выделено 40 байт для массива |
| 4 | Заполнение array[0] | 0x7ffeffd0 |
0x7ffefff8 |
[rbp-8] = 0 (0²) |
| 5 | Заполнение array[1] | 0x7ffeffd0 |
0x7ffefff8 |
[rbp-16] = 1 (1²) |
| ... | ... | ... | ... | ... |
| N | mov rsp, rbp |
0x7ffefff8 |
0x7ffefff8 |
Локальные переменные освобождены |
| N+1 | pop rbp |
0x7fff0000 |
0x7fff0100 |
rbp восстановлен |
| N+2 | ret |
0x7fff0008 |
0x7fff0100 |
Возврат по адресу 0x401239 |
Почему используется rbp?
Без rbp (использование только rsp):
- Каждая операция push/pop в теле функции смещает rsp
- Смещения локальных переменных относительно rsp постоянно меняются
- Компилятор должен отслеживать текущее смещение rsp на каждой инструкции
- Усложняется отладка (стековые трейсы требуют сложного раскручивания)
С rbp:
- rbp фиксирован в течение выполнения функции
- Локальные переменные имеют постоянные смещения:
[rbp-8],[rbp-16], ... - Отладчики могут легко найти границы фрейма и параметры
- Упрощается генерация debug-информации (DWARF)
Современная оптимизация: компиляторы с флагом -fomit-frame-pointer могут не использовать rbp, освобождая ещё один регистр общего назначения. Это требует точного отслеживания глубины стека на этапе компиляции.
6. Выравнивание стека (Stack Alignment)
Зачем нужно выравнивание?
Системное соглашение System V AMD64 ABI требует, чтобы стек был выровнен на 16-байтную границу в момент выполнения инструкции call. Это означает, что rsp должен быть кратен 16 перед call.
Причины требования:
- SSE/AVX инструкции: многие SIMD-инструкции (например,
movaps,movdqa) требуют выравнивания на 16 байт для работы с памятью. Невыровненный доступ вызывает исключениеSIGSEGVилиSIGBUS. - Производительность: даже инструкции, которые допускают невыровненный доступ (например,
movups), работают медленнее при нарушении выравнивания. - Совместимость с библиотеками: все системные библиотеки (libc, libm) ожидают выровненный стек.
Механика выравнивания
graph TD
A["До вызова функции: rsp выровнен на 16"] --> B["call: push return address (8 байт)"]
B --> C["Внутри функции: rsp = старое_значение - 8"]
C --> D{"rsp кратен 16?"}
D -->|Нет| E["rsp = (old_rsp - 8) mod 16 = 8"]
E --> F["Невыровненный стек!"]
D -->|Да| G["Функция должна сохранить выравнивание"]
style F fill:#ff6b6b
style G fill:#4ecdc4
Важное следствие: после call стек смещён на 8 байт (return address). Если до call было rsp % 16 == 0, то после call будет rsp % 16 == 8.
Кто отвечает за выравнивание?
Вызывающая функция (caller):
- Гарантирует, что
rsp % 16 == 0перед инструкциейcall - Если нужно передать аргументы через стек (7+), должна учесть их при выравнивании
Вызываемая функция (callee):
- Может предполагать, что
rsp % 16 == 8в начале (послеcall) - Если нужно вызвать другую функцию, должна восстановить выравнивание на 16
Визуализация: выравнивание при вложенных вызовах
sequenceDiagram
participant Main as main (rsp=0x7fff0000)
participant FuncA as func_a
participant FuncB as func_b
Note over Main: rsp = 0x7fff0000 (кратно 16)
Main->>FuncA: call func_a
Note over FuncA: rsp = 0x7ffefff8 (0x7fff0000 - 8)<br/>НЕ кратно 16!
Note over FuncA: push rbp (rsp -= 8)<br/>rsp = 0x7ffefff0 (кратно 16)
Note over FuncA: sub rsp, 24<br/>rsp = 0x7ffeffe8 (НЕ кратно 16)
Note over FuncA: Нужно выровнять перед вызовом!
Note over FuncA: sub rsp, 8<br/>rsp = 0x7ffeffd8 (кратно 16)
FuncA->>FuncB: call func_b
Note over FuncB: rsp = 0x7ffeffD0 (0x7ffeffd8 - 8)<br/>Смещение 8, как ожидалось
FuncB-->>FuncA: ret
Note over FuncA: add rsp, 8 (убрать padding)
Note over FuncA: add rsp, 24 (освободить локальные)
Note over FuncA: pop rbp
FuncA-->>Main: ret
Пример: ручное выравнивание стека
; Функция, которая вызывает printf с выравниванием стека
section .data
format_str: db "Value: %d", 10, 0
section .text
extern printf
print_value:
; Аргумент в rdi = значение для печати
; === ПРОЛОГ ===
push rbp ; rsp -= 8, теперь rsp % 16 == 0
mov rbp, rsp
; Сохранить аргумент (rdi может быть испорчен)
push rdi ; rsp -= 8, теперь rsp % 16 == 8 (невыровнен!)
; === ВЫРАВНИВАНИЕ ===
; Перед вызовом printf нужно rsp % 16 == 0
; Сейчас rsp % 16 == 8, поэтому нужно вычесть ещё 8
sub rsp, 8 ; Padding для выравнивания
; Теперь rsp % 16 == 0
; === ВЫЗОВ PRINTF ===
mov rdi, format_str ; 1-й аргумент: формат
mov rsi, [rbp - 8] ; 2-й аргумент: сохранённое значение
xor rax, rax ; printf - varargs, требует rax=0
call printf ; Стек выровнен!
; === ОЧИСТКА ===
add rsp, 8 ; Убрать padding
pop rdi ; Восстановить (хотя уже не нужен)
; === ЭПИЛОГ ===
pop rbp
ret
Автоматическое выравнивание: формула
Предположим, функции нужно:
- Сохранить N callee-saved регистров (N × 8 байт)
- Выделить M байт для локальных переменных
Алгоритм:
- После
push rbpстек выровнен на 16 (так как доcallбыло кратно 16,callвычел 8,push rbpвычел ещё 8) - Вычислить общий размер:
total = N*8 + M - Если
total % 16 != 0, добавить padding:padding = (16 - (total % 16)) % 16 - Выделить:
sub rsp, total + padding
Пример:
; Нужно: 3 callee-saved регистра (rbx, r12, r13) + 20 байт локальных
; total = 3*8 + 20 = 44
; 44 % 16 = 12, padding = 16 - 12 = 4
push rbp
mov rbp, rsp
push rbx
push r12
push r13 ; Сохранено 3*8 = 24 байта
sub rsp, 20 + 4 ; 20 локальных + 4 padding = 24
; Теперь rsp % 16 == 0
Битовая маска для выравнивания
Часто используется трюк с битовой маской:
; Выровнять rsp на 16-байтную границу (округлить вниз)
and rsp, -16 ; Обнулить младшие 4 бита
; -16 в дополнительном коде = 0xFFFFFFFFFFFFFFF0
; AND с этой маской убирает биты [3:0]
Пример использования:
_start:
; rsp может быть любым при старте программы
and rsp, -16 ; Принудительно выровнять
call main ; Теперь main получит выровненный стек
Типичная ошибка: забыть про выравнивание
Неправильно:
my_function:
push rbp
mov rbp, rsp
sub rsp, 32 ; Выделить 32 байта
; rsp % 16 == 8 (невыровнен!)
call some_function ; SEGFAULT, если some_function использует SSE!
Правильно:
my_function:
push rbp ; rsp % 16 == 0 после этого
mov rbp, rsp
sub rsp, 32 ; Всё ещё rsp % 16 == 0 (32 кратно 16)
call some_function ; OK!
Или с нечётным размером:
my_function:
push rbp ; rsp % 16 == 0
mov rbp, rsp
sub rsp, 24 ; 24 % 16 = 8, теперь rsp % 16 == 8
sub rsp, 8 ; Добавить padding, rsp % 16 == 0
call some_function ; OK!
add rsp, 32 ; Освободить всё (24 + 8)
pop rbp
ret
Проверка выравнивания в GDB
(gdb) p/x $rsp
$1 = 0x7fffffffe4a8
# Проверить выравнивание:
(gdb) p $rsp % 16
$2 = 8 # Не выровнен! Должно быть 0 перед call
# Или:
(gdb) p/t $rsp # Вывести в двоичном виде
$3 = 111111111111111111111111111100101000
# ^^^^ младшие 4 бита должны быть 0000
7. Конечные автоматы: теория
Что такое конечный автомат?
Конечный автомат (Finite State Machine, FSM) — математическая модель вычислений, состоящая из:
- Конечного множества состояний Q = {q₀, q₁, ..., qₙ}
- Начального состояния q₀ ∈ Q
- Множества принимающих (конечных) состояний F ⊆ Q
- Входного алфавита Σ (например, ASCII-символы)
- Функции переходов δ: Q × Σ → Q
Детерминированный автомат (DFA): для каждого состояния и символа существует ровно один переход.
Недетерминированный автомат (NFA): может иметь несколько переходов для одной пары (состояние, символ) или ε-переходы.
Пример 1: проверка чётности количества единиц
Задача: принять строку из '0' и '1', если в ней чётное количество единиц.
stateDiagram-v2
[*] --> Even: начало
Even --> Odd: 1
Even --> Even: 0
Odd --> Even: 1
Odd --> Odd: 0
Even --> [*]: конец (принять)
note right of Even
Чётное количество 1
(включая 0)
end note
note right of Odd
Нечётное количество 1
end note
Таблица переходов:
| Состояние | Символ '0' | Символ '1' |
|---|---|---|
| Even | Even | Odd |
| Odd | Odd | Even |
Код на ассемблере:
; Проверка чётности единиц
; Вход: rdi = указатель на строку
; Выход: rax = 1 (чётно), 0 (нечётно)
check_parity:
xor rax, rax ; Начальное состояние: Even (rax = 0)
.loop:
movzx rcx, byte [rdi] ; Прочитать символ
test rcx, rcx ; Конец строки?
jz .done
cmp cl, '1' ; Если символ == '1'
jne .next
xor rax, 1 ; Переключить состояние (Even ↔ Odd)
.next:
inc rdi
jmp .loop
.done:
; rax = 0 (Even, принять) или 1 (Odd, отклонить)
xor rax, 1 ; Инвертировать: 1 = принято, 0 = отклонено
ret
Пример 2: распознавание целых чисел
Задача: проверить, является ли строка корректным целым числом (возможен знак +/−, затем цифры).
Грамматика (регулярное выражение): [+-]?[0-9]+
Автомат:
stateDiagram-v2
[*] --> Start
Start --> Sign: +, -
Start --> Digit: 0-9
Sign --> Digit: 0-9
Digit --> Digit: 0-9
Digit --> [*]: конец строки (принять)
Start --> Error: другой символ
Sign --> Error: другой символ
Digit --> Error: другой символ
Error --> [*]: отклонить
note right of Start
Начальное состояние
end note
note right of Sign
Прочитан знак
end note
note right of Digit
Прочитана хотя бы
одна цифра
end note
Состояния:
- Start (A): начало, ожидаем знак или цифру
- Sign (B): прочитан знак, ожидаем цифру
- Digit (C): прочитана хотя бы одна цифра (принимающее состояние)
- Error (E): обнаружен недопустимый символ (отклонение)
Таблица переходов:
| Состояние | '+' или '−' | '0'–'9' | Конец строки | Другое |
|---|---|---|---|---|
| Start (A) | Sign (B) | Digit (C) | Error (E) | Error (E) |
| Sign (B) | Error (E) | Digit (C) | Error (E) | Error (E) |
| Digit (C) | Error (E) | Digit (C) | Accept | Error (E) |
| Error (E) | Error (E) | Error (E) | Reject | Error (E) |
Реализация автомата в ассемблере: паттерн
Типичная структура:
; Шаблон для реализации DFA
check_pattern:
; Инициализация: начальное состояние
; (можно использовать регистр для хранения состояния)
.state_A:
call read_symbol ; Прочитать следующий символ в al
test al, al ; Конец ввода?
jz .reject ; В состоянии A конец = ошибка
; Проверить переходы из состояния A
cmp al, '+'
je .state_B
cmp al, '-'
je .state_B
cmp al, '0'
jb .reject
cmp al, '9'
ja .reject
jmp .state_C ; Цифра: переход в C
.state_B:
call read_symbol
test al, al
jz .reject ; Только знак без цифр
cmp al, '0'
jb .reject
cmp al, '9'
ja .reject
jmp .state_C
.state_C:
call read_symbol
test al, al
jz .accept ; Конец в состоянии C = успех
cmp al, '0'
jb .reject
cmp al, '9'
ja .reject
jmp .state_C ; Петля: ещё цифры
.accept:
mov rax, 1
ret
.reject:
xor rax, rax
ret
Свойства конечных автоматов
Преимущества:
- Эффективная проверка за O(n), где n — длина входа
- Детерминированность: нет backtracking, каждый символ обрабатывается один раз
- Просто реализовать на низком уровне (ассемблер, FPGA)
- Легко доказать корректность (формальная верификация)
Ограничения:
- Не могут считать (например, проверить баланс скобок)
- Не могут распознать язык
{aⁿbⁿ | n ≥ 0}(нужен стек → автомат с магазинной памятью) - Не могут проверить, что число является палиндромом произвольной длины
Что могут DFA:
- Все регулярные языки (эквивалентны регулярным выражениям)
- Лексический анализ (токенизация)
- Валидация формата (email, URL, числа)
- Протоколы с фиксированным числом состояний
8. Реализация FSM: анализ check_int.asm
Автомат из check_int.asm
Программа check_int.asm реализует автомат для распознавания целых чисел (с опциональным знаком). Разберём его подробно.
Диаграмма состояний:
stateDiagram-v2
[*] --> A: начало
A --> B: + или -
A --> C: 0-9
A --> E: другое
B --> C: 0-9
B --> E: другое
C --> C: 0-9
C --> D: конец ввода (\0 или \n)
C --> E: другое
D --> [*]: принять (Okay)
E --> [*]: отклонить (Fail)
note right of A
Начальное состояние:
ожидаем знак или цифру
end note
note right of B
Прочитан знак:
теперь нужна цифра
end note
note right of C
Прочитана хотя бы
одна цифра:
принимающее состояние
end note
Код с комментариями
global _start
_start:
; === СОСТОЯНИЕ A: начало ===
_A:
call getsymbol ; Прочитать символ в al
; Проверить на знак '+'
cmp al, '+'
je _B ; Переход A → B
; Проверить на знак '-'
cmp al, '-'
je _B ; Переход A → B
; Проверить на цифру (ASCII '0' = 0x30, '9' = 0x39)
cmp al, '0'
jb _E ; Меньше '0' → ошибка
cmp al, '9'
ja _E ; Больше '9' → ошибка
jmp _C ; Цифра: переход A → C
; === СОСТОЯНИЕ B: прочитан знак ===
_B:
call getsymbol ; Прочитать следующий символ
; После знака ОБЯЗАТЕЛЬНО должна быть цифра
cmp al, '0'
jb _E
cmp al, '9'
ja _E
jmp _C ; Переход B → C
; === СОСТОЯНИЕ C: читаем цифры ===
_C:
call getsymbol
; Проверить на конец ввода
test al, al ; Проверить на NULL (\0)
jz _D ; Конец ввода → принять (C → D)
cmp al, 10 ; Проверить на \n (newline)
jz _D ; Newline → принять (C → D)
; Проверить, ещё одна цифра?
cmp al, '0'
jb _E ; Не цифра → ошибка
cmp al, '9'
ja _E
jmp _C ; Ещё цифра → петля C → C
; === СОСТОЯНИЕ D: принятие ===
_D:
mov rdi, ok_msg ; Указатель на "Okay\n"
mov rsi, 5 ; Длина строки
call print_string
call exit
; === СОСТОЯНИЕ E: ошибка ===
_E:
mov rdi, fail_msg ; Указатель на "Fail\n"
mov rsi, 5
call print_string
call exit
Ключевые особенности реализации
- Метки = состояния: каждой метке (
_A,_B,_C) соответствует состояние автомата - Переходы = переходы:
jmp,je,ja,jbреализуют функцию переходов δ - Чтение символов: функция
getsymbolчитает по одному символу из stdin - Обработка EOF:
getsymbolвозвращает 0 при конце файла - Принимающее состояние: только состояние C может перейти в D (принятие)
Функция getsymbol: чтение одного символа
getsymbol:
xor rax, rax ; syscall 0 = read
xor rdi, rdi ; fd = 0 (stdin)
dec rsp ; Выделить 1 байт на стеке
mov rsi, rsp ; Буфер = вершина стека
mov rdx, 1 ; Читать 1 байт
syscall ; read(0, rsp, 1)
test rax, rax ; Проверить результат
jz .EOF ; Если rax == 0 → конец файла
mov al, [rsp] ; Загрузить прочитанный символ
jmp .END
.EOF:
xor rax, rax ; Вернуть 0 (конец ввода)
.END:
inc rsp ; Восстановить стек
ret
Особенности:
- Использует стек как временный буфер (1 байт)
dec rsp/inc rspвыделяют и освобождают место на стеке- Возвращает символ в
al(младший байт rax) - При EOF возвращает 0
9. Пошаговая трассировка работы FSM
Пример 1: ввод "123"
Входная строка: '1', '2', '3', '\n'
| Шаг | Состояние | Прочитанный символ (al) | Проверка | Переход |
|---|---|---|---|---|
| 1 | A (начало) | '1' (0x31) | Не '+', не '−' | Проверка цифры |
| '0' ≤ '1' ≤ '9' | A → C | |||
| 2 | C | '2' (0x32) | Не \0, не \n | Проверка цифры |
| '0' ≤ '2' ≤ '9' | C → C (петля) | |||
| 3 | C | '3' (0x33) | Не \0, не \n | Проверка цифры |
| '0' ≤ '3' ≤ '9' | C → C | |||
| 4 | C | '\n' (0x0A) | cmp al, 10 | C → D |
| 5 | D (принятие) | — | Вывод "Okay" | exit(0) |
Результат: принято ✓
Пример 2: ввод "-45"
Входная строка: '-', '4', '5', '\n'
| Шаг | Состояние | Прочитанный символ | Проверка | Переход |
|---|---|---|---|---|
| 1 | A | '−' (0x2D) | cmp al, '-' | A → B (je _B) |
| 2 | B | '4' (0x34) | '0' ≤ '4' ≤ '9' | B → C |
| 3 | C | '5' (0x35) | Не \0, не \n | Проверка цифры |
| '0' ≤ '5' ≤ '9' | C → C | |||
| 4 | C | '\n' (0x0A) | cmp al, 10 | C → D |
| 5 | D | — | Вывод "Okay" | exit(0) |
Результат: принято ✓
Пример 3: ввод "+" (ошибка)
Входная строка: '+', '\n'
| Шаг | Состояние | Прочитанный символ | Проверка | Переход |
|---|---|---|---|---|
| 1 | A | '+' (0x2B) | cmp al, '+' | A → B |
| 2 | B | '\n' (0x0A) | cmp al, '0' | al < '0' (0x0A < 0x30) |
| jb _E | B → E | |||
| 3 | E (ошибка) | — | Вывод "Fail" | exit(0) |
Результат: отклонено ✗ (знак без цифр)
Пример 4: ввод "12a3" (ошибка)
Входная строка: '1', '2', 'a', '3'
| Шаг | Состояние | Прочитанный символ | Проверка | Переход |
|---|---|---|---|---|
| 1 | A | '1' | Цифра | A → C |
| 2 | C | '2' | Цифра | C → C |
| 3 | C | 'a' (0x61) | Не \0, не \n | Проверка цифры |
| cmp al, '9' | 'a' > '9' (0x61 > 0x39) | |||
| ja _E | C → E | |||
| 4 | E | — | Вывод "Fail" | exit(0) |
Результат: отклонено ✗ (недопустимый символ)
Детальная трассировка с регистрами: "-7"
Вход: echo "-7" | ./check_int
| Шаг | RIP (метка) | Инструкция | al | Флаги | Примечание |
|---|---|---|---|---|---|
| 1 | _A |
call getsymbol |
? | — | Чтение символа |
| 2 | _A |
(after call) | 0x2D ('-') | — | Символ прочитан |
| 3 | _A |
cmp al, '+' |
0x2D | ZF=0 | 0x2D ≠ 0x2B |
| 4 | _A |
je _B |
0x2D | ZF=0 | Не переходит |
| 5 | _A |
cmp al, '-' |
0x2D | ZF=1 | 0x2D == 0x2D |
| 6 | _A |
je _B |
0x2D | ZF=1 | Переход A → B |
| 7 | _B |
call getsymbol |
0x2D | — | Читаем след. символ |
| 8 | _B |
(after call) | 0x37 ('7') | — | Прочитано '7' |
| 9 | _B |
cmp al, '0' |
0x37 | CF=0 | 0x37 ≥ 0x30 |
| 10 | _B |
jb _E |
0x37 | CF=0 | Не переходит |
| 11 | _B |
cmp al, '9' |
0x37 | CF=1 | 0x37 < 0x39 |
| 12 | _B |
ja _E |
0x37 | CF=1 | Не переходит |
| 13 | _B |
jmp _C |
0x37 | — | Переход B → C |
| 14 | _C |
call getsymbol |
0x37 | — | Читаем след. символ |
| 15 | _C |
(after call) | 0x0A ('\n') | — | Прочитан newline |
| 16 | _C |
test al, al |
0x0A | ZF=0 | 0x0A ≠ 0 |
| 17 | _C |
jz _D |
0x0A | ZF=0 | Не переходит |
| 18 | _C |
cmp al, 10 |
0x0A | ZF=1 | 0x0A == 10 |
| 19 | _C |
jz _D |
0x0A | ZF=1 | Переход C → D |
| 20 | _D |
mov rdi, ok_msg |
— | — | Подготовка вывода |
| 21 | _D |
call print_string |
— | — | Вывод "Okay\n" |
| 22 | — | call exit |
— | — | exit(0) |
Результат: программа выводит "Okay" и завершается с кодом 0 ✓
Визуализация переходов для "-7"
graph LR
Start([Запуск]) --> A
A[Состояние A<br/>читает '-'] --> B[Состояние B<br/>читает '7']
B --> C[Состояние C<br/>читает newline]
C --> D[Состояние D<br/>выводит Okay]
D --> End([exit 0])
style D fill:#51cf66
style End fill:#51cf66
10. Типичные ошибки при работе со стеком и функциями
Ошибка 1: Неправильное выравнивание стека
Проблема: забыли выровнять стек перед call, что приводит к SEGFAULT в функциях, использующих SSE.
Неправильный код:
my_function:
push rbp ; rsp % 16 == 0 после этого
mov rbp, rsp
sub rsp, 24 ; 24 % 16 = 8 → rsp % 16 == 8 (НЕВЫРОВНЕН!)
; Передать параметры
mov rdi, format_str
mov rsi, 42
call printf ; ❌ SEGFAULT! printf ожидает выровненный стек
add rsp, 24
pop rbp
ret
Правильный код:
my_function:
push rbp ; rsp % 16 == 0
mov rbp, rsp
sub rsp, 32 ; 32 % 16 == 0 → rsp % 16 == 0 (ВЫРОВНЕН!)
mov rdi, format_str
mov rsi, 42
call printf ; ✓ Работает корректно
add rsp, 32
pop rbp
ret
Альтернатива с padding:
my_function:
push rbp ; rsp % 16 == 0
mov rbp, rsp
sub rsp, 24 ; Выделить 24 байта локальных
sub rsp, 8 ; Добавить 8 байт padding → итого 32
; Теперь rsp % 16 == 0
call printf ; ✓ OK
add rsp, 32 ; Освободить всё (24 + 8)
pop rbp
ret
Ошибка 2: Не сохранили callee-saved регистры
Проблема: использовали rbx/r12-r15 без сохранения, вызывающая функция получила испорченные данные.
Неправильный код:
calculate:
; Вызывающая функция полагается на rbx
mov rbx, rdi ; ❌ Испортили rbx!
add rbx, 10
mov rax, rbx
ret ; Вернулись, но rbx испорчен
Правильный код:
calculate:
push rbx ; Сохранить rbx
mov rbx, rdi
add rbx, 10
mov rax, rbx
pop rbx ; Восстановить rbx
ret ; ✓ Теперь вызывающая функция видит исходный rbx
Ошибка 3: Дисбаланс стека (push/pop не совпадают)
Проблема: количество push не равно количеству pop, стек смещён при ret.
Неправильный код:
broken_function:
push rbx
push r12
push r13 ; Сохранили 3 регистра
; ... тело функции ...
pop r13
pop r12 ; ❌ Забыли pop rbx!
ret ; Возврат по неправильному адресу → CRASH
Правильный код:
correct_function:
push rbx
push r12
push r13
; ... тело функции ...
pop r13
pop r12
pop rbx ; ✓ Все 3 регистра восстановлены
ret
Диагностика в GDB:
(gdb) break broken_function
(gdb) run
(gdb) info registers rsp
# Запомнить значение rsp
(gdb) stepi # Пошагово до ret
(gdb) info registers rsp
# Если значение отличается на неожиданную величину → дисбаланс!
Ошибка 4: Неправильный порядок pop
Проблема: pop в обратном порядке по отношению к push, регистры получают неправильные значения.
Неправильный код:
wrong_order:
push rbx ; Стек: [rbx]
push r12 ; Стек: [r12, rbx]
push r13 ; Стек: [r13, r12, rbx]
; ...
pop rbx ; ❌ rbx получит значение r13!
pop r12 ; r12 получит значение r12 (случайно правильно)
pop r13 ; r13 получит значение rbx
ret
Правильный код:
correct_order:
push rbx ; Стек: [rbx]
push r12 ; Стек: [r12, rbx]
push r13 ; Стек: [r13, r12, rbx]
; ...
pop r13 ; ✓ r13 восстановлен
pop r12 ; ✓ r12 восстановлен
pop rbx ; ✓ rbx восстановлен
ret
Мнемоника: LIFO (Last In, First Out) — что последним положили, то первым достаём.
Ошибка 5: Использование caller-saved регистра без учёта его изменения
Проблема: положились на сохранение значения в r10/r11 через вызов функции.
Неправильный код:
buggy_code:
mov r10, 100 ; Сохранили значение в r10
mov rdi, 42
call some_function ; some_function может испортить r10!
add rax, r10 ; ❌ r10 может содержать мусор!
ret
Правильный код (вариант 1: использовать callee-saved):
fixed_code_v1:
push rbx ; Сохранить rbx
mov rbx, 100 ; Использовать rbx вместо r10
mov rdi, 42
call some_function ; rbx НЕ изменится
add rax, rbx ; ✓ rbx содержит 100
pop rbx
ret
Правильный код (вариант 2: сохранить на стеке):
fixed_code_v2:
mov r10, 100
push r10 ; Сохранить r10 на стеке
mov rdi, 42
call some_function
pop r10 ; Восстановить r10
add rax, r10 ; ✓ r10 содержит 100
ret
Ошибка 6: Неправильное использование локальных переменных
Проблема: забыли выделить место на стеке для локальных переменных или обращаемся по неправильному смещению.
Неправильный код:
bad_locals:
push rbp
mov rbp, rsp
; ❌ Забыли sub rsp, N!
mov qword [rbp - 8], 42 ; Перезаписываем старый rbp!
mov rax, [rbp - 8]
pop rbp
ret
Правильный код:
good_locals:
push rbp
mov rbp, rsp
sub rsp, 16 ; ✓ Выделили 16 байт для локальных
mov qword [rbp - 8], 42 ; Локальная переменная 1
mov qword [rbp - 16], 100 ; Локальная переменная 2
mov rax, [rbp - 8]
add rax, [rbp - 16]
mov rsp, rbp ; Освободить локальные
pop rbp
ret
Ошибка 7: Неполный FSM (отсутствующие переходы)
Проблема: автомат не обрабатывает все возможные входные символы, программа падает или зависает.
Неправильный код:
incomplete_fsm:
_A:
call getsymbol
cmp al, '0'
jb _C ; Если < '0'
cmp al, '9'
ja _C ; Если > '9'
jmp _B ; Иначе цифра
_B:
; ❌ Что дальше? Нет обработки следующего символа!
ret ; Немедленный возврат?
Правильный код:
complete_fsm:
_A:
call getsymbol
test al, al ; ✓ Проверить EOF
jz _ERROR
cmp al, '0'
jb _ERROR
cmp al, '9'
ja _ERROR
jmp _B
_B:
call getsymbol ; ✓ Продолжить чтение
test al, al
jz _ACCEPT ; EOF после цифры = OK
cmp al, '0'
jb _ERROR
cmp al, '9'
ja _ERROR
jmp _B ; Петля для нескольких цифр
_ACCEPT:
mov rax, 1
ret
_ERROR:
xor rax, rax
ret
Ошибка 8: Неправильный выбор условного перехода (signed vs unsigned)
Проблема: использовали jl/jg для беззнаковых чисел или jb/ja для знаковых.
Неправильный код:
; Проверить, что ASCII код в диапазоне [0x30, 0x39]
check_digit_wrong:
cmp al, 0x30
jl .not_digit ; ❌ jl для знакового сравнения!
cmp al, 0x39 ; ASCII коды беззнаковые
jg .not_digit ; ❌ jg для знакового сравнения!
; ...
Правильный код:
; ASCII коды — беззнаковые значения
check_digit_correct:
cmp al, 0x30
jb .not_digit ; ✓ jb (below) для unsigned
cmp al, 0x39
ja .not_digit ; ✓ ja (above) для unsigned
; ...
Таблица выбора условных переходов:
| Тип данных | "Меньше" | "Меньше/равно" | "Больше" | "Больше/равно" |
|---|---|---|---|---|
| Unsigned | jb |
jbe |
ja |
jae |
| Signed | jl |
jle |
jg |
jge |
Пример ошибки: -1 (0xFF) как unsigned = 255, как signed = -1. Сравнение с 10:
cmp al, 10; jb→ 255 > 10, не переходит (неправильно, если ожидали signed)cmp al, 10; jl→ -1 < 10, переходит (правильно для signed)
11. Практические задания
Задание 1: Функция с локальными переменными
Напишите функцию sum_array, которая:
- Принимает указатель на массив (rdi) и длину (rsi)
- Создаёт локальную переменную-счётчик на стеке
- Возвращает сумму элементов в rax
- Корректно выравнивает стек
Требования:
- Использовать фрейм стека (rbp)
- Сохранить callee-saved регистры при необходимости
- Правильное выравнивание для возможных вложенных вызовов
Задание 2: FSM для проверки email
Реализуйте конечный автомат для упрощённой проверки email формата: [a-z]+@[a-z]+\.[a-z]+
Состояния:
- A: начало, ожидаем букву (локальная часть)
- B: читаем локальную часть
- C: прочитан @, ожидаем букву (домен)
- D: читаем домен
- E: прочитана точка, ожидаем букву (TLD)
- F: читаем TLD (принимающее состояние)
- ERR: ошибка
Пример входов:
"user@example.com"→ принять"test@domain.org"→ принять"invalid"→ отклонить"@example.com"→ отклонить"user@.com"→ отклонить
Задание 3: Рекурсивная функция факториала
Реализуйте рекурсивную функцию factorial(n):
; factorial(n) = n * factorial(n-1), factorial(0) = 1
; Вход: rdi = n
; Выход: rax = n!
factorial:
; TODO: реализовать с правильным управлением стеком
Требования:
- Обработать базовый случай (n = 0)
- Сохранить rdi перед рекурсивным вызовом (caller-saved!)
- Правильно восстановить стек
- Проверить для n = 5 (должно быть 120)
Задание 4: FSM с подсчётом
Напишите автомат, который:
- Читает строку из цифр
- Подсчитывает количество нулей
- Возвращает count в rax
- Использует локальную переменную-счётчик на стеке
Пример:
"12003"→ возвращает 2"999"→ возвращает 0"00000"→ возвращает 5
Задание 5: Отладка с GDB
Дан код с ошибкой:
mystery_function:
push rbp
mov rbp, rsp
push rbx
push r12
mov rbx, rdi
mov r12, rsi
; Какие-то вычисления...
add rbx, r12
mov rax, rbx
pop rbx ; ❌ Ошибка здесь!
pop r12
pop rbp
ret
Задания:
- Найдите ошибку визуально
- Проверьте с GDB, какие регистры испорчены
- Исправьте код
- Убедитесь, что все callee-saved регистры восстановлены правильно
12. Справочная информация
Calling Convention System V AMD64 ABI (краткая памятка)
Передача аргументов:
| Аргумент | Регистр | Тип |
|---|---|---|
| 1 | rdi | integer/pointer |
| 2 | rsi | integer/pointer |
| 3 | rdx | integer/pointer |
| 4 | rcx | integer/pointer |
| 5 | r8 | integer/pointer |
| 6 | r9 | integer/pointer |
| 7+ | стек | push в обратном порядке |
Возвращаемое значение: rax (или rax:rdx для 128-бит)
Caller-saved (может быть испорчен вызываемой функцией):
- rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11
Callee-saved (должен быть сохранён вызываемой функцией):
- rbx, rbp, r12, r13, r14, r15
- rsp (указатель стека)
Выравнивание: rsp % 16 == 0 перед инструкцией call
Инструкции управления стеком
| Инструкция | Описание | Эффект на rsp |
|---|---|---|
push reg |
Сохранить регистр на стеке | rsp -= 8 |
pop reg |
Восстановить регистр со стека | rsp += 8 |
call addr |
Вызвать функцию (push rip, jmp addr) | rsp -= 8 |
ret |
Вернуться (pop rip) | rsp += 8 |
leave |
Эквивалент mov rsp, rbp; pop rbp |
rsp = rbp, затем rsp += 8 |
enter N, 0 |
Создать фрейм стека (медленно, не рекомендуется) |
Условные переходы для FSM
После cmp op1, op2:
| Условие | Unsigned | Signed | Флаги |
|---|---|---|---|
| Равно | je / jz |
je / jz |
ZF=1 |
| Не равно | jne / jnz |
jne / jnz |
ZF=0 |
| op1 < op2 | jb |
jl |
CF=1 (unsigned) / SF≠OF (signed) |
| op1 ≤ op2 | jbe |
jle |
CF=1 or ZF=1 / ZF=1 or SF≠OF |
| op1 > op2 | ja |
jg |
CF=0 and ZF=0 / ZF=0 and SF=OF |
| op1 ≥ op2 | jae |
jge |
CF=0 / SF=OF |
Мнемоника:
- Below / Above — для unsigned
- Less / Greater — для signed
- Equal — для любых
Системные вызовы (частичный список)
| Номер | Имя | rdi | rsi | rdx | Описание |
|---|---|---|---|---|---|
| 0 | read | fd | buf* | count | Читать из файла |
| 1 | write | fd | buf* | count | Писать в файл |
| 60 | exit | code | — | — | Завершить процесс |
Использование:
mov rax, syscall_number
mov rdi, arg1
mov rsi, arg2
mov rdx, arg3
syscall
; Результат в rax
Отладка с GDB
Основные команды:
# Запуск
gdb ./program
(gdb) break _start # Точка останова
(gdb) run # Запустить
# Пошаговое выполнение
(gdb) stepi # Выполнить одну инструкцию
(gdb) nexti # Следующая инструкция (не заходить в call)
(gdb) continue # Продолжить до следующей точки останова
# Просмотр
(gdb) info registers # Все регистры
(gdb) info registers rax rbx rsp # Конкретные регистры
(gdb) x/10gx $rsp # 10 qword в памяти от rsp (стек)
(gdb) x/s $rdi # Строка по адресу в rdi
(gdb) disassemble # Дизассемблировать текущую функцию
# Точки останова
(gdb) break _A # Остановиться на метке _A
(gdb) break *0x401234 # Остановиться на адресе
(gdb) delete 1 # Удалить точку останова №1
# Проверка выравнивания
(gdb) p $rsp % 16 # Должно быть 0 перед call
Полезные макросы для NASM
; Сохранить все callee-saved регистры
%macro SAVE_CALLEE 0
push rbx
push r12
push r13
push r14
push r15
%endmacro
; Восстановить все callee-saved регистры
%macro RESTORE_CALLEE 0
pop r15
pop r14
pop r13
pop r12
pop rbx
%endmacro
; Стандартный пролог функции
%macro FUNC_PROLOGUE 1
push rbp
mov rbp, rsp
sub rsp, %1 ; %1 = размер локальных переменных
%endmacro
; Стандартный эпилог функции
%macro FUNC_EPILOGUE 0
mov rsp, rbp
pop rbp
ret
%endmacro
Использование:
my_function:
FUNC_PROLOGUE 32 ; Выделить 32 байта локальных
SAVE_CALLEE ; Сохранить регистры
; Тело функции
RESTORE_CALLEE
FUNC_EPILOGUE
Ссылки на документацию
- System V AMD64 ABI — официальное соглашение о вызовах
- Intel Software Developer Manual — полное описание инструкций x86-64
- Linux System Call Table — таблица системных вызовов
- NASM Documentation — документация по ассемблеру NASM
Конец методички для семинара 2