Решаем задачи Advent of Code 2020 на GNU Assembler — часть 1
Оглавление
Эта серия — эксперимент в том, сколько можно достичь, используя только самые базовые инструменты.
У нас есть регистры, стек и инструкция syscall
— остальное придётся реализовать самим.
Уточним условия:
- Используем только GNU Assembler и его встроенный препроцессор.
- Все бинарники должны быть статическими и freestanding. Не опираемся на
libc
. - Решение не обязано быть идеально корректным — к примеру, размер буфера можно определять, исходя из реальных входных данных. Единственная цель — получить правильный ответ.
- Единственная поддерживаемая платформа — Linux x86_64.
Код будет подробно прокомментирован, но от читателя всё-таки ожидается базовое понимание синтаксиса языка ассемблера.
Структура кода
Для начала, давайте настроим систему сборки: я хочу сделать это один раз и больше об этом не думать. Для этого будем использовать just — Make слишком сложный для наших целей.
Все файлы, относящиеся к задаче, будем группировать в директории с её номером (к примеру, первая
задача будет лежать в директории 1
). Остальная структура такая:
Путь | Значение |
---|---|
code.s | Код самого решения |
inp.txt | Входные данные |
example.txt | Пример входных данных |
Чтобы не делать на ассемблере парсинг аргументов, введём простую конвенцию: запуск исполняемого
файла без аргументов вызывает решение первой части задачи, а с одним аргументом -
— второй.
build target:
# Собираем объектный файл
as "{{target}}/code.s" -o "{{target}}/obj.o"
# И линкуем его в исполняемый
ld "{{target}}/obj.o" -o "{{target}}/exe"
# Запуск первой части на основном вводе
run target: (build target)
"{{target}}/exe" < "{{target}}/inp.txt"
# Второй части на основном вводе
run2 target: (build target)
"{{target}}/exe" - < "{{target}}/inp.txt"
# Первой части на примере
runex target: (build target)
"{{target}}/exe" < "{{target}}/example.txt"
# Второй части на примере
runex2 target: (build target)
"{{target}}/exe" - < "{{target}}/example.txt"
Лирическое отступление: calling conventions
Большая часть языков имеет какое-то мнение по поводу того, как нужно вызывать функции. К примеру,
современный C на Linux x86_64 использует System V ABI: целочисленные аргументы в функцию передаются
в регистрах rdi
, rsi
, rdx
, rcx
, r8
и r9
, возвращаемое значение записывается в rax
и функция обязана сохранить значения регистров rbx
, r12
, r13
, r14
и r15
(а также состояние
стека). В ассемблере нет «функций» в прямом смысле этого слова: инструкции call
/ret
это примерный
аналог следующего:
# Запомним на стеке, где мы были
push rip
# И перейдём в функцию
jmp func
# Удалим адрес возврата со стека
add rsp, 8
# И вернёмся туда
jmp [rsp - 8]
Таким образом, мы имеем свободу самостоятельно выбрать конвенцию вызовов для наших функций. Мы немного модифицирем System V ABI: по меньшей мере, хочется иметь возможность возвращать из функции несколько значений. Вот таблица, отражающая нашу конвенцию:
Таблица регистров
Регистр | Смысл |
---|---|
rdi | Аргумент 1 |
rsi | Аргумент 2 |
rdx | Аргумент 3 |
r10 | Аргумент 4 |
r8 | Аргумент 5 и возврат 5 |
r9 | Аргумент 6 и возврат 6 |
rax | Возврат 1 (и номер системного вызова для syscall ) |
rbx | Возврат 2 |
rcx | Возврат 3 |
rdx | Возврат 4 |
r1[2345] | Вызываемая функция должна сохранить |
Стандартная библиотека
Нам потребуется какой-то базовый инструментарий, который мы будем использовать в каждой
задаче. Создадим файл stdlib.s
— его мы будем подключать во все остальные файлы:
# Мы хотим использовать нормальный синтаксис
.intel_syntax noprefix
# Сохраняем себе `\n` для дальнейшего использования
.section .rodata
newline: .ascii "\n"
# Задаём номера системных вызовов, чтобы не писать их вручную
.set SYS_READ, 0
.set SYS_WRITE, 1
.set SYS_EXIT, 60
# И номера стандартных файловых дескрипторов
.set STDIN, 0
.set STDOUT, 1
.set STDERR, 2
# Первая интересная часть: положить в регистр `reg` функцию `first`,
# если мы решаем первую часть задачи, и функцию `second`,
# если вторую:
.macro chooseimpl reg, first, second
# Поместим для начала в регистр первую функцию
mov \reg, offset \first
# И достанем в `rax` значение на вершине стека.
# В Linux это значение на старте программы — количество
# аргументов командной строки, аналог `argc` в C.
mov rax, [rsp]
# Если аргумент один, то это название программы, и это первая
# часть. Если больше, то вторая.
mov rbx, 1
cmp rax, rbx
# Если кроме названия программы аргументов нет,
# то выходим из макроса
je .Lchooseimpl.end
# Иначе помещаем в регистр вторую функцию
mov \reg, offset \second
.Lchooseimpl.end:
.endm
# Добавим синтаксис `exit <code>` для выхода из программы
.macro exit code
mov rax, SYS_EXIT
mov rdi, \code
syscall
.endm
# Для всех системных вызовов, кроме `exit`, нам нужно как-то
# обрабатывать возможную ошибку. Для простоты будем просто
# завершать программу с ненулевым кодом возврата:
# type: () -> !
.global trysyscall_die
trysyscall_die:
# В `rax` лежит код возврата от системного вызова
# Он отрицательный, поэтому инвертируем его перед тем,
# как передать в `exit`.
mov rdi, rax
neg rdi
mov rax, SYS_EXIT
syscall
# Для удобства обернём инструкцию `syscall`, чтобы она завершала
# программу при ошибке
.macro trysyscall
syscall
# Если `rax` меньше 0, то вызываем `trysyscall_die`
test rax, rax
js trysyscall_die
Кроме макросов, нам потребуются функции ввода-вывода.
Вывод на экран
Начнём с вывода на экран: определим функцию fputsn
, которая выводит n
байт из буфера на заданный файловый дескриптор fd
:
# type: (int fd, char* buf, int n) -> void
.global fputsn
fputsn:
# Поместим в `rax` номер системного вызова
mov rax, SYS_WRITE
# Остальные аргументы уже на своих местах: сигнатура `fputsn`
# соответствует сигнатуре самого системного вызова `write`
trysyscall
# Теперь в `rax` лежит количество выведенных байтов
# Если мы вывели на экран всю строку, то выходим из функции
cmp rax, rdx
je .Lfputsn.end
# В противном случае, уменьшаем `n`, сдвигаем `buf` и повторяем
sub rdx, rax
add rsi, rax
jmp fputsn
.Lfputsn.end:
ret
Остальные функции вывода (putsn
, fputnl
— выводит символ новой строки, putnl
) тривиально
определяются через fputsn
, поэтому не будем заострять на них внимание.
Ввод с клавиатуры
Для ввода нам хватит одной функции readline
, которая читает байты из STDIN до символа новой
строки или конца файла. Это первый случай, когда мы будем возвращать два значения: мы хотим
вернуть количество прочитанных байт, и то, дошли ли мы до EOF.
# Принимаем буфер, в который будем класть данные
# Считаем, что переполнений буфера не существует
# Возвращаем флаг «мы не дошли до EOF» и количество байт
# type: (char* buf) -> (bool newline, int num)
.global readline
readline:
# Сохраним исходный указатель в `rbx`, чтобы потом
# вычислить количество прочитанных байт
mov rbx, rdi
# Его же положим в `rsi`, так как он — второй аргумент
# системного вызова `read`
mov rsi, rdi
# Сохраним символ новой строки в `r10`
mov r10, 10
# И добавим оставшийся аргумент `read` — файловый дескриптор
mov rdi, STDIN
.Lreadline.loop:
# Мы хотим прочитать один байт с STDIN
# Если бы мы читали бОльшими кусками, нам нужен был бы
# отдельный буфер ввода для построчного чтения
mov rax, SYS_READ
mov rdx, 1
trysyscall
# Если мы прочитали 0 байт (достигнут EOF), то выходим
test rax, rax
jz .Lreadline.end
# Вытащим прочитанный байт из буфера в регистр
# Тут легко ошибиться: если написать `r8` вместо `r8b`, то
# мы попытаемся прочитать 8 байт и, возможно, вызовем SIGSEGV
mov r8b, [rsi]
# Поставим флаг `newline` в 1 на случай, если сейчас выйдем
mov rax, 1
# Если мы нашли перенос строки, то выходим из функции
cmp r8b, r10b
je .Lreadline.end
# В противном случае — перемещаем указатель на буфер и повторяем
inc rsi
jmp .Lreadline.loop
.Lreadline.end:
# Кладём количество прочитанных байт в `rbx`
sub rsi, rbx
mov rbx, rsi
# `rax` нам трогать не надо: он либо равен 0 из-за EOF,
# либо мы выставили его в 1 перед проверкой на `newline`
ret
Конвертация строки в число (и наоборот)
Так как многие из задач Advent of Code (включая первую), работают с числами, нам потребуется способ превращать их в строки и из строк. Напишем функции в стандартную библиотеку для этого.
Строку в число
Тут алгоритм простой: идём по строке с конца, умножаем на текущий разряд, складываем вместе.
# Конвертирует строку в число
# type: (char* buf, int len) -> int
.global stoi
stoi:
# Положим в `rsi` указатель на конец строки
add rsi, rdi
# Обнулим возвращаемое значение
xor rax, rax
# И заведём счётчик разрядов
mov r9, 1
.Lstoi.loop:
# Сдвинем `rsi` к началу строки
# На первой итерации `rsi` станет показывать на
# последний элемент
dec rsi
# Достанем один байт по указателю
# Мы хотим работать с ним как с 64-битным целым,
# поэтому зануляем все остальные биты
xor rcx, rcx
mov cl, [rsi]
# Превращаем символ цифры в цифру, вычитая '0'
sub rcx, 48
# Домножаем цифру на разряд
# Используем знаковое умножение, у `mul` менее удобный интерфейс
imul rcx, r9
# Добавляем разряд к результату
add rax, rcx
# И увеличиваем счётчик разрядов
imul r9, 10
# Если не дошли до начала массива, повторяем
cmp rdi, rsi
jne .Lstoi.loop
ret
Числа в строку
Тут всё немного интереснее. Наивным подходом (взять последнюю цифру, записать в ответ, поделить число на десять) мы получим перевёрнутую строку. Мы не можем здесь «идти с конца», так как у нас нет ни способа получить первую цифру, ни знания, какой длины число в итоге получится.
Интересно, что способа превратить десятичное число в строку, не «пройдя» по нему два раза не существует1 — разные имплементации либо переворачивают результирующую строку, либо просто записывают её в буфер достаточного размера с конца, и потом копируют в нужное место.
Мы пойдём простым путём — напишем сначала хелпер, который будет переворачивать строку:
# Переворачиваем строку длины `len` на месте
# type: (char* buf, int len) -> void
.global reverse_string
reverse_string:
# Положим указатель на конец строки в `rsi`
add rsi, rdi
# Если длина строки <= 0, то завершаем работу
cmp rdi, rsi
jge .Lreverse_string.end
# Сдвигаем указатель к последнему символу
dec rsi
.Lreverse_string.loop:
# Достаём символы по обоим указателям в регистры
mov cl, [rsi]
mov dl, [rdi]
# И кладём их в обратном порядке
mov [rdi], cl
mov [rsi], dl
# Двигаем указетели друг к другу
dec rsi
inc rdi
# Если они не «столкнулись» — повторяем
jl .Lreverse_string.loop
.Lreverse_string.end:
ret
И, с его помощью, несложно написать основную реализацию:
# Конвертирует число в строку
# Опять же, считаем, что переполнений буфера не бывает
# Возвращаем длину результирующей строки
# type: (int n, char* buf) -> int
.global itos
itos:
# Нам нужно положить число в `rax`, чтобы использовать `div`
mov rax, rdi
# Скопируем указатель на буфер: нам ещё потребуется исходный
mov rdi, rsi
# Сохраним систему счисления для сдвига
mov r9, 10
.Litos.loop:
# Делимое для `div` это 128-битное число `rdx:rax`. Так как
# мы хотим поделить просто `rax`, `rdx` надо обнулить.
xor rdx, rdx
# Осуществляем деление! Эта инструкция делит `rdx:rax` на `r9`
# Результат будет в `rax`, а остаток в `rdx`
div r9
# Переводим остаток в символ добавлением '0'
add rdx, 48
# Записываем его в результирующую строку
mov [rsi], dl
# Сдвигаем указатель на буфер вперёд
inc rsi
# И, если число ещё не 0, повторяем
test rax, rax
jnz .Litos.loop
# Конец цикла
# Считаем, сколько цифр мы записали
sub rsi, rdi
# Сохраняем `rsi` на стеке — это наш результат
push rsi
# Переворачиваем строку — мы записывали цифры в обратном порядке
call reverse_string
# Достаём результат в `rax` и возвращаем
pop rax
ret
Заключение
Итого у нас на руках есть базовые примитивы ввода-вывода — мы можем прочитать число с клавиатуры и вывести ответ на экран. Этого вполне хватит, чтобы решить первую задачу Advent of Code 2020 — чем мы и займёмся в следующей серии.
Технически говоря, это не совсем правда — можно было бы посчитать десятичный логарифм, чтобы оценить количество знаков, и идти с начала числа. Однако подсчёт логарифма это довольно дорогая операция, так что так почти никто не делает.
[назад]