Перейти к основному содержимому

5.16. Справочник по Ассемблеру

Разработчику Архитектору

Справочник по Ассемблеру

Архитектурные основы и контекст

Что такое язык ассемблера

Язык ассемблера — это низкоуровневый язык программирования, предназначенный для прямого управления аппаратными ресурсами процессора. Каждая инструкция ассемблера соответствует одной машинной команде целевой архитектуры. Язык ассемблера обеспечивает полный контроль над регистрами, памятью, флагами и выполнением программы.

Целевые архитектуры

Существует множество архитектур процессоров, каждая из которых имеет собственный набор инструкций (ISA — Instruction Set Architecture). Наиболее распространённые архитектуры, для которых пишут на ассемблере:

  • x86 — 32-битная архитектура, разработанная Intel, используется в персональных компьютерах.
  • x86-64 (AMD64 / Intel 64) — 64-битное расширение x86, доминирующая архитектура настольных и серверных систем.
  • ARM — энергоэффективная архитектура, применяемая в мобильных устройствах, встраиваемых системах и современных ноутбуках.
  • MIPS — учебная и встраиваемая архитектура, часто используется в образовательных целях.
  • RISC-V — открытая архитектура с модульным набором инструкций, набирающая популярность в академической и промышленной среде.

Данный справочник ориентирован преимущественно на x86-64, как наиболее актуальную архитектуру для обучения и практического применения на стандартных ПК.

Регистры процессора

Регистры — это сверхбыстрые ячейки памяти внутри процессора, используемые для хранения операндов, адресов и промежуточных результатов вычислений.

Основные регистры общего назначения (в x86-64)

РегистрРазмерНазначение
RAX64 битАккумулятор, используется в арифметических операциях и возврате значений функций
RBX64 битБазовый регистр, часто используется для хранения базовых адресов
RCX64 битСчётчик, применяется в циклах и сдвигах
RDX64 битРасширенный регистр данных, используется в операциях умножения и деления
RSI64 битИсточник для строковых операций
RDI64 битПриёмник для строковых операций
RBP64 битУказатель базы стека (base pointer)
RSP64 битУказатель вершины стека (stack pointer)

Каждый из этих 64-битных регистров имеет младшие части:

  • 32-битные: EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP
  • 16-битные: AX, BX, CX, DX, SI, DI, BP, SP
  • 8-битные: AL, AH, BL, BH, CL, CH, DL, DH и т.д.

Дополнительные регистры (R8–R15)

Архитектура x86-64 добавляет восемь новых 64-битных регистров общего назначения:

  • R8, R9, R10, R11, R12, R13, R14, R15

Их младшие части:

  • 32-битные: R8D, R9D, ..., R15D
  • 16-битные: R8W, R9W, ..., R15W
  • 8-битные: R8B, R9B, ..., R15B

Сегментные регистры

  • CS — код
  • DS — данные
  • SS — стек
  • ES, FS, GS — дополнительные сегменты

В 64-битном режиме сегментация практически отключена, но FS и GS используются для доступа к данным потока или системной информации.

Регистр флагов (RFLAGS)

Регистр флагов содержит битовые индикаторы состояния процессора после выполнения операций:

  • CF (Carry Flag) — флаг переноса, устанавливается при переполнении беззнаковой арифметики
  • ZF (Zero Flag) — устанавливается, если результат операции равен нулю
  • SF (Sign Flag) — устанавливается, если результат отрицателен (старший бит = 1)
  • OF (Overflow Flag) — флаг переполнения знаковой арифметики
  • PF (Parity Flag) — чётность младшего байта результата
  • AF (Auxiliary Carry Flag) — перенос между битами 3 и 4 (используется в BCD-арифметике)

Эти флаги используются условными переходами (JZ, JNZ, JS, JO и др.).

Модели памяти и адресация

Память в x86-64 адресуется линейно, с использованием 64-битных виртуальных адресов. Реальный физический адрес формируется через многоуровневую таблицу страниц, но программист работает с виртуальными адресами.

Способы адресации операндов

  1. Непосредственная (immediate)
    Значение указано прямо в инструкции:

    mov eax, 42
  2. Регистровая (register)
    Операнд находится в регистре:

    mov ebx, eax
  3. Прямая (direct memory)
    Операнд по фиксированному адресу:

    mov eax, [0x1000]
  4. Косвенная (indirect)
    Адрес хранится в регистре:

    mov eax, [rbx]
  5. Базовая + смещение (base + displacement)

    mov eax, [rbx + 8]
  6. Масштабируемая индексная (scaled index)

    mov eax, [rbx + rsi*4]
  7. База + индекс + смещение

    mov eax, [rbx + rsi*4 + 16]

Все эти формы допустимы в x86-64 и позволяют гибко работать с массивами, структурами и стеком.

Стек

Стек — это область памяти, управляемая по принципу LIFO (Last In, First Out). Указатель стека (RSP) всегда указывает на вершину стека.

Основные операции:

  • push reg/mem/imm — помещает значение в стек, уменьшая RSP
  • pop reg — извлекает значение из стека, увеличивая RSP

Стек используется для:

  • передачи аргументов функций
  • хранения возвращаемых адресов при вызове подпрограмм
  • временного хранения регистров

Вызов функций и соглашения о вызовах

В x86-64 на большинстве Unix-подобных систем (Linux, macOS) используется System V AMD64 ABI. В Windows — Microsoft x64 calling convention.

System V AMD64 ABI (Linux/macOS)

  • Первые 6 целочисленных аргументов передаются в регистрах:
    RDI, RSI, RDX, RCX, R8, R9
  • Первые 8 вещественных аргументов — в XMM0XMM7
  • Дополнительные аргументы — через стек
  • Возвращаемое значение — в RAX (или RDX:RAX для 128-битных значений)
  • Регистры RBX, RBP, R12R15 — callee-saved (вызывающая функция ожидает, что они сохранятся)
  • Регистры RAX, RCX, RDX, R8R11 — caller-saved

Пример вызова функции

mov rdi, 10      ; первый аргумент
mov rsi, 20 ; второй аргумент
call add_numbers ; вызов функции
; результат в RAX

Секции программы

Программа на ассемблере состоит из секций (sections), каждая из которых имеет своё назначение:

  • .text — исполняемый код (только чтение, исполняемый)
  • .data — инициализированные данные (чтение/запись)
  • .bss — неинициализированные данные (резервирование памяти)
  • .rodata — константные данные (только чтение)

Пример:

section .data
msg db 'Hello, world!', 0xA
msg_len equ $ - msg

section .bss
buffer resb 256

section .text
global _start
_start:
; код программы

Директивы ассемблера

Директивы — это команды для ассемблера, а не для процессора. Они управляют сборкой программы.

Часто используемые директивы (в NASM):

  • db, dw, dd, dq — определение байтов, слов, двойных слов, четверных слов
  • equ — определение константы
  • resb, resw, resd, resq — резервирование памяти
  • global — экспортирование метки как точки входа
  • extern — объявление внешней метки (например, из libc)
  • times — повторение данных заданное число раз

Системные вызовы (Linux)

В Linux взаимодействие с ядром происходит через системные вызовы. В x86-64 используется инструкция syscall.

Номер системного вызова помещается в RAX, аргументы — в RDI, RSI, RDX, R10, R8, R9.

Пример: вывод строки

mov rax, 1        ; sys_write
mov rdi, 1 ; stdout
mov rsi, msg ; адрес строки
mov rdx, msg_len ; длина
syscall

Завершение программы:

mov rax, 60       ; sys_exit
mov rdi, 0 ; код возврата
syscall

Инструкции процессора

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

Передача данных

Эти инструкции перемещают данные между регистрами, памятью и непосредственными значениями.

MOV — копирование данных

Синтаксис:

mov приёмник, источник

Примеры:

mov eax, 100        ; загрузка константы
mov ebx, eax ; копирование регистра
mov [var], ecx ; запись в память
mov edx, [buffer] ; чтение из памяти

Ограничения:

  • Нельзя напрямую копировать из памяти в память: mov [dst], [src] — недопустимо.
  • Нельзя загружать непосредственное значение в сегментный регистр (например, mov ds, 0x10 — запрещено в защищённом режиме).

LEA — загрузка эффективного адреса

LEA вычисляет адрес, но не обращается к памяти. Это мощный инструмент для арифметики указателей.

lea rax, [rbx + rcx*4 + 16]

Эта команда помещает в RAX значение RBX + RCX*4 + 16, не читая содержимое по этому адресу. Часто используется для быстрого умножения и сложения.

XCHG — обмен значениями

xchg eax, ebx   ; обмен содержимым EAX и EBX

Атомарная операция, полезна в многопоточной среде.


Арифметические операции

Все арифметические инструкции обновляют флаги в RFLAGS.

ADD — сложение

add eax, ebx    ; EAX = EAX + EBX
add [counter], 1

SUB — вычитание

sub eax, 5      ; EAX = EAX - 5

INC / DEC — инкремент и декремент

inc eax         ; EAX = EAX + 1
dec ebx ; EBX = EBX - 1

Эти инструкции короче по размеру, но не обновляют флаг переноса (CF). В современном коде часто предпочитают add reg, 1 ради единообразия.

MUL / IMUL — умножение

  • MUL — беззнаковое умножение
  • IMUL — знаковое умножение

Однооперандная форма:

mul rbx   ; RDX:RAX = RAX * RBX

Двухоперандная форма (результат в первом операнде):

imul eax, ebx      ; EAX = EAX * EBX
imul eax, ebx, 5 ; EAX = EBX * 5

DIV / IDIV — деление

  • DIV — беззнаковое
  • IDIV — знаковое

Деление использует пару регистров RDX:RAX как делимое:

mov rax, 100
xor rdx, rdx ; обнулить RDX (остаток)
mov rbx, 7
div rbx ; RAX = 100 / 7, RDX = 100 % 7

Логические и побитовые операции

AND, OR, XOR, NOT

and eax, 0xFF     ; маскирование младшего байта
or ebx, 1 ; установка младшего бита
xor ecx, ecx ; обнуление регистра (быстрее, чем mov ecx, 0)
not edx ; побитовая инверсия

XOR с самим собой — идиома обнуления регистра.

TEST — проверка битов без изменения значения

test eax, eax     ; устанавливает ZF, если EAX == 0
jz zero_label

Часто используется вместо cmp eax, 0.

CMP — сравнение

cmp eax, ebx      ; вычитает EBX из EAX, обновляет флаги, но не сохраняет результат
je equal_label

Управление потоком выполнения

Безусловный переход — JMP

jmp start_loop

Может быть:

  • прямымjmp label
  • косвеннымjmp rax (переход по адресу в регистре)

Условные переходы

Условные переходы проверяют флаги и выполняют переход, если условие истинно.

ИнструкцияУсловиеОписание
JE / JZZF = 1равно / ноль
JNE / JNZZF = 0не равно / не ноль
JL / JNGESF ≠ OFменьше (знаковое)
JLE / JNGZF = 1 или SF ≠ OFменьше или равно
JG / JNLEZF = 0 и SF = OFбольше
JGE / JNLSF = OFбольше или равно
JB / JNAECF = 1ниже (беззнаковое)
JBE / JNACF = 1 или ZF = 1ниже или равно
JA / JNBECF = 0 и ZF = 0выше
JAE / JNBCF = 0выше или равно
JSSF = 1отрицательный
JNSSF = 0неотрицательный
JOOF = 1переполнение
JNOOF = 0нет переполнения
JP / JPEPF = 1чётный паритет
JNP / JPOPF = 0нечётный паритет

Циклы

Инструкция LOOP (устаревшая, но иногда используется):

mov rcx, 10
loop_start:
; тело цикла
loop loop_start ; уменьшает RCX, переходит, если RCX ≠ 0

Современный подход — использовать dec + jnz:

mov rcx, 10
loop_start:
; тело
dec rcx
jnz loop_start

Вызов подпрограмм

CALL — вызов функции

call my_function

Выполняет два действия:

  1. Помещает адрес следующей инструкции в стек (push rip)
  2. Загружает в RIP адрес my_function

RET — возврат из функции

ret

Извлекает адрес из стека и присваивает его RIP.


Работа со стеком

PUSH — положить в стек

push rax
push 42
push qword [var]

Уменьшает RSP на 8 (в 64-битном режиме) и записывает значение.

POP — извлечь из стека

pop rbx

Читает значение из стека и увеличивает RSP.


Специальные инструкции

NOP — пустая операция

Занимает один такт, ничего не делает. Используется для выравнивания, отладки, задержек.

HLT — остановка процессора

Останавливает CPU до следующего прерывания. Используется в ядрах ОС.

INT — программное прерывание

В 64-битном режиме почти не используется. Раньше применялось для системных вызовов (int 0x80 в 32-битном Linux).

SYSCALL — современный системный вызов

Как описано ранее, основной способ вызова ядра в x86-64.


Префиксы инструкций

Некоторые инструкции могут иметь префиксы, изменяющие их поведение:

  • LOCK — обеспечивает атомарность (lock inc [var])
  • REP — повторяет строковую операцию (rep movsb)
  • REPE / REPZ — повторяет, пока ZF = 1
  • REPNE / REPNZ — повторяет, пока ZF = 0

Пример копирования блока памяти:

mov rsi, src
mov rdi, dst
mov rcx, count
cld ; направление вперёд
rep movsb ; копировать RCX байт из [RSI] в [RDI]

Работа с памятью и данными

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

Организация данных в памяти

Данные в ассемблере определяются в секциях .data (инициализированные) и .bss (неинициализированные). Каждое определение создаёт символ, который можно использовать как адрес.

Базовые директивы определения данных

ДирективаРазмерОписание
db1 байтDefine Byte
dw2 байтаDefine Word
dd4 байтаDefine Doubleword
dq8 байтDefine Quadword
dt10 байтDefine Ten-byte (для расширенных вещественных чисел, редко используется)

Примеры:

section .data
byte_val db 42
word_val dw 1000
dword_val dd 0x12345678
qword_val dq 9223372036854775807
pi dd 3.141592 ; вещественное число (IEEE 754)

Массивы

Массив — это последовательность элементов одного типа, расположенных подряд в памяти.

Однобайтовый массив:

digits db 0, 1, 2, 3, 4, 5, 6, 7, 8, 9

Массив слов:

scores dw 100, 200, 300, 400

Массив из 100 нулей:

buffer db 100 dup(0)

В NASM директива dup позволяет повторить значение заданное число раз.

Строки

Строка в ассемблере — это массив байтов, завершённый нулём (C-стиль) или имеющий известную длину.

Примеры строк:

msg1 db 'Hello', 0                 ; C-строка (с нулевым терминатором)
msg2 db 'World!', 10 ; строка с символом новой строки
msg3 db "Quoted text", 0 ; допустимы двойные кавычки

Длина строки часто вычисляется с помощью метки:

hello db 'Hello, world!', 0xA, 0
hello_len equ $ - hello ; $ — текущая позиция, вычитаем начало

Значение hello_len будет равно 14 (13 символов + \n).

Доступ к элементам массива

Доступ к элементу массива осуществляется через адресацию с масштабированием.

Однобайтовый массив

mov al, [digits + 3]     ; AL = 3

Массив слов (2 байта)

mov ax, [scores + 2*1]   ; второй элемент (индекс 1): 200

Массив двойных слов (4 байта)

mov eax, [array_dd + 4*2] ; третий элемент

Общая формула:
адрес = база + индекс * размер_элемента

В x86-64 можно использовать гибкую адресацию:

mov eax, [rbx + rsi*4]   ; RBX — база, RSI — индекс, 4 — размер элемента

Структуры данных

Ассемблер не имеет встроенного типа «структура», но можно моделировать её через смещения.

Пример: структура точки на плоскости

struc Point
.x resd 1 ; 4 байта для x
.y resd 1 ; 4 байта для y
endstruc

Использование:

section .data
origin Point 0, 0
p1 Point 10, 20

section .text
mov eax, [p1 + Point.x] ; EAX = 10
mov ebx, [p1 + Point.y] ; EBX = 20

Если ассемблер не поддерживает struc (например, в GAS), смещения определяются вручную:

POINT_X equ 0
POINT_Y equ 4

mov eax, [p1 + POINT_X]

Выравнивание данных

Процессор эффективнее читает данные, выровненные по границам, кратным их размеру:

  • 2-байтовые — по чётным адресам
  • 4-байтовые — по адресам, кратным 4
  • 8-байтовые — по адресам, кратным 8

NASM предоставляет директиву align:

section .data
align 8
counter dq 0

Выравнивание особенно важно для SIMD-инструкций (SSE, AVX), требующих 16- или 32-байтное выравнивание.

Работа со строками

Строковые операции ускоряются специальными инструкциями:

ИнструкцияДействие
MOVSBкопирует байт из [RSI] в [RDI], увеличивает/уменьшает RSI/RDI
MOVSWкопирует слово
MOVSDкопирует двойное слово
MOV SQкопирует четверное слово
STOSBзаписывает AL в [RDI]
LODSBзагружает байт из [RSI] в AL
CMPSBсравнивает байты в [RSI] и [RDI]
SCASBсравнивает AL с [RDI]

Направление изменения указателей задаётся флагом DF:

  • CLD — очистка DF, направление вперёд (RSI++, RDI++)
  • STD — установка DF, направление назад (RSI--, RDI--)

Пример: поиск символа в строке

mov rdi, msg
mov al, '!'
mov rcx, msg_len
cld
repne scasb ; ищет '!' в строке
jnz not_found ; если ZF=0 — не найдено
; RDI указывает на символ после '!'

Динамическое выделение памяти

В пользовательском режиме под Linux динамическая память запрашивается через системный вызов mmap или через библиотечную функцию malloc.

Пример с malloc (через libc):

extern malloc
extern free

section .text
mov rdi, 1024 ; запрашиваем 1024 байта
call malloc ; возвращает указатель в RAX
test rax, rax
jz alloc_failed

; используем память по адресу RAX
mov [rax], byte 42

; освобождение
mov rdi, rax
call free

Чистый системный вызов mmap:

; mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
mov rax, 9 ; sys_mmap
mov rdi, 0 ; addr
mov rsi, 4096 ; length
mov rdx, 3 ; prot: read|write
mov r10, 0x22 ; flags: MAP_PRIVATE|MAP_ANONYMOUS
mov r8, -1 ; fd
mov r9, 0 ; offset
syscall
; RAX = адрес или -1 при ошибке

Управление памятью в стеке

Локальные переменные часто размещаются в стеке:

sub rsp, 16         ; выделить 16 байт под локальные данные
mov [rsp], rax ; сохранить RAX
mov [rsp + 8], rbx ; сохранить RBX

; ... работа ...

add rsp, 16 ; освободить

Это безопасно, так как стек автоматически «растёт» вниз, и ОС гарантирует наличие страницы-сторожа.

Константы и псевдонимы

Константы определяются через equ:

MAX_SIZE equ 1000
FLAG_ON equ 1
FLAG_OFF equ 0

Они заменяются на этапе сборки и не занимают места в памяти.


Функции, рекурсия и организация стекового фрейма

Функции (подпрограммы) — основной способ структурирования кода даже в ассемблере. Они позволяют повторно использовать логику, изолировать задачи и управлять сложностью программы. В отличие от высокоуровневых языков, где вызов функции выглядит как простая конструкция, в ассемблере программист полностью отвечает за передачу аргументов, сохранение контекста, управление стеком и возврат результата.

Основы вызова функции

Вызов функции в x86-64 выполняется с помощью инструкции call, которая автоматически помещает адрес возврата в стек и передаёт управление на метку функции. Завершение функции происходит через ret, которая извлекает адрес возврата из стека и продолжает выполнение.

Пример простой функции:

section .text
global _start

_start:
mov rdi, 5
call factorial
; результат в RAX
mov rdi, rax
mov rax, 60 ; sys_exit
syscall

factorial:
cmp rdi, 1
jle .base_case
push rdi ; сохраняем текущий n
dec rdi
call factorial ; рекурсивный вызов
pop rbx ; восстанавливаем n
imul rax, rbx ; RAX = RAX * n
ret
.base_case:
mov rax, 1
ret

Эта функция вычисляет факториал числа, переданного в RDI, и возвращает результат в RAX.

Стековый фрейм (stack frame)

Стековый фрейм — это область стека, выделенная под одну активацию функции. Он содержит:

  • адрес возврата
  • сохранённые регистры
  • локальные переменные
  • аргументы (если передаются через стек)

В x86-64 принято использовать RBP как указатель базы фрейма (frame pointer), хотя современные компиляторы часто его опускают для оптимизации.

Типичная пролога и эпилога функции

Пролога (начало функции):

push rbp        ; сохраняем старый RBP
mov rbp, rsp ; устанавливаем новый RBP = текущий RSP
sub rsp, 32 ; выделяем 32 байта под локальные переменные

Эпилога (конец функции):

mov rsp, rbp    ; восстанавливаем RSP
pop rbp ; восстанавливаем старый RBP
ret

После установки RBP, смещения относительно него имеют фиксированный смысл:

  • [rbp + 8] — адрес возврата
  • [rbp + 16] — первый аргумент, переданный через стек
  • [rbp] — предыдущий RBP
  • [rbp - 8], [rbp - 16] — локальные переменные

Пример с фреймом:

my_function:
push rbp
mov rbp, rsp
sub rsp, 16 ; два 8-байтовых локальных слова

mov [rbp - 8], rdi ; сохранили первый аргумент
mov [rbp - 16], rsi ; сохранили второй аргумент

; ... вычисления ...

mov rax, [rbp - 8] ; загрузили результат
mov rsp, rbp
pop rbp
ret

Передача аргументов

Согласно System V AMD64 ABI, первые шесть целочисленных аргументов передаются в регистрах:

  • RDI, RSI, RDX, RCX, R8, R9

Если аргументов больше шести, остальные размещаются в стеке справа налево (последний аргумент — ближе к вершине стека).

Пример функции с семью аргументами:

; C-прототип: int func(a, b, c, d, e, f, g)
; a → RDI, b → RSI, ..., f → R9, g → [RSP + 8]
func:
; g доступен как [rbp + 16], если используется RBP
mov rax, [rsp + 8] ; или напрямую через RSP
add rax, rdi
ret

Возвращаемые значения

  • Целые числа и указатели возвращаются в RAX
  • 128-битные значения — в RDX:RAX
  • Вещественные числа — в XMM0

Сохранение регистров (callee-saved vs caller-saved)

ABI определяет, какие регистры обязан сохранять вызываемая функция (callee-saved), а какие — вызывающая (caller-saved).

Callee-saved (функция обязана сохранить и восстановить):

  • RBX, RBP, R12R15

Caller-saved (вызывающая сторона не ожидает их сохранения):

  • RAX, RCX, RDX, RSI, RDI, R8R11

Если функция использует RBX, она должна сохранить его в начале и восстановить перед ret:

my_func:
push rbx
; ... используем RBX ...
pop rbx
ret

Рекурсия

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

Условие завершения (базовый случай) обязательно, иначе стек переполнится (stack overflow).

Пример: вычисление суммы чисел от 1 до n

sum_to_n:
cmp rdi, 0
je .base
push rdi
dec rdi
call sum_to_n
pop rbx
add rax, rbx
ret
.base:
xor rax, rax
ret

Хвостовая рекурсия и оптимизация

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

Пример хвостовой рекурсии:

; sum_tail(n, acc)
sum_tail:
cmp rdi, 0
je .done
add rsi, rdi ; acc += n
dec rdi ; n--
jmp sum_tail ; хвостовой вызов → заменён на JMP
.done:
mov rax, rsi
ret

Здесь jmp вместо call предотвращает накопление фреймов.

Локальные массивы и структуры

Большие локальные данные также размещаются в стеке:

process_data:
push rbp
mov rbp, rsp
sub rsp, 256 ; 256 байт под буфер

lea rax, [rbp - 256] ; RAX = адрес начала буфера
mov [rax], byte 0

; ... обработка ...

mov rsp, rbp
pop rbp
ret

Важно: стек ограничен (обычно 8 МБ в Linux), поэтому очень большие массивы следует выделять динамически.

Обработка ошибок и возврат статусов

Функции могут возвращать коды ошибок через RAX. Например, -1 означает ошибку, 0 — успех.

safe_divide:
test rsi, rsi ; делитель = 0?
jz .error
mov rax, rdi
xor rdx, rdx
div rsi
ret
.error:
mov rax, -1
ret

Взаимодействие с C

Ассемблерные функции можно вызывать из C, если соблюдать ABI.

Пример объявления в C:

extern long my_asm_func(long a, long b);

Ассемблерная реализация:

global my_asm_func
my_asm_func:
add rdi, rsi
mov rax, rdi
ret

Сборка:

nasm -f elf64 func.asm -o func.o
gcc main.c func.o -o program

Взаимодействие с операционной системой

Программа на ассемблере не существует в вакууме. Чтобы читать файлы, выводить текст, получать ввод от пользователя или завершать выполнение, она должна взаимодействовать с операционной системой. В Unix-подобных системах (Linux, macOS) это происходит через системные вызовы — специальные точки входа в ядро. В Windows используется другая модель (WinAPI), но в рамках данного справочника фокус сделан на Linux x86-64, как наиболее доступной и документированной среде для обучения.

Механизм системных вызовов в x86-64

В 64-битном режиме Linux системные вызовы выполняются через инструкцию syscall. Процессор переключается в привилегированный режим, управление передаётся ядру, которое выполняет запрошенную операцию и возвращает результат.

Регистры для системных вызовов (System V ABI)

РегистрНазначение
RAXномер системного вызова
RDI1-й аргумент
RSI2-й аргумент
RDX3-й аргумент
R104-й аргумент
R85-й аргумент
R96-й аргумент

Важно: четвёртый аргумент передаётся в R10, а не в RCX, потому что syscall разрушает значение RCX.

Результат возвращается в RAX:

  • неотрицательное значение — успешный результат
  • отрицательное значение — код ошибки (-errno)

Основные системные вызовы

1. sys_write — запись данных

Номер: 1
Сигнатура: ssize_t write(int fd, const void *buf, size_t count)

Пример: вывод строки в стандартный вывод

section .data
msg db 'Hello from assembly!', 0xA
msg_len equ $ - msg

section .text
global _start

_start:
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
mov rsi, msg ; адрес буфера
mov rdx, msg_len ; количество байт
syscall

2. sys_read — чтение данных

Номер: 0
Сигнатура: ssize_t read(int fd, void *buf, size_t count)

Пример: чтение одной строки с клавиатуры

section .bss
input resb 256

section .text
; ...
mov rax, 0 ; sys_read
mov rdi, 0 ; stdin
mov rsi, input ; буфер
mov rdx, 256 ; максимум байт
syscall
; RAX = количество прочитанных байт

3. sys_exit — завершение программы

Номер: 60
Сигнатура: void _exit(int status)

mov rax, 60
mov rdi, 0 ; код возврата
syscall

4. sys_open — открытие файла

Номер: 2
Сигнатура: int open(const char *pathname, int flags, mode_t mode)

Флаги (часто используемые):

  • O_RDONLY = 0
  • O_WRONLY = 1
  • O_RDWR = 2
  • O_CREAT = 0x40
  • O_TRUNC = 0x200

Режим (если создаётся файл): 0o644 (восьмеричное) → 0x1A4

Пример: открытие файла для чтения

section .data
filename db 'data.txt', 0

section .text
mov rax, 2 ; sys_open
mov rdi, filename ; путь
mov rsi, 0 ; O_RDONLY
mov rdx, 0 ; режим не нужен при чтении
syscall
; RAX = дескриптор файла или -1 при ошибке

5. sys_close — закрытие файла

Номер: 3
Сигнатура: int close(int fd)

mov rax, 3
mov rdi, r12 ; предположим, дескриптор в R12
syscall

6. sys_lseek — перемещение указателя в файле

Номер: 8
Сигнатура: off_t lseek(int fd, off_t offset, int whence)

whence:

  • SEEK_SET = 0 (от начала)
  • SEEK_CUR = 1 (от текущей позиции)
  • SEEK_END = 2 (от конца)

Пример: перейти в конец файла

mov rax, 8
mov rdi, fd
mov rsi, 0
mov rdx, 2 ; SEEK_END
syscall

7. sys_mmap — отображение файла в память

Номер: 9
Позволяет отобразить файл или выделить анонимную память.

Пример: выделение 4 КБ анонимной памяти

mov rax, 9
mov rdi, 0 ; addr (NULL)
mov rsi, 4096 ; length
mov rdx, 3 ; PROT_READ | PROT_WRITE
mov r10, 0x22 ; MAP_PRIVATE | MAP_ANONYMOUS
mov r8, -1 ; fd = -1
mov r9, 0 ; offset
syscall
; RAX = адрес или -1

Обработка ошибок

После системного вызова проверяйте знак результата:

syscall
cmp rax, 0
jl .error_handler

Код ошибки можно получить как -rax и использовать для диагностики (например, -2 = ENOENT — файл не найден).

Работа с несколькими файлами

Пример: копирование содержимого одного файла в другой

; Открыть исходный файл
mov rax, 2
mov rdi, src_name
mov rsi, 0
syscall
mov r12, rax ; сохраняем дескриптор

; Открыть целевой файл (создать, если нет)
mov rax, 2
mov rdi, dst_name
mov rsi, 0x441 ; O_CREAT | O_WRONLY | O_TRUNC
mov rdx, 0o644
syscall
mov r13, rax

; Чтение и запись в цикле
.read_loop:
mov rax, 0
mov rdi, r12
mov rsi, buffer
mov rdx, 1024
syscall
test rax, rax
jle .done ; <= 0 — конец или ошибка

mov rbx, rax ; сохраняем количество байт
mov rax, 1
mov rdi, r13
mov rsi, buffer
mov rdx, rbx
syscall

jmp .read_loop

.done:
; Закрыть оба файла
mov rax, 3
mov rdi, r12
syscall
mov rax, 3
mov rdi, r13
syscall

Сигналы

Ассемблер позволяет устанавливать обработчики сигналов через sys_rt_sigaction (номер 13), но это сложная тема. В простых программах сигналы обычно игнорируются или обрабатываются по умолчанию (например, SIGINT завершает программу).

Процессы и потоки

  • sys_fork (57) — создаёт новый процесс
  • sys_execve (59) — заменяет текущий образ памяти новой программой
  • sys_clone (56) — создаёт поток (низкоуровневый аналог pthread_create)

Пример запуска другой программы:

section .data
prog db '/bin/ls', 0
argv dq prog, 0
envp dq 0

section .text
mov rax, 59 ; sys_execve
mov rdi, prog
mov rsi, argv
mov rdx, envp
syscall
; Если execve вернулся — произошла ошибка

Время и задержки

  • sys_nanosleep (35) — приостанавливает выполнение
section .data
ts dq 1, 500000000 ; 1 секунда + 500 млн наносекунд = 1.5 сек

section .text
mov rax, 35
mov rdi, ts
mov rsi, 0
syscall

Использование libc вместо прямых системных вызовов

Для сложных задач (например, форматированный вывод) удобнее вызывать функции из стандартной библиотеки C (printf, fopen, malloc и т.д.).

Пример с printf:

extern printf
extern exit

section .data
fmt db 'Result: %ld', 10, 0

section .text
global main

main:
push rbp
mov rbp, rsp

mov rdi, fmt
mov rsi, 42
call printf

mov rdi, 0
call exit

Сборка:

nasm -f elf64 program.asm -o program.o
gcc program.o -o program

Преимущества libc:

  • удобство (printf, scanf)
  • портируемость
  • обработка ошибок
  • работа с локалью

Недостатки:

  • зависимость от библиотеки
  • больший размер исполняемого файла
  • менее прямой контроль

Отладка, анализ и инструменты

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

Основной инструментарий для x86-64 (Linux)

ИнструментНазначение
NASMАссемблер (Netwide Assembler) — преобразует .asm в объектные файлы
GASАссемблер из GNU Binutils, использует AT&T-синтаксис
LDКомпоновщик (linker) — объединяет объектные файлы в исполняемый
GCCМожет выступать как компоновщик, автоматически подключая libc
GDBОтладчик — позволяет шагать по инструкциям, проверять регистры и память
objdumpДизассемблер — показывает машинный код и символы
straceТрассировщик системных вызовов
ltraceТрассировщик вызовов библиотечных функций
readelfАнализ ELF-файлов (заголовки, секции, символы)

Сборка программы без libc (чистый системный вызов)

Файл hello.asm:

section .data
msg db 'Hello, world!', 0xA
len equ $ - msg

section .text
global _start

_start:
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, len
syscall

mov rax, 60
mov rdi, 0
syscall

Сборка:

nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello

Получаем минимальный исполняемый файл (~300–500 байт).


Сборка с использованием libc

Файл hello_libc.asm:

extern printf
extern exit

section .data
fmt db 'Hello from libc!', 10, 0

section .text
global main

main:
push rbp
mov rbp, rsp

mov rdi, fmt
xor rax, rax ; количество векторных регистров = 0
call printf

mov rdi, 0
call exit

Сборка:

nasm -f elf64 hello_libc.asm -o hello_libc.o
gcc hello_libc.o -o hello_libc

Здесь gcc автоматически вызывает ld, подключает libc, crt0.o и другие необходимые компоненты.


Отладка с помощью GDB

GDB — мощный отладчик, поддерживающий низкоуровневую работу.

Запуск отладки

gdb ./hello

Основные команды

КомандаДействие
layout asmПоказать ассемблерный код в реальном времени
break _startУстановить точку останова
runЗапустить программу
stepi (si)Выполнить одну инструкцию
nexti (ni)Выполнить инструкцию, не заходя в вызовы
info registers (i r)Показать все регистры
print/x $raxПоказать RAX в шестнадцатеричном виде
x/10xb $rspПоказать 10 байт памяти по адресу RSP
disassembleДизассемблировать текущую функцию
quitВыйти

Пример сессии

(gdb) break _start
(gdb) run
(gdb) layout asm
(gdb) stepi
(gdb) i r rax rdi rsi rdx
(gdb) x/s $rsi ; показать строку по адресу RSI

GDB особенно полезен при отладке переполнения стека, неправильной адресации или ошибок системных вызовов.


Анализ исполняемого файла: objdump и readelf

objdump — дизассемблирование

objdump -d hello

Выводит машинный код и соответствующие ассемблерные инструкции:

0000000000401000 <_start>:
401000: 48 c7 c0 01 00 00 00 mov $0x1,%rax
401007: 48 c7 c7 01 00 00 00 mov $0x1,%rdi
...

Опции:

  • -d — дизассемблировать исполняемые секции
  • -t — показать таблицу символов
  • -s — показать содержимое секций в hex

readelf — анализ структуры ELF

readelf -h hello    # заголовок ELF
readelf -S hello # секции
readelf -s hello # символы

Позволяет убедиться, что секции .text, .data присутствуют, точки входа корректны.


Трассировка выполнения: strace и ltrace

strace — отслеживание системных вызовов

strace ./hello

Вывод:

write(1, "Hello, world!\n", 14)         = 14
exit(0) = ?
+++ exited with 0 +++

Полезно для:

  • проверки, какие файлы открываются
  • диагностики ошибок (-1 ENOENT)
  • понимания поведения программы без исходного кода

ltrace — отслеживание вызовов libc

ltrace ./hello_libc

Вывод:

printf("Hello from libc!\n")            = 18
exit(0) = ?

Используется, когда программа активно применяет стандартную библиотеку.


Поиск ошибок: типичные проблемы и решения

1. Segmentation fault (ошибка сегментации)

Причины:

  • обращение по недопустимому адресу (mov eax, [0])
  • запись в .text-секцию
  • порча стека (pop без push, непарный ret)

Решение:

  • запустить под gdb, выполнить до падения, проверить регистры и стек
  • использовать strace, чтобы увидеть последний системный вызов

2. Программа завершается, но ничего не выводит

Проверьте:

  • правильно ли задана длина строки (не забыт ли \n или терминатор)
  • используется ли stdout (fd=1), а не stderr (fd=2)
  • не перепутаны RDX и RSI в sys_write

3. Стек не выровнен (ошибка при вызове libc)

При вызове функций из libc стек должен быть выровнен по 16-байтной границе перед call.

Если в main вы делаете push rbp, стек смещается на 8 байт. Чтобы выровнять:

main:
push rbp
mov rbp, rsp
sub rsp, 8 ; теперь RSP % 16 == 0
; ... вызовы printf ...
add rsp, 8
pop rbp
ret

Или используйте чётное количество push.

4. Неправильный номер системного вызова

Номера отличаются между архитектурами. Для x86-64 Linux актуальные номера можно найти в:

  • /usr/include/asm/unistd_64.h
  • онлайн-справочниках (например, syscalls.kernelgrok.com)

Автоматизация: Makefile

Для проектов с несколькими файлами удобно использовать Makefile:

ASM = nasm
CFLAGS = -f elf64
LD = ld
CC = gcc

all: hello pure_hello

hello: hello_libc.o
$(CC) $< -o $@

pure_hello: hello.o
$(LD) $< -o $@

%.o: %.asm
$(ASM) $(CFLAGS) $< -o $@

clean:
rm -f *.o hello pure_hello

Команда make соберёт обе версии программы.


Практические примеры

Теория становится полезной, когда применяется на практике. В этой заключительной части представлены законченные, рабочие примеры программ на ассемблере для x86-64 Linux. Каждый пример демонстрирует ключевые концепции: управление памятью, арифметика, рекурсия, работа с файлами, обработка ввода и оптимизация. Все программы написаны в синтаксисе NASM и могут быть собраны стандартными инструментами.


Пример 1: Рекурсивный факториал (чистый системный вызов)

Эта программа вычисляет факториал числа, переданного как аргумент командной строки (упрощённо — фиксированное значение), и выводит результат в виде десятичного числа.

section .data
result_msg db 'Factorial: ', 0
newline db 10

section .bss
buffer resb 20

section .text
global _start

; Функция: преобразует число в строку (десятичное)
; вход: RDI = число, RSI = буфер (заполняется задом наперёд)
; выход: RAX = длина строки
itoa:
mov rax, rdi
mov rdi, rsi
add rdi, 19 ; указываем на конец буфера
mov byte [rdi], 0 ; нулевой терминатор
dec rdi
mov rbx, 10
cmp rax, 0
jnz .loop
mov byte [rdi], '0'
dec rdi
jmp .done
.loop:
xor rdx, rdx
div rbx
add dl, '0'
mov [rdi], dl
dec rdi
test rax, rax
jnz .loop
.done:
inc rdi
mov rax, rsi
sub rax, rdi
neg rax
ret

; Функция: факториал
; вход: RDI = n
; выход: RAX = n!
factorial:
cmp rdi, 1
jle .base
push rdi
dec rdi
call factorial
pop rbx
imul rax, rbx
ret
.base:
mov rax, 1
ret

_start:
mov rdi, 6 ; вычисляем 6!
call factorial

lea rsi, [buffer]
mov rdi, rax
call itoa ; RAX = длина

; Вывод "Factorial: "
mov rax, 1
mov rdi, 1
mov rsi, result_msg
mov rdx, 11
syscall

; Вывод числа
mov rax, 1
mov rdi, 1
lea rsi, [buffer]
; длина уже в RAX
syscall

; Новая строка
mov rax, 1
mov rdi, 1
mov rsi, newline
mov rdx, 1
syscall

; Завершение
mov rax, 60
mov rdi, 0
syscall

Особенности:

  • Ручная конвертация числа в строку (itoa)
  • Рекурсивный вызов с сохранением контекста
  • Чистый вывод через sys_write

Пример 2: Обход массива и поиск максимума

Программа ищет максимальное значение в статическом массиве и выводит его.

section .data
arr dq 15, -3, 42, 7, 29, -10, 100
arr_len equ ($ - arr) / 8
msg db 'Max: ', 0

section .bss
num_buf resb 20

section .text
global _start

; Подпрограмма itoa (как в предыдущем примере, сокращена)
; ... (вставьте itoa здесь или вынесите в отдельный файл) ...

find_max:
mov rcx, arr_len
mov rsi, arr
mov rax, [rsi] ; первый элемент — текущий максимум
dec rcx
jz .done
.next:
add rsi, 8
mov rdx, [rsi]
cmp rdx, rax
jle .skip
mov rax, rdx
.skip:
loop .next
.done:
ret

print_str:
mov rax, 1
mov rdi, 1
syscall
ret

_start:
call find_max ; RAX = максимум

lea rsi, [num_buf]
mov rdi, rax
call itoa

; Вывод "Max: "
mov rdx, 5
mov rsi, msg
call print_str

; Вывод числа
mov rdx, rax
lea rsi, [num_buf]
call print_str

; Новая строка
mov rdx, 1
mov rsi, newline
call print_str

mov rax, 60
mov rdi, 0
syscall

Особенности:

  • Использование loop для итерации
  • Сравнение знаковых чисел
  • Модульный вывод через подпрограмму

Пример 3: Чтение файла и подсчёт байтов

Программа открывает файл, читает его по блокам и выводит общий размер.

section .data
filename db 'input.txt', 0
size_msg db 'File size: ', 0

section .bss
buffer resb 4096
num_buf resb 20

section .text
global _start

; itoa и print_str — как выше ...

_start:
; Открыть файл
mov rax, 2
mov rdi, filename
mov rsi, 0 ; O_RDONLY
mov rdx, 0
syscall
mov r12, rax ; fd

cmp rax, 0
jl .error

xor r13, r13 ; total = 0

.read_loop:
mov rax, 0
mov rdi, r12
mov rsi, buffer
mov rdx, 4096
syscall

test rax, rax
jle .done

add r13, rax
jmp .read_loop

.done:
mov rax, 3
mov rdi, r12
syscall ; close

; Вывод результата
mov rdx, 11
mov rsi, size_msg
call print_str

lea rsi, [num_buf]
mov rdi, r13
call itoa

mov rdx, rax
lea rsi, [num_buf]
call print_str

mov rsi, newline
mov rdx, 1
call print_str

mov rax, 60
mov rdi, 0
syscall

.error:
mov rax, 60
mov rdi, 1 ; exit code 1
syscall

Особенности:

  • Работа с файловыми дескрипторами
  • Циклическое чтение большого файла
  • Обработка ошибок открытия

Пример 4: Оптимизированный цикл — сумма массива

Сравним два подхода: через loop и через dec/jnz.

; Метод 1: loop (медленнее на современных CPU)
sum_loop:
mov rcx, len
mov rsi, arr
xor rax, rax
.add:
add rax, [rsi]
add rsi, 8
loop .add
ret

; Метод 2: dec + jnz (быстрее)
sum_fast:
mov rcx, len
mov rsi, arr
xor rax, rax
.add:
add rax, [rsi]
add rsi, 8
dec rcx
jnz .add
ret

На современных процессорах loop не оптимизирован так же хорошо, как dec/jnz, поэтому второй вариант предпочтителен.


Пример 5: Использование SIMD (SSE) для ускорения

Сложение массива с использованием 128-битных регистров:

section .data
align 16
vec_arr dd 1,2,3,4,5,6,7,8

section .text
sum_sse:
pxor xmm0, xmm0 ; обнулить аккумулятор
mov rsi, vec_arr
mov rcx, 2 ; 8 элементов / 4 = 2 итерации

.loop:
movdqa xmm1, [rsi] ; загрузить 4 int32
paddd xmm0, xmm1 ; сложить
add rsi, 16
loop .loop

; Горизонтальное сложение
movhlps xmm1, xmm0
paddd xmm0, xmm1
pshufd xmm1, xmm0, 1
paddd xmm0, xmm1
movd eax, xmm0 ; результат в EAX
ret

Требования:

  • Данные выровнены по 16 байтам
  • Поддержка SSE у процессора