Язык ассемблера
Наш подопытный кролик — x86-64
Компьютер IBM PC, выпущенный в 1981 году, оснащался процессором Intel 8088, а более поздние модели — процессорами 80286, 80386 и 80486, поэтому ISA стала известна как x86.
В этом тысячелетии компания AMD расширила x86 для поддержки 64-битных чисел, и результат сначала назывался x86-64, а потом разными другими словами.
Машинный код и язык ассемблера
Читать инструкции процессора в виде чисел очень неудобно (а писать тем более). Рассмотрим для примера реализацию алгоритма Евклида для 32-битных чисел на x86:
39 c8 74 07 77 01 91 29 c8 eb f5
Допустим, она расположена в памяти начиная с адреса 0x43210, и IP (instruction pointer) процессора равен 0x43210. Посмотрим, что будут значить для процессора эти байты:
43210: ┌─► 39 c8 // сравнить регистры 0 и 1
43212: │ 74 07 ─┐ // если числа равны, прибавить к IP число 0x07 = 7
43214: │┌─ 77 01 │ // если первое число больше, прибавить к IP число 0x01 = 1
43216: ││ 91 │ // поменять местами регистры 0 и 1
43217: │└► 29 c8 │ // вычесть регистр 1 из регистра 0
43219: └── eb f5 │ // прибавить к IP число 0xf5 = −11
4321b: ... ◄┘ // предположительно дальше там вывод результата
Для инструкций придумывают названия (мнемоники) и правила записи их операндов, а потом делают конвертор из такого текстового представления в двоичное (машинный код). Такой конвертор называется ассемблером, а текстовое представление инструкций — языком ассемблера.
43210: 39 c8 cmp eax, ecx // сравнить регистры 0 и 1
43212: 74 07 je 0x4321b // если числа равны, прибавить к IP число 0x07 = 7
43214: 77 01 ja 0x43217 // если первое число больше, прибавить к IP число 0x01 = 1
43216: 91 xchg eax, ecx // поменять местами регистры 0 и 1
43217: 29 c8 sub eax, ecx // вычесть регистр 1 из регистра 0
43219: eb f5 jmp 0x43210 // прибавить к IP число 0xf5 = −9
4321b: ...
В мире x86 исторически больше всего используются два синтаксиса языка ассемблера: AT&T vs Intel. Эти же инструкции в синтаксисе AT&T выглядят так:
cmp %ecx,%eax
je ...
ja ...
xchg %eax,%ecx
sub %ecx,%eax
jmp ...
Можно заметить, что мнемоники инструкций в основном те же, но операнды записываются иначе и идут в другом порядке.
Мы будем использовать синтаксис Intel.
Регистры
«Переменные» внутри процессора.
von Neumann closer to reality
┌────────────────┐ ┌────────────────┐
│ CPU │ │ CPU │
│ │ │ │
│ ┌────────────┐ │ │ │
│ │Control unit│ │ │ Registers │
│ │ │ │ │ │
│ │IP │ │ │ (including IP) │
│ └────────────┘ │ │ │
│ │ │ │
└────────┬───────┘ └────────┬───────┘
│ │
│ │
┌────────┴───────┐ ┌────────┴───────┐
│ Memory │ │ Cache(s) │
│ │ │ │
│ │ │ │
│ │ └────────┬───────┘
│ │ │
│ │ ┌────────┴───────┐
│ │ │ RAM │
│ │ │ │
│ │ │ │
└────────────────┘ └────────────────┘
Instruction pointer (program counter): rip
.
Регистры общего назначения (general purpose registers):
Регистр | Младшие 32 бита | Младшие 16 бит | Два младших байта по отдельности |
---|---|---|---|
rax | eax | ax | ah , al |
rbx | ebx | bx | bh , bl |
rcx | ecx | cx | ch , cl |
rdx | edx | dx | dh , dl |
rsi | esi | si | —, sil |
rdi | edi | di | —, dil |
rsp | esp | sp | —, spl |
rbp | ebp | bp | —, bpl |
r8 | r8d | r8w | —, r8b |
... | |||
r15 | r15d | r15w | —, r15b |
(Регистры *sp
имеют специальное значение, мы их пока не трогаем.)
Первые инструкции
Инструкция выглядит примерно так: мнемоника операнд, операнд
.
Наша первая мнемоника: mov
.
mov DST, SRC // копировать SRC в DST
mov ebx, eax // скопировать биты eax в ebx
// и выставить старшие 32 бита rbx в 0;
// старое значение rbx теряется
mov ax, bx
mov ah, bl
Справочник по вариантам инструкции MOV
Непосредственно заданный операнд:
mov rcx, 42 // положить в rcx битовое представление числа 42
mov rdx, 0x80 // шестнадцатеричная запись операнда
mov eax, -1 // установить все биты eax в 1 (а старшие биты rax в 0)
Библиотека simpleio
call writei32 // напечатать на экране значение edi
// как знаковое десятичное число
call writei64 // вывести rdi
call readi32 // ввести с клавиатуры число и сохранить в eax
call readi64 // ввести с клавиатуры число и сохранить в rax
call finish // завершить исполнение программы
Любой вызов подпрограммы сохраняет значения регистров
rbx, rsp, rbp, r12–r15
. Остальные регистры могут измениться произвольным образом.
Наша первая программа на языке ассемблера x86, вычисляющая сумму двух чисел:
.intel_syntax noprefix
.global main
main:
call readi64 // считали первое число
mov r12, rax // сохранили его в r12
call readi64 // считали второе число
add r12, rax // сложили первое и второе
mov rdi, r12
call writei64 // вывели результат
call finish // завершили программу
Сохраним её в файл sum.S
(да, заглавная S), оттранслируем и запустим:
$ gcc -g sum.S simpleio_x86_64.S -o sum
$ ./sum
Некоторые арифметические инструкции
add DST, SRC // DST += SRC
sub DST, SRC // DST -= SRC
inc DST // DST++
dec DST // DST--
neg DST // DST = -DST
not DST // DST = ~DST
and DST, SRC // DST &= SRC
or DST, SRC // DST |= SRC
xor DST, SRC // DST ^= SRC
Инструкции сдвига
Логический сдвиг: двигаем биты внутри регистра, дополняя его нулями и теряя то, что «выпало».
mov ax, 0x1234
shr ax, 4 // ax == 0x0123
shl ax, 4 // ax == 0x1230
add ax, 7 // ax == 0x1237
ror ax, 4 // ax == 0x7123
Арифметический сдвиг вправо: двигаем биты, дополняя слева знаковым битом
mov ax, 0xfff0 // ax == -16
sar ax, 4 // ax == 0xffff == -1
sal ax, 5 // ax == 0xfff0 == -16
См. также справочник.