Абстракция — основа программирования. Многие вещи мы используем, не задумываясь об их внутреннем устройстве, и они отлично работают. Всем известно, что пользовательские программы взаимодействуют с ядром через системные вызовы, но задумывался ли ты, как это происходит на твоей машине?
Вспомним сигнатуру функции main
в C:
int main(int argc, char** argv)
Откуда берутся число аргументов (argc
) и массив указателей на их строки (argv
)? Как возвращаемое значение main
становится кодом возврата самой программы?
Краткий ответ: зависит от архитектуры процессора. К сожалению, доступных для начинающих материалов по самой распространенной сейчас архитектуре x86-64 не так много, и интересующиеся новички вынуждены сначала обращаться к старой литературе по 32-битным x86, которая следует другим соглашениям.
Попробуем исправить этот пробел и продемонстрировать прямое взаимодействие с машиной и ядром Linux сразу в 64-битном режиме.
Для демонстрации мы напишем расширенную версию hello world, которая может приветствовать любое количество объектов или людей, чьи имена передаются в аргументах команды.
$ ./hello Dennis Brian Ken Hello Dennis! Hello Brian! Hello Ken!
Для демонстрации мы будем использовать Linux и GNU toolchain (GCC и binutils), как самые распространенные ОС и среда разработки. Писать мы будем на языке ассемблера, потому что продемонстрировать низкоуровневое взаимодействие с ОС из языка сколько-нибудь высокого уровня невозможно.
Чтобы упростить чтение статьи тем, кто вообще никогда не сталкивался с ассемблером x86, я использовал только самые простые инструкции и постарался аннотировать их псевдокодом везде, где возможно. Я использую синтаксис AT&T, который все инструменты GNU используют по умолчанию. Нужно помнить, что регистры пишутся с префиксом %
(например, %rax
), а константы — c префиксом $
. Например, $255
, $0xFF
, $foo
— значение символа foo
.
Синтаксис указателей: смещение(база, индекс, множитель)
. Очень краткая справка:
mov <источник>, <приемник>
— копирует значение из источника (регистра или адреса) в приемник;push <источник>
— добавляет значение из источника на стек;pop <приемник>
— удаляет значение из стека и копирует в приемник;call <указатель на функцию>
— вызывает функцию по указанному адресу;ret
— возврат из функции;jmp <адрес>
— безусловный переход по адресу (метке);inc
и dec
— инкремент и декремент;cmp <значение1> <значение2>
— сравнение и установка флагов (например, равенство);je <метка>
— переход на метку в случае, если аргументы cmp
оказались равными.Условные переходы и циклы реализуются через инструкции сравнения и условные переходы. Инструкции сравнения устанавливают определенные разряды в регистре флагов, команда условного перехода проверяет их и принимает решение, переходить или нет. Например, следующий цикл увеличивает значение регистра %rax
, пока оно не станет равным 10, а затем копирует его в %rbx
.
Для изучения ассемблера x86 я могу посоветовать книгу Programming From the Ground Up — к сожалению, ориентированную на 32-битную архитектуру, но очень хорошо написанную и подходящую новичкам.
mov $0, %rax # rax = 0 my_loop: inc %rax cmp $10, %rax jne my_loop # Jump if Not Equal mov %rax, %rbx # %rbx = 10
О регистрах: в x86-64 их куда больше. Кроме традиционных, добавлены регистры от %r8
до %r15
, всего шестнадцать 64-битных регистров. Чтобы обратиться к нижним байтам новых регистров, нужно использовать суффиксы d
, w
, или b
. То есть %r10d
— нижние четыре байта, %r10w
— нижние два байта, %10b
— нижний байт.
SystemV ABI, которой в большей или меньшей степени следуют почти все UNIX-подобные системы, состоит из двух частей. Первая часть, общая для всех систем, описывает формат исполняемых файлов ELF. Ее можно найти на сайте SCO.
К общей части прилагаются архитектурно зависимые дополнения. Они описывают:
Знать формат ELF в деталях, особенно его двоичную реализацию, нужно только авторам ассемблеров и компоновщиков. Эти задачи мы в статье не рассматриваем. Тем не менее пользователю следует понимать организацию формата.
Файлы ELF состоят из нескольких секций. Компиляторы принимают решение о размещении данных по секциям автоматически, но ассемблеры оставляют это на человека или компилятор. Полный список можно найти в разделе Special Sections. Вот самые распространенные:
.text
— основной исполняемый код программы;.rodata
— данные только для чтения (константы);.data
— данные для чтения и записи (инициализированные переменные);.bss
— неинициализированные переменные известного размера.Соглашение о вызовах — важная часть ABI, которая позволяет пользовательским программам взаимодействовать с ядром, а программам и библиотекам — друг с другом. В соглашении указывается, как передаются аргументы (в регистрах или на стеке), какие именно регистры используются и где хранится результат. Кроме того, оговаривается, какие регистры вызываемая функция обязуется сохранить нетронутыми (callee-saved), а какие может свободно перезаписать (caller-saved).
Системные вызовы выполняются с помощью инструкции процессора syscall
.
На старых 32-разрядных x86 использовалось программное прерывание 0x80
и поныне используется в 32-разрядном коде. Инструкция syscall
из x86-64 передает управление напрямую в точку входа в пространстве ядра, без накладных расходов на вызов обработчика прерывания.
Через регистры в ядро передается номер системного вызова и его аргументы. Соглашение для Linux описано в параграфе A.2.1.
%rax
.%rdi
, %rsi
, %rdx
, %r10
, %r9
, %r8
.%rax
.%rcx
и %r11
должны быть сохранены пользователем.Номера системных вызовов зависят от ОС и архитектуры. В Linux номера вызовов для x86-64 можно найти в заголовках ядра. На установленной системе он обычно находится в /usr/include/asm/unistd_64.h
. Для наших целей потребуются всего два системных вызова: write
(номер 1) и exit
(номер 60).
Напишем простейшую программу, которая корректно завершается с кодом возврата 0 — аналог /bin/true
.
.file "true.s" .section .text _start: # syscall(number=60/exit, arg0=0) mov $60, %rax mov $0, %rdi syscall .global _start
Код программы мы помещаем в секцию .text
, как говорит директива .section .text
. Метка _start
— соглашение компоновщика ld
, именно там он ожидает найти точку входа программы. Директива .global _start
делает символ _start
видимым для компоновщика.
Соберем и запустим программу:
$ as -o true.o ./true.s && ld -nostdlib -o true ./true.o $ ./true && echo Success Success
Соглашение о вызовах функций похоже на соглашение о системных вызовах. Детали можно найти в разделе 3.2. Мы будем работать только с целыми числами и указателями, поэтому наши значения можно отнести к классу INTEGER
.
К нашему случаю относятся следующие соглашения:
%rdi
, %rsi
, %rdx
, %rcx
, %r8
, %r9
;%rax
;%rbx
, %rbp
, %r12–15
.Пользуясь этими знаниями, мы можем написать небольшую стандартную библиотеку. Прежде всего нам понадобится функция puts
, чтобы выводить строки на стандартный вывод. Системный вызов write
сделает за нас почти всю работу. Единственная сложность в том, что он требует длину строки в качестве аргумента. Его условная сигнатура — write(file_descriptor, string_pointer, string_length)
. Поэтому нам потребуется функция strlen
.
Сначала приведем код библиотеки, а потом разберем ее функции.
.file "stdlib.s" .section .text .macro save_registers push %rbx push %rbp push %r12 push %r13 push %r14 push %r15 .endm .macro restore_registers pop %r15 pop %r14 pop %r13 pop %r12 pop %rbp pop %rbx .endm .macro write filedescr bufptr length mov $1, %rax mov filedescr, %rdi mov bufptr, %rsi mov length, %rdx syscall .endm ## strlen(char* buf) asm_strlen: save_registers # r12 — индекс символа в строке mov $0, %r12 # index = 0 strlen_loop: # r13b = buf[r12] mov (%rdi, %r12, 1), %r13b # if(r13b == 0) goto strlen_return cmp $0, %r13b je strlen_return inc %r12 # index++ jmp strlen_loop strlen_return: # return index mov %r12, %rax restore_registers ret .type asm_strlen, @function .global asm_strlen ## puts(int filedescr, char* buf) asm_puts: save_registers mov %rdi, %r12 # r12 = filedescr mov %rsi, %r13 # r13 = buf # r13 = strlen(buf) mov %r13, %rdi call asm_strlen mov %rax, %r14 # r14 = asm_strlen(buf) write %r12 %r13 %r14 restore_registers ret .type asm_puts, @function .global asm_puts
Макросы save_registers
и restore_registers
просто автоматизируют сохранение регистров callee-saved. Первый добавляет все регистры на стек, а второй удаляет их значения из стека и возвращает обратно в регистры. Макрос write
— более удобная обертка к системному вызову.
Функция strlen
использует тот факт, что строки следуют соглашению языка С, — нулевой байт выступает в качестве признака конца строки. На каждом шаге цикла strlen_loop
следующий байт строки сравнивается с нулем, и, пока он не равен нулю, значение индекса элемента в регистре %r12
увеличивается на единицу. Если он равен нулю, производится условный переход на метку strlen_return
.
Семейство команд условных переходов в x86 довольно обширно и включает в себя команду jz
— jump if zero. Я намеренно использовал команды, которые мне кажутся наиболее наглядными для читателей, не сталкивавшихся с языком ассемблера до этой статьи. Возможно, более правильно было бы для индекса элемента строки использовать регистр %r11
, который зарезервирован как scratch register и не обязан сохраняться вызываемой функцией.
Попробуем использовать нашу библиотеку из программы на C. Сигнатура функции asm_puts
с точки зрения C будет asm_puts(int filedescr, char* string)
. Выводить будем на stdout, его дескриптор всегда равен 1.
Сохраним следующий код в hello.c
:
#define STDOUT 1 int main(void) { asm_puts(STDOUT, "hello worldn"); }
Теперь соберем из этого всего программу:
$ gcc -Wno-implicit-function-declaration -c -o hello.o ./hello.c $ as -o stdlib.o ./stdlib.s $ gcc -o hello ./hello.o ./stdlib.o $ ./hello hello world
Как видим, вызов нашей функции из C сработал. Увы, main
в исполнении GCC зависит от инициализаций из libc, поэтому финальную программу тоже придется писать на языке ассемблера, если мы не хотим эмулировать работу GCC.
Ответ на это можно найти в разделе 3.4.1, и он проще, чем можно было ожидать: на стеке процесса. При запуске процесса регистр %rbp
указывает на выделенный для него кадр стека, и первое значение на стеке — количество аргументов (argc
). За ним следуют указатели на сами аргументы.
Таким образом, все, что нам нужно, — это несложный цикл, который извлекает значения со стека по одному и передает их нашей функции asm_puts
.
## Константы .section .rodata hello_begin: .ascii "Hello " hello_end: .ascii "!n " ## Код программы .section .text _start: # argc — первое значение на стеке, сохраним его в %r12 pop %r12 # r12 = argc # Следующее значение — *argv[0], это имя программы, и оно нам не нужно pop %r13 # r13 = argv[0] dec %r12 # argc-- # Сохраним первый нужный аргумент в %r13 перед входом в цикл main_loop pop %r13 # r13 = argv[1] main_loop: # if(argc == 0) goto exit cmp $0, %r12 je exit # asm_puts(STDOUT, hello_begin) mov $1, %rdi # rdi = STDOUT mov $hello_begin, %rsi call asm_puts # asm_puts(STDOUT, argv[r12]) mov $1, %rdi mov %r13, %rsi call asm_puts # asm_puts(STDOUT, hello_end) mov $1, %rdi mov $hello_end, %rsi call asm_puts pop %r13 # Извлекаем следующий аргумент dec %r12 # argc-- jmp main_loop exit: # syscall(number=60/exit, arg0/exit_code=0) mov $60, %rax mov $0, %rdi syscall .global _start
Соберем программу и проверим ее в работе:
$ as -o hello.o ./hello.s $ as -o stdlib.o ./stdlib.s $ ld -nostdlib -o hello ./hello.o ./stdlib.o $ ./hello Dennis Brian Ken Hello Dennis! Hello Brian! Hello Ken!
Логика достаточно проста. Число аргументов, которое находится на вершине стека, мы сохраняем в регистре %r12
, а затем извлекаем указатели на аргументы из стека и уменьшаем значение в %r12
на единицу, пока оно не достигнет нуля. Основной цикл программы организован через те же команды сравнения и условного перехода, которые мы уже видели в asm_strlen
.
Поскольку форматированный вывод нам недоступен, его отсутствие приходится компенсировать отдельным выводом сначала строки Hello
, затем аргумента и только затем восклицательного знака.
Мы успешно поговорили с ядром Linux без посредников на его собственном языке. Такие упражнения несут мало практического смысла, но приближают нас к пониманию того, как userspace работает с ядром.
Читайте также
Последние новости