Перейти к содержанию

Основы ассемблера x86-64

Ассемблер (assembly language) — это низкоуровневый язык программирования, в котором каждая инструкция почти однозначно соответствует одной машинной инструкции процессора. В отличие от машинного кода, представляющего собой последовательность двоичных байт, ассемблер использует мнемоники — читаемые обозначения инструкций (mov, add, jmp) вместо их числовых кодов.

Разновидности ассемблера

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

  • x86-64 — 64-битные ПК и серверы (Linux, Windows, macOS);
  • x86-32 — устаревшая 32-битная версия x86;
  • ARM / AArch64 — мобильные устройства, Apple Silicon, встроенные системы;
  • RISC-V — открытая архитектура, активно развивающаяся.

Для x86-64 существуют два синтаксиса, которые по-разному записывают одни и те же инструкции:

Intel-синтаксис (используется в NASM, MASM):

mov rax, rbx          ; destination <- source
add rax, 5

AT&T-синтаксис (используется в GNU as, GCC):

movq %rbx, %rax       ; source -> destination (порядок обратный!)
addq $5, %rax

В системах GNU/Linux (в том числе при компиляции GCC) по умолчанию используется AT&T-синтаксис. Регистры записываются с префиксом %, непосредственные значения — с префиксом $, а суффикс инструкции задаёт размер операнда: b (byte, 8 бит), w (word, 16 бит), l (long, 32 бита), q (quad, 64 бита).

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

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

Регистр (64-бит) 32-бит 16-бит 8-бит Назначение
rax eax ax al Аккумулятор; возвращаемое значение функции; результат умножения/деления
rbx ebx bx bl Базовый регистр; сохраняется вызываемой функцией (callee-saved)
rcx ecx cx cl Счётчик циклов и сдвигов; 4-й аргумент функции
rdx edx dx dl Данные; часть результата умножения/деления; 3-й аргумент
rsi esi si sil Source index; 2-й аргумент функции
rdi edi di dil Destination index; 1-й аргумент функции
rsp esp sp spl Stack pointer — вершина стека
rbp ebp bp bpl Base pointer — основание стекового фрейма
r8–r15 r8d–r15d r8w–r15w r8b–r15b Дополнительные регистры, появившиеся в x86-64
rax (64 бита)
┌────────────────────────────────────────────────────────────────┐
│                             rax                                │
├────────────────────────────────┬───────────────────────────────┤
│            (старшие 32)        │              eax              │
│                                ├───────────────┬───────────────┤
│                                │   (старш. 16) │      ax       │
│                                │               ├───────┬───────┤
│                                │               │  ah   │  al   │
└────────────────────────────────┴───────────────┴───────┴───────┘
  64 бита            32 бита         16 бит    8 бит  8 бит

Запись в 32-битный подрегистр (например, eax) обнуляет верхние 32 бита 64-битного регистра (rax). Запись в 8- или 16-битный подрегистр верхние биты не затрагивает.

Соглашение о вызовах System V AMD64 ABI

В Linux x86-64 действует соглашение о вызовах System V AMD64 ABI, определяющее, как функции передают аргументы и возвращают результаты:

  • Аргументы (целые числа и указатели) передаются в регистрах: rdi, rsi, rdx, rcx, r8, r9. Остальные аргументы — на стеке.
  • Вещественные аргументы передаются в xmm0xmm7.
  • Возвращаемое значение — в raxrdx для 128-битных результатов).
  • Callee-saved регистры (должны быть сохранены вызываемой функцией): rbx, rbp, r12r15, rsp.
  • Caller-saved регистры (могут быть изменены функцией): rax, rcx, rdx, rsi, rdi, r8r11.

Основные инструкции

Пересылка данных

Инструкция mov — наиболее часто встречающаяся. Она копирует значение из источника в приёмник:

movq %rax, %rbx         # rbx = rax (64-бит)
movl $42, %eax          # eax = 42 (32-бит; верхние 32 бита rax обнуляются)
movb $0xFF, %al         # al = 0xFF (8-бит)
movq (%rax), %rbx       # rbx = *(uint64_t *)rax (загрузка из памяти)
movq %rax, (%rbx)       # *(uint64_t *)rbx = rax (сохранение в память)

Квадратные скобки в Intel-синтаксисе соответствуют круглым скобкам в AT&T: (%rax) означает «значение по адресу, хранящемуся в rax».

Адресация памяти в x86-64 поддерживает обобщённую форму disp(base, index, scale):

movl (%rbx),          %eax   # [rbx]
movl 8(%rbx),         %eax   # [rbx + 8]
movl (%rbx, %rcx),    %eax   # [rbx + rcx]
movl (%rbx, %rcx, 4), %eax   # [rbx + rcx*4]  — удобно для массивов
movl -4(%rbp),        %eax   # [rbp - 4]       — локальная переменная

Расширяющие пересылки применяются при загрузке узкого значения в широкий регистр:

movsbq %al,  %rax   # знаковое расширение byte -> qword
movzbq %al,  %rax   # нулевое расширение  byte -> qword
movswq %ax,  %rax   # знаковое расширение word -> qword
movslq %eax, %rax   # знаковое расширение long -> qword (= movsxd)

LEA (Load Effective Address) вычисляет адрес, не обращаясь к памяти:

leaq 8(%rax),          %rbx  # rbx = rax + 8  (без чтения из памяти)
leaq (%rax, %rcx, 4),  %rbx  # rbx = rax + rcx*4

LEA часто используется компиляторами для быстрого умножения на 2, 3, 4, 5, 8, 9 за одну инструкцию:

leaq (%rax, %rax, 4), %rax   # rax = rax + rax*4 = rax*5

Арифметические инструкции

addq %rbx, %rax         # rax += rbx
subq $10, %rax          # rax -= 10
imulq %rbx, %rax        # rax *= rbx (знаковое умножение)
idivq %rbx              # rax = rdx:rax / rbx; rdx = остаток (знаковое)
incq %rax               # rax++
decq %rax               # rax--
negq %rax               # rax = -rax

Перед idivq необходимо расширить знак rax в пару rdx:rax с помощью инструкции cqto (convert quad to octa).

Логические и сдвиговые инструкции

andq %rbx, %rax         # rax &= rbx  (побитовое И)
orq  %rbx, %rax         # rax |= rbx  (побитовое ИЛИ)
xorq %rbx, %rax         # rax ^= rbx  (побитовое XOR)
notq %rax               # rax = ~rax  (побитовое НЕ)
shlq $3, %rax           # rax <<= 3   (логический сдвиг влево)
shrq $3, %rax           # rax >>= 3   (логический сдвиг вправо)
sarq $3, %rax           # rax >>= 3   (арифметический сдвиг, сохраняет знак)

Типичный идиом для обнуления регистра — xorq %rax, %rax: эта инструкция короче movq $0, %rax и на один байт компактнее в машинном представлении.

Пример: функция на ассемблере

Следующий фрагмент определяет функцию add_numbers, принимающую два аргумента в rdi и rsi и возвращающую их сумму в rax:

    .globl add_numbers
    .type  add_numbers, @function
add_numbers:
    movq %rdi, %rax     # rax = первый аргумент
    addq %rsi, %rax     # rax += второй аргумент
    ret                 # возврат; результат в rax

Объявление и использование из C:

long add_numbers(long a, long b);

int main(void) {
    long result = add_numbers(5, 3);  /* rdi=5, rsi=3 -> rax=8 */
    return 0;
}

Сборка:

gcc -c add_numbers.s -o add_numbers.o
gcc -c main.c -o main.o
gcc add_numbers.o main.o -o program

Связанные темы

Источники

  • man 1 as — документация GNU Assembler
  • man 1 gcc — компилятор GCC и флаги ассемблирования
  • System V Application Binary Interface AMD64 Supplement: https://gitlab.com/x86-psABIs/x86-64-ABI
  • Intel 64 and IA-32 Architectures Software Developer's Manual: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
  • AT&T Assembly Syntax Overview (GAS manual): https://sourceware.org/binutils/docs/as/