Пишем 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 крайне прост:

  1. Прочитать i-ый символ в строке,

  2. Если i-ый символ равен нулевому, тогда закрыть выполнение,

  3. Напечатать символ (print_char),

  4. Инкрементировать значение i,

  5. Перейти на пункт [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 следующий:

  1. Если число меньше нуля, тогда напечатать символ минус,

  2. Разделить число на 10, взять частное и остаток от деления,

  3. Положить остаток в стек,

  4. Инкрементировать значение i,

  5. Если частное не равно 0, тогда перейти на пункт [2],

  6. Если значение i равно 0, тогда закрыть выполнение,

  7. Выгрузить число из стека (остаток),

  8. Прибавить к остатку символ ‘0’ (число 48 по ASCII),

  9. Напечатать получившийся символ (print_char),

  10. Декрементировать значение i,

  11. Перейти на пункт [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

Несмотря на более масштабное количество кода в сравнении с прошлыми кодами, логика остаётся простой:

  1. Прочитать i-ый символ в строке,

  2. Если символ равен %, тогда перейти на пункт [7],

  3. Если символ равен нулю, тогда завершить выполнение,

  4. Иначе, напечатать i-ый символ,

  5. Инкрементировать значение i,

  6. Перейти на пункт [1],

  7. Инкрементировать значение i,

  8. Если i-ый символ равен %, тогда напечатать его,

  9. Если i-ый символ равен d, тогда взять из стека значение и напечатать число (print_decimal),

  10. Если i-ый символ равен s, тогда взять из стека значение и напечатать строку (print_string),

  11. Если i-ый символ равен c, тогда взять из стека значение и напечатать символ (print_char),

  12. Иначе, вернуть ошибку и перейти на пункт [3],

  13. Инкрементировать значение i,

  14. Перейти на пункт [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); } 

Стоит здесь заметить следующие специфичные детали:

  1. Я использую тип int64_t в качестве возвращаемого значения функцией c_printf, в то время как оригинальная функция возвращает значение типа int. Связано это непосредственно с тем, чтобы c_printf на 16 строчке мог выводить отрицательные числа, если произойдёт ошибка форматирования. Связано это с тем, что %d в нашей реализации есть число 64-битное,

  2. Вместо функции main используется функция _start. Связано это с тем, что при опции компилирования -nostdlib пропадает точка входа в лице функции main. Таким образом, мы фактически обращаемся к первичной точке входа _start напрямую.

  3. Стандартная функция 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.


ссылка на оригинал статьи https://habr.com/ru/articles/766044/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *