goldstein's blog

Решаем задачи Advent of Code 2020 на GNU Assembler — часть 1

Оглавление


Эта серия — эксперимент в том, сколько можно достичь, используя только самые базовые инструменты. У нас есть регистры, стек и инструкция syscall — остальное придётся реализовать самим. Уточним условия:

  1. Используем только GNU Assembler и его встроенный препроцессор.
  2. Все бинарники должны быть статическими и freestanding. Не опираемся на libc.
  3. Решение не обязано быть идеально корректным — к примеру, размер буфера можно определять, исходя из реальных входных данных. Единственная цель — получить правильный ответ.
  4. Единственная поддерживаемая платформа — Linux x86_64.

Код будет подробно прокомментирован, но от читателя всё-таки ожидается базовое понимание синтаксиса языка ассемблера.

Структура кода

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

Все файлы, относящиеся к задаче, будем группировать в директории с её номером (к примеру, первая задача будет лежать в директории 1). Остальная структура такая:

ПутьЗначение
code.sКод самого решения
inp.txtВходные данные
example.txtПример входных данных

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

Just
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 это примерный аналог следующего:

GNU Assembly
# Запомним на стеке, где мы были
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 — его мы будем подключать во все остальные файлы:

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:

stdlib.s
# 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.

stdlib.s
# Принимаем буфер, в который будем класть данные
# Считаем, что переполнений буфера не существует
# Возвращаем флаг «мы не дошли до 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 (включая первую), работают с числами, нам потребуется способ превращать их в строки и из строк. Напишем функции в стандартную библиотеку для этого.

Строку в число

Тут алгоритм простой: идём по строке с конца, умножаем на текущий разряд, складываем вместе.

stdlib.s
# Конвертирует строку в число
# 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 — разные имплементации либо переворачивают результирующую строку, либо просто записывают её в буфер достаточного размера с конца, и потом копируют в нужное место.

Мы пойдём простым путём — напишем сначала хелпер, который будет переворачивать строку:

stdlib.s
# Переворачиваем строку длины `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

И, с его помощью, несложно написать основную реализацию:

stdlib.s
# Конвертирует число в строку
# Опять же, считаем, что переполнений буфера не бывает
# Возвращаем длину результирующей строки
# 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 — чем мы и займёмся в следующей серии.


1

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

[назад]