Пишем printf на языке ассемблера FASM
Введение
Иногда, и чаще всего спонтанно, у меня появляется дикое желание реализовывать что-либо на языке ассемблера, а потом прикручивать это «что-либо» на уровни выше. Так например, ранее из-за такого желания я написал сначала стековую виртуальную машину, которая могла принимать в себя байт-код и непосредственно его исполнять, далее написал ассемблер, который мог бы транслировать язык ассемблера в этот самый байт-код, а после и написал высокоуровневый LISP-подобный язык, который компилировался в ассемблерный код ранее написанной виртуальной машины.
И примерно на такой же реактивной тяге желания у меня появилась идея реализовать с нуля функцию printf, которую можно было бы далее подключать к языку Си и пользоваться ею на здоровье без стандартных библиотек. И здесь можно задаться вполне логичным вопросом — зачем и для чего? Для этого вопроса я подготовил два ответа: 1) просто было интересно; 2) printf может помочь студентам изучающим ассемблер.
Второй пункт всё же требует более детальных пояснений. На предметах ОАиП (основы алгоритмизации и программирования) или ААС (архитектура аппаратных средств) студентам иногда может потребоваться написать что-либо на языке ассемблера, чтобы сдать успешно лабораторную работу. И когда появляются ошибки, то студент может потратить несколько минут/часов на поиск бага. С отладчиками не все умеют работать, но с принтами все справляются отлично. И данный printf может как раз выполнять эту самую роль — чтобы буквально выводить числа, строки, символы хранимые в регистрах.
Реализация
Наш printf будет минималистичным и ограниченным лишь тремя-четырьмя спец-символами: %s
, %c
, %d
и %%
. Я не стал писать реализацию с плавающими числами %f
, а также удалил реализации с %x
(hex, 16-ыми), %o
(oct, 8-ыми) и %b
(bin, 2-ыми) числами, чтобы конечный код можно было легко понять и прочитать. Также весь нижеизложенный код не будет претендовать на оптимизированность вычислений, скорее даже наоборот, он будет действовать исключительно ради простоты своего объяснения.
Первое с чего следует начать — это непосредственно с самой печати символов, а следовательно с системных вызовов. Весь последующий код будет представлен на Linux платформе с 64-битной архитектурой. Но не стоит пугаться того факта, если у вас ОС не является Linux’ом. Суть заключается в том, что в моей реализации обращение к системным вызовам происходит лишь из одной части кода. Весь же оставшийся код — это чистый алгоритм. Следовательно, чтобы код заработал на другой платформе, например Windows, необходимо будет изменить лишь эту малую часть кода, не затрагивая при этом всё остальное.
Функция печати одного символа у нас будет выглядеть следующим образом. На этой функции все дальнейшие системные вызовы собственно и заканчиваются. Таким образом, если код будет адаптироваться под другую платформу, то всё что необходимо будет сделать — это переписать print_char
.
section '.print_char' executable
; | input
; rax = char
print_char:
push rax
push rdx
push rsi
push rdi
push rax
mov rsi, rsp ; начало области памяти
mov rdi, 1 ; 1 байт
mov rdx, 1 ; stdout
mov rax, 1 ; write
call do_syscall
pop rax
pop rdi
pop rsi
pop rdx
pop rax
ret
section '.do_syscall' executable
do_syscall:
; syscall rewrite (rcx, r11)
push rcx
push r11
syscall
pop r11
pop rcx
ret
В архитектуре amd64 нет инструкций по подобию pusha / popa, которые бы автоматически загрузили значения регистров в стек и выгрузили их, поэтому приходится делать всё вручную. Далее, значение регистра rax кладётся в стек, для того, чтобы 64-битный системный вызов смог успешно считать один символ из стека. Более подробно с большинством системных вызовов под Linux x86–64 можно ознакомиться здесь.
Системный вызов syscall был обёрнут в отдельную процедуру do_syscall. Суть такого манёвра заключается в том, что 64-битный syscall изменяет неявно регистры rcx и r11, что может в будущем повлиять на некорректную работу.
Пример работы `print_char`
Код
; main.asm
format ELF64
public _start
section '.text' executable
_start:
mov rax, 'h'
call print_char
mov rax, 'e'
call print_char
mov rax, 'l'
call print_char
mov rax, 'l'
call print_char
mov rax, 'o'
call print_char
mov rax, 0xA
call print_char
exit:
mov rax, 60
xor rdi, rdi
syscall
section '.print_char' executable
; | input
; rax = char
print_char:
push rax
push rdx
push rsi
push rdi
push rax
mov rsi, rsp
mov rdi, 1
mov rdx, 1
mov rax, 1
call do_syscall
pop rax
pop rdi
pop rsi
pop rdx
pop rax
ret
section '.do_syscall' executable
do_syscall:
push rcx
push r11
syscall
pop r11
pop rcx
ret
Запуск
$ fasm main.asm && ld main.o -o main && ./main
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
3 passes, 1168 bytes.
hello
Теперь из печати одного символа мы можем выразить печать целой строки. Хоть системный вызов write и может вывести сразу всю строку, что несомненно уменьшает итоговое количество затратных системных вызовов, тем не менее, вывод строки из посимвольного вывода символов даёт два преимущества: 1) вывод строки становится независим от системных вызовов; 2) нам не нужна доп. информация в лице количества символов в строке — достаточно будет читать лишь до нулевого символа (старый добрый Сишный стиль).
section '.print_string' executable
; | input
; rax = string
print_string:
push rbx
xor rbx, rbx
.next_iter:
cmp [rax+rbx], byte 0
je .close
push rax
mov rax, [rax+rbx]
call print_char
pop rax
inc rbx
jmp .next_iter
.close:
pop rbx
ret
Алгоритм процедуры print_string
крайне прост:
Прочитать i-ный символ в строке,
Если i-ный символ равен нулевому, тогда закрыть выполнение,
Напечатать символ (print_char),
Инкрементировать значение i,
Перейти на пункт [1].
Пример работы `print_string`
Код
; main.asm
format ELF64
public _start
section '.data' writeable
string db "hello", 0xA, 0
section '.text' executable
_start:
mov rax, string
call print_string
exit:
mov rax, 60
xor rdi, rdi
syscall
section '.print_string' executable
; | input
; rax = string
print_string:
push rbx
xor rbx, rbx
.next_iter:
cmp [rax+rbx], byte 0
je .close
push rax
mov rax, [rax+rbx]
call print_char
pop rax
inc rbx
jmp .next_iter
.close:
pop rbx
ret
section '.print_char' executable
; | input
; rax = char
print_char:
push rax
push rdx
push rsi
push rdi
push rax
mov rsi, rsp
mov rdi, 1
mov rdx, 1
mov rax, 1
call do_syscall
pop rax
pop rdi
pop rsi
pop rdx
pop rax
ret
section '.do_syscall' executable
do_syscall:
push rcx
push r11
syscall
pop r11
pop rcx
ret
Запуск
fasm main.asm && ld main.o -o main && ./main INT ✘
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
4 passes, 1240 bytes.
$ hello
Таким образом, мы реализовали уже вывод символа, вывод строки и остаётся реализовать лишь вывод числа и после уже всё это имплементировать в printf функцию. Вывод числа представляет собой более сложную процедуру, потому как нужно не только печатать посимвольно цифра-за-цифрой всё число, но и также учитывать какое число — отрицательное или положительное.
section '.print_decimal' executable
; | input:
; rax = number
print_decimal:
push rax
push rbx
push rcx
push rdx
xor rcx, rcx
cmp rax, 0
jl .is_minus
jmp .next_iter
.is_minus:
neg rax
push rax
mov rax, '-'
call print_char
pop rax
.next_iter:
mov rbx, 10
xor rdx, rdx
div rbx
push rdx
inc rcx
cmp rax, 0
je .print_iter
jmp .next_iter
.print_iter:
cmp rcx, 0
je .close
pop rax
add rax, '0'
call print_char
dec rcx
jmp .print_iter
.close:
pop rdx
pop rcx
pop rbx
pop rax
ret
Алгоритм процедуры print_decimal
следующий:
Если число меньше нуля, тогда напечатать символ минус,
Разделить число на 10, взять частное и остаток от деления,
Положить остаток в стек,
Инкрементировать значение i,
Если частное не равно 0, тогда перейти на пункт [2],
Если значение i равно 0, тогда закрыть выполнение,
Выгрузить число из стека (остаток),
Прибавить к остатку символ '0' (число 48 по ASCII),
Напечатать получившийся символ (print_char),
Декрементировать значение i,
Перейти на пункт [6].
В этой реализации стоит учитывать один момент — процедура print_decimal
работает с 64-битными числами, а следовательно и отрицательное число она будет трактовать лишь когда таковое будет 64-битным. В противном случае, если число 32-битное отрицательное, то оно со стороны 64-битного числа будет трактоваться как unsigned, то есть число без знака.
Пример работы `print_decimal`
Код
; main.asm
format ELF64
public _start
section '.text' executable
_start:
mov rax, 571
call print_decimal
mov rax, 0xA
call print_char
exit:
mov rax, 60
xor rdi, rdi
syscall
section '.print_decimal' executable
; | input:
; rax = number
print_decimal:
push rax
push rbx
push rcx
push rdx
xor rcx, rcx
cmp rax, 0
jl .is_minus
jmp .next_iter
.is_minus:
neg rax
push rax
mov rax, '-'
call print_char
pop rax
.next_iter:
mov rbx, 10
xor rdx, rdx
div rbx
add rdx, '0'
push rdx
inc rcx
cmp rax, 0
je .print_iter
jmp .next_iter
.print_iter:
cmp rcx, 0
je .close
pop rax
call print_char
dec rcx
jmp .print_iter
.close:
pop rdx
pop rcx
pop rbx
pop rax
ret
section '.print_char' executable
; | input
; rax = char
print_char:
push rax
push rdx
push rsi
push rdi
push rax
mov rsi, rsp
mov rdi, 1
mov rdx, 1
mov rax, 1
call do_syscall
pop rax
pop rdi
pop rsi
pop rdx
pop rax
ret
section '.do_syscall' executable
do_syscall:
push rcx
push r11
syscall
pop r11
pop rcx
ret
Запуск
$ fasm main.asm && ld main.o -o main && ./main ✔
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
4 passes, 1224 bytes.
571
И наконец-то мы приступаем к финалу — написанию функции printf. Здесь логика выполнения уже достаточно простая, потому как основные алгоритмические действия были выполнены. Остаётся лишь добавить все вышеописанные функции в одну композицию.
section '.printf' executable
; | input:
; rax = format
; stack = values
; | output:
; rax = count
printf:
push rbx
push rcx
; call/ret = 8byte
; rax+rbx+rcx = 24byte
mov rbx, 32
; count of format elements
xor rcx, rcx
.next_iter:
cmp [rax], byte 0
je .close
cmp [rax], byte '%'
je .special_char
jmp .default_char
.special_char:
inc rax
cmp [rax], byte 's'
je .print_string
cmp [rax], byte 'd'
je .print_decimal
cmp [rax], byte 'c'
je .print_char
cmp [rax], byte '%'
je .default_char
jmp .is_error
.print_string:
push rax
mov rax, [rsp+rbx]
call print_string
pop rax
jmp .shift_stack
.print_decimal:
push rax
mov rax, [rsp+rbx]
call print_decimal
pop rax
jmp .shift_stack
.print_char:
push rax
mov rax, [rsp+rbx]
call print_char
pop rax
jmp .shift_stack
.default_char:
push rax
mov rax, [rax]
call print_char
pop rax
jmp .next_step
.shift_stack:
inc rcx
add rbx, 8
.next_step:
inc rax
jmp .next_iter
.is_error:
mov rcx, -1
.close:
mov rax, rcx
pop rcx
pop rbx
ret
Несмотря на более масштабное количество кода в сравнении с прошлыми кодами, логика остаётся простой:
Прочитать i-ный символ в строке,
Если символ равен %, тогда перейти на пункт [6],
Если символ равен нулю, тогда завершить выполнение,
Иначе, напечатать i-ный символ,
Инкрементировать значение i,
Перейти на пункт [1],
Инкрементировать значение i,
Если i-ный символ равен %, тогда напечатать его,
Если i-ный символ равен d, тогда взять из стека значение и напечатать число (print_decimal),
Если i-ный символ равен s, тогда взять из стека значение и напечатать строку (print_string),
Если i-ный символ равен c, тогда взять из стека значение и напечатать символ (print_char),
Иначе, вернуть ошибку и перейти на пункт [3],
Инкрементировать значение i,
Перейти на пункт [1].
В этом алгоритме я не рассказал лишь об одном действии — подсчёте количества обработанных формат-строк, то есть %c
, %s
, %d
, %%
.
Пример работы `printf`
Код
; main.asm
format ELF64
public _start
extrn printf
section '.data' writeable
input db "{ %s, %d%c }", 0xA, 0
string db "hello", 0
decimal dq 571
symbol dq '!'
section '.text' executable
_start:
mov rax, input
push [symbol]
push [decimal]
push string
call printf
exit:
mov rax, 60
xor rdi, rdi
syscall
; printf.asm
format ELF64
include "print_decimal.asm"
include "print_string.asm"
include "print_char.asm"
public printf
section '.printf' executable
; | input:
; rax = format
; stack = values
; | output:
; rax = count
printf:
push rbx
push rcx
; call/ret = 8byte
; rax+rbx+rcx = 24byte
mov rbx, 32
; count of format elements
xor rcx, rcx
.next_iter:
cmp [rax], byte 0
je .close
cmp [rax], byte '%'
je .special_char
jmp .default_char
.special_char:
inc rax
cmp [rax], byte 's'
je .print_string
cmp [rax], byte 'd'
je .print_decimal
cmp [rax], byte 'c'
je .print_char
cmp [rax], byte '%'
je .default_char
jmp .is_error
.print_string:
push rax
mov rax, [rsp+rbx]
call print_string
pop rax
jmp .shift_stack
.print_decimal:
push rax
mov rax, [rsp+rbx]
call print_decimal
pop rax
jmp .shift_stack
.print_char:
push rax
mov rax, [rsp+rbx]
call print_char
pop rax
jmp .shift_stack
.default_char:
push rax
mov rax, [rax]
call print_char
pop rax
jmp .next_step
.shift_stack:
inc rcx
add rbx, 8
.next_step:
inc rax
jmp .next_iter
.is_error:
mov rcx, -1
.close:
mov rax, rcx
pop rcx
pop rbx
ret
; print_char.asm
section '.print_char' executable
; | input
; rax = char
print_char:
push rax
push rdx
push rsi
push rdi
push rax
mov rsi, rsp
mov rdi, 1
mov rdx, 1
mov rax, 1
call do_syscall
pop rax
pop rdi
pop rsi
pop rdx
pop rax
ret
section '.do_syscall' executable
do_syscall:
; syscall rewrite (rcx, r11)
push rcx
push r11
syscall
pop r11
pop rcx
ret
; print_string.asm
section '.print_string' executable
; | input
; rax = string
print_string:
push rbx
xor rbx, rbx
.next_iter:
cmp [rax+rbx], byte 0
je .close
push rax
mov rax, [rax+rbx]
call print_char
pop rax
inc rbx
jmp .next_iter
.close:
pop rbx
ret
; print_decimal.asm
section '.print_decimal' executable
; | input:
; rax = number
print_decimal:
push rax
push rbx
push rcx
push rdx
xor rcx, rcx
cmp rax, 0
jl .is_minus
jmp .next_iter
.is_minus:
neg rax
push rax
mov rax, '-'
call print_char
pop rax
.next_iter:
mov rbx, 10
xor rdx, rdx
div rbx
push rdx
inc rcx
cmp rax, 0
je .print_iter
jmp .next_iter
.print_iter:
cmp rcx, 0
je .close
pop rax
add rax, '0'
call print_char
dec rcx
jmp .print_iter
.close:
pop rdx
pop rcx
pop rbx
pop rax
ret
Запуск
$ fasm main.asm && fasm printf.asm
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
1 passes, 816 bytes.
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
6 passes, 1592 bytes.
$ ld main.o printf.o -o main
$ ./main
{ hello, 571! }
Таким образом, мы успешно написали функцию printf. Пожалуй из нюансов данной реализации здесь только тот момент, что printf читает аргументы не сверху-вниз, а наоборот — снизу-вверх. Связано это непосредственно с тем, что в качестве аргументов мы использовали стек, где последнее внесённое значение в стек являлось первым взятым из стека. Иными словами, нами использовалась структура LIFO (last in, first out). Данный момент не настолько критичен, как может показаться на первый взгляд. Со стороны высокоуровневых языков, а именно Сишки, его можно будет исключить.
Подключаем printf к языку Си
Основной нашей целью в данном разделе будет являться вызов функции printf без использования стандартной библиотеки языка Си. Задача лёгкая и сводится лишь к соблюдению ABI (application binary interface) со стороны Сишного компилятора. В моём случае это компилятор gcc. Трактовка правил ABI для совместного использования процедур языка ассемблера и Сишных процедур следующая:
Первые шесть аргументов должны находиться в регистрах: rdi, rsi, rdx, r8, r9,
Оставшиеся аргументы должны помещаться в стек,
Возвращаемое значение должно помещаться в rax.
Для таких правил нам будет нужно создать процедуру-обёртку под уже имеющуюся функцию printf. Назовём эту обёрточную функцию как c_printf
.
; c_printf.asm
format ELF64
extrn printf
public c_printf
section '.c_printf' executable
c_printf:
pop r10
push r9
push r8
push rcx
push rdx
push rsi
mov rax, rdi
call printf
pop rsi
pop rdx
pop rcx
pop r8
pop r9
push r10
ret
Здесь происходит всё по логике ABI, но значения в стек кладутся в обратном порядке. Теперь, если мы вспомним как реализована нами написанная процедура printf, то такое поведение играет нам лишь на пользу, потому как со стороны Сишного кода аргументы будут идти слева-направо, собственно как и нужно.
Помимо чисел в стеке здесь также присутствует некий хак в лице регистра r10 и комбинации инструкций call / ret. Суть этого хака в том, что ABI читает все последующие аргументы уже из стека, но в стеке у нас в качестве седьмого элемента хранилось бы не нужное значение, а адрес возврата, которое ставит call и к которому обращается ret. Данное значение необходимо для правильной работы, поэтому здесь я жертвую регистром r10.
Далее, если мы будем компилировать Сишный код с опцией -nostdlib
, то у нас сбросятся все точки входа (то есть функция main), а также если таковая будет являться точкой входа, то при возвращении нуля она не будет вызывать автоматического завершения всего цикла программы, а потому последующее поведение будет UB (undefined behaviour). С этой целью нам также нужно реализовать процедуру c_exit
, которая бы завершала выполнение программы аналогично функции main с кодом результата.
; c_exit.asm
format ELF64
public c_exit
section '.c_exit' executable
c_exit:
mov rax, 60
syscall
В регистр rdi со стороны Сишного кода передастся значение и оно сразу же будет интерпретироваться системным вызовом. Поэтому здесь нам не нужно ничего писать кроме самого номера системного вызова = 60 (exit).
Перфекционистам не читать!
На самом деле есть небольшой минус в метке c_exit
. Это есть не что иное, как метка, но Си будет вызывать c_exit как процедуру через инструкцию call, а не jmp. Из-за этого в стек будет помещаться лишнее значение. Фактически оно никак не влияет ни на выполнение, ни на завершение работы, но просто потрепать нервы перфекционистам может.
И теперь, всё что нам остаётся сделать — это написать Сишный код, который бы вызывал две ассемблерные процедуры: c_printf
и c_exit
.
// main.c
typedef long long int int64_t;
extern void c_exit(int ret);
extern int64_t c_printf(char *fmt, ...);
int _start(void) {
char *string = "hello";
int64_t decimal = 571;
char symbol = '!';
int64_t ret = c_printf(
"{ %s, %d%c }\n",
string, decimal, symbol
);
c_printf("%d\n", ret); // 3
c_exit(0);
}
Стоит здесь заметить следующие специфичные детали:
Я использую тип int64_t в качестве возвращаемого значения функцией
c_printf
, в то время как оригинальная функция возвращает значение типа int. Связано это непосредственно с тем, чтобыc_printf
на 16 строчке мог выводить отрицательные числа, если произойдёт ошибка форматирования. Связано это с тем, что %d в нашей реализации есть число 64-битное,Вместо функции main используется функция _start. Связано это с тем, что при опции компилирования
-nostdlib
пропадает точка входа в лице функции main. Таким образом, мы фактически обращаемся к первичной точке входа _start напрямую.
Компилируем, линкуем, запускаем и смотрим результат.
$ fasm fasm printf.asm && fasm c_printf.asm && fasm c_exit.asm
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
6 passes, 1592 bytes.
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
1 passes, 584 bytes.
flat assembler version 1.73.31 (16384 kilobytes memory, x64)
1 passes, 440 bytes.
$ gcc -nostdlib -no-pie -o main printf.o c_printf.o c_exit.o main.c
$ ./main
{ hello, 571! }
3
Заключение
И вот таким образом мы успешно смогли реализовать функцию printf
на языке ассемблера FASM. Хоть данная функция является более урезанной версией в сравнении с оригинальной функцией printf, тем не менее она проста в понимании и способна легко расширяться, вбирая в себя новые функции, такие как печать чисел с плавающей точкой, чисел в формате hex, oct и т.п. Весь исходный код, с сопутствующими примерами, можно найти в репозитории Github.