Files
obsidian/ЯПы/Семинары/Семинар-2.md
T
2026-09-29 21:22:14 +03:00

78 KiB
Raw Blame History

Методичка по языку ассемблера x86-64

Семинар 2: Функции, стек, конечные автоматы


Оглавление

  1. Механизм работы стека
  2. Определение и вызов функций
  3. Функции с аргументами и возвращаемые значения
  4. Соглашение вызова (Calling Convention)
  5. Использование стека для локальных переменных
  6. Фрейм стека и выравнивание
  7. Конечные автоматы (Finite State Machines)
  8. Автоматы и регулярные выражения
  9. Кодирование автоматов на ассемблере
  10. Типичные ошибки
  11. Практические задания
  12. Справочник: часто используемые конструкции

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:

  1. Сохранить адрес возврата на стек: push адрес следующей инструкции после call
  2. Перейти к функции: установить RIP = адрес метки function_name

Эквивалентно:

push rip_next        ; (это псевдокод, rip нельзя использовать напрямую)
jmp function_name
rip_next:            ; адрес следующей инструкции

Инструкция RET

ret

Действия ret:

  1. Снять адрес возврата со стека: pop значение в RIP
  2. Перейти по этому адресу: продолжить выполнение

Эквивалентно:

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 находится по адресу 0x401000
  • print_hello находится по адресу 0x401020
  • RSP изначально 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) — это набор правил, определяющих:

  1. Как передаются аргументы (регистры, стек, порядок)
  2. Какой регистр используется для возвращаемого значения
  3. Какие регистры должны сохраняться функцией (callee-saved)
  4. Какие регистры может свободно изменять функция (caller-saved)
  5. Как выравнивается стек
  6. Кто очищает стек после вызова

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. Аргументы 1-6 передаются в регистрах, аргументы 7+ — на стеке (выше return address)
  2. Return address сохраняется автоматически инструкцией call
  3. Старый rbp сохраняется в прологе функции для восстановления фрейма вызывающей функции
  4. Локальные переменные располагаются ниже rbp (отрицательные смещения)
  5. 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.

Причины требования:

  1. SSE/AVX инструкции: многие SIMD-инструкции (например, movaps, movdqa) требуют выравнивания на 16 байт для работы с памятью. Невыровненный доступ вызывает исключение SIGSEGV или SIGBUS.
  2. Производительность: даже инструкции, которые допускают невыровненный доступ (например, movups), работают медленнее при нарушении выравнивания.
  3. Совместимость с библиотеками: все системные библиотеки (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 байт для локальных переменных

Алгоритм:

  1. После push rbp стек выровнен на 16 (так как до call было кратно 16, call вычел 8, push rbp вычел ещё 8)
  2. Вычислить общий размер: total = N*8 + M
  3. Если total % 16 != 0, добавить padding: padding = (16 - (total % 16)) % 16
  4. Выделить: 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) — математическая модель вычислений, состоящая из:

  1. Конечного множества состояний Q = {q₀, q₁, ..., qₙ}
  2. Начального состояния q₀ ∈ Q
  3. Множества принимающих (конечных) состояний F ⊆ Q
  4. Входного алфавита Σ (например, ASCII-символы)
  5. Функции переходов δ: 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

Ключевые особенности реализации

  1. Метки = состояния: каждой метке (_A, _B, _C) соответствует состояние автомата
  2. Переходы = переходы: jmp, je, ja, jb реализуют функцию переходов δ
  3. Чтение символов: функция getsymbol читает по одному символу из stdin
  4. Обработка EOF: getsymbol возвращает 0 при конце файла
  5. Принимающее состояние: только состояние 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

Задания:

  1. Найдите ошибку визуально
  2. Проверьте с GDB, какие регистры испорчены
  3. Исправьте код
  4. Убедитесь, что все 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

Ссылки на документацию


Конец методички для семинара 2