Введение
Иногда, и чаще всего спонтанно, у меня появляется дикое желание реализовывать что-либо на языке ассемблера, а потом прикручивать это "что-либо" на уровни выше. Так например, ранее из-за такого желания я написал сначала стековую виртуальную машину, которая могла принимать в себя байт-код и непосредственно его исполнять, далее написал ассемблер, который мог бы транслировать язык ассемблера в этот самый байт-код, а после и написал высокоуровневый 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-ый символ в строке,
Если символ равен %, тогда перейти на пункт [7],
Если символ равен нулю, тогда завершить выполнение,
Иначе, напечатать 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, rcx, 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, ...); void _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 напрямую.Стандартная функция
printfвозвращает количество всех напечатанных символов, в то время как нами написанная процедура возвращает количество обработанных специальных символов (взаимодействующих со стеком). Иными словами, если бы использовалась стандартнаяprintf, то вместо результата = 3, мы получили бы результат = 16.
Компилируем, линкуем, запускаем и смотрим результат.
$ 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 -o main printf.o c_printf.o c_exit.o main.c $ ./main { hello, 571! } 3
Заключение
И вот таким образом мы успешно смогли реализовать функцию printf на языке ассемблера FASM. Хоть данная функция является более урезанной версией в сравнении с оригинальной функцией printf, тем не менее она проста в понимании и способна легко расширяться, вбирая в себя новые функции, такие как печать чисел с плавающей точкой, чисел в формате hex, oct и т.п. Весь исходный код, с сопутствующими примерами, можно найти в репозитории Github.
