Пишем printf на языке ассемблера FASM

ff275d4e009c094e56f1d239da7d5fcb.png

Введение

Иногда, и чаще всего спонтанно, у меня появляется дикое желание реализовывать что-либо на языке ассемблера, а потом прикручивать это «что-либо» на уровни выше. Так например, ранее из-за такого желания я написал сначала стековую виртуальную машину, которая могла принимать в себя байт-код и непосредственно его исполнять, далее написал ассемблер, который мог бы транслировать язык ассемблера в этот самый байт-код, а после и написал высокоуровневый 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. Если символ равен %, тогда перейти на пункт [6],

  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, 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);
}

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

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

  2. Вместо функции 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.

© Habrahabr.ru