Абстракция — основа программирования. Многие вещи мы используем, не задумываясь об их внутреннем устройстве, и они отлично работают. Всем известно, что пользовательские программы взаимодействуют с ядром через системные вызовы, но задумывался ли ты, как это происходит на твоей машине?
Вспомним сигнатуру функции
Откуда берутся число аргументов (
Краткий ответ: зависит от архитектуры процессора. К сожалению, доступных для начинающих материалов по самой распространенной сейчас архитектуре x86-64 не так много, и интересующиеся новички вынуждены сначала обращаться к старой литературе по 32-битным x86, которая следует другим соглашениям.
Попробуем исправить этот пробел и продемонстрировать прямое взаимодействие с машиной и ядром Linux сразу в 64-битном режиме.
Демонстрационная задача
Для демонстрации мы напишем расширенную версию hello world, которая может приветствовать любое количество объектов или людей, чьи имена передаются в аргументах команды.
Среда разработки
Для демонстрации мы будем использовать Linux и GNU toolchain (GCC и binutils), как самые распространенные ОС и среда разработки. Писать мы будем на языке ассемблера, потому что продемонстрировать низкоуровневое взаимодействие с ОС из языка сколько-нибудь высокого уровня невозможно.
Очень краткая справка
Чтобы упростить чтение статьи тем, кто вообще никогда не сталкивался с ассемблером x86, я использовал только самые простые инструкции и постарался аннотировать их псевдокодом везде, где возможно. Я использую синтаксис AT&T, который все инструменты GNU используют по умолчанию. Нужно помнить, что регистры пишутся с префиксом % (например,
Синтаксис указателей: смещение(база, индекс, множитель). Очень краткая справка:
Для изучения ассемблера x86 я могу посоветовать книгу Programming From the Ground Up — к сожалению, ориентированную на 32-битную архитектуру, но очень хорошо написанную и подходящую новичкам.
О регистрах: в x86-64 их куда больше. Кроме традиционных, добавлены регистры от %r8 до %r15, всего шестнадцать 64-битных регистров. Чтобы обратиться к нижним байтам новых регистров, нужно использовать суффиксы d, w, или b. То есть
Что входит в соглашения ABI?
SystemV ABI, которой в большей или меньшей степени следуют почти все UNIX-подобные системы, состоит из двух частей. Первая часть, общая для всех систем, описывает формат исполняемых файлов ELF. Ее можно найти на сайте SCO.
К общей части прилагаются архитектурно зависимые дополнения. Они описывают:
Формат ELF
Знать формат ELF в деталях, особенно его двоичную реализацию, нужно только авторам ассемблеров и компоновщиков. Эти задачи мы в статье не рассматриваем. Тем не менее пользователю следует понимать организацию формата.
Файлы ELF состоят из нескольких секций. Компиляторы принимают решение о размещении данных по секциям автоматически, но ассемблеры оставляют это на человека или компилятор. Полный список можно найти в разделе Special Sections. Вот самые распространенные:
Соглашения о вызовах
Соглашение о вызовах — важная часть ABI, которая позволяет пользовательским программам взаимодействовать с ядром, а программам и библиотекам — друг с другом. В соглашении указывается, как передаются аргументы (в регистрах или на стеке), какие именно регистры используются и где хранится результат. Кроме того, оговаривается, какие регистры вызываемая функция обязуется сохранить нетронутыми (callee-saved), а какие может свободно перезаписать (caller-saved).
Соглашение о системных вызовах
Системные вызовы выполняются с помощью инструкции процессора
На старых 32-разрядных x86 использовалось программное прерывание
Через регистры в ядро передается номер системного вызова и его аргументы. Соглашение для Linux описано в параграфе A.2.1.
Напишем простейшую программу, которая корректно завершается с кодом возврата 0 — аналог
Код программы мы помещаем в секцию
Соберем и запустим программу:
Соглашение о вызовах функций
Соглашение о вызовах функций похоже на соглашение о системных вызовах. Детали можно найти в разделе 3.2. Мы будем работать только с целыми числами и указателями, поэтому наши значения можно отнести к классу
К нашему случаю относятся следующие соглашения:
Пишем стандартную библиотеку
Пользуясь этими знаниями, мы можем написать небольшую стандартную библиотеку. Прежде всего нам понадобится функция puts, чтобы выводить строки на стандартный вывод. Системный вызов write сделает за нас почти всю работу. Единственная сложность в том, что он требует длину строки в качестве аргумента. Его условная сигнатура —
Сначала приведем код библиотеки, а потом разберем ее функции.
Макросы
Функция
Семейство команд условных переходов в x86 довольно обширно и включает в себя команду jz — jump if zero. Я намеренно использовал команды, которые мне кажутся наиболее наглядными для читателей, не сталкивавшихся с языком ассемблера до этой статьи. Возможно, более правильно было бы для индекса элемента строки использовать регистр %r11, который зарезервирован как scratch register и не обязан сохраняться вызываемой функцией.
Попробуем использовать нашу библиотеку из программы на C. Сигнатура функции asm_putsс точки зрения C будет
Сохраним следующий код в
Теперь соберем из этого всего программу:
Как видим, вызов нашей функции из C сработал. Увы,
Где лежат аргументы командной строки?
Ответ на это можно найти в разделе 3.4.1, и он проще, чем можно было ожидать: на стеке процесса. При запуске процесса регистр
Таким образом, все, что нам нужно, — это несложный цикл, который извлекает значения со стека по одному и передает их нашей функции
Финальная программа
Соберем программу и проверим ее в работе:
Логика достаточно проста. Число аргументов, которое находится на вершине стека, мы сохраняем в регистре %r12, а затем извлекаем указатели на аргументы из стека и уменьшаем значение в %r12 на единицу, пока оно не достигнет нуля. Основной цикл программы организован через те же команды сравнения и условного перехода, которые мы уже видели в
Поскольку форматированный вывод нам недоступен, его отсутствие приходится компенсировать отдельным выводом сначала строки
Заключение
Мы успешно поговорили с ядром Linux без посредников на его собственном языке. Такие упражнения несут мало практического смысла, но приближают нас к пониманию того, как userspace работает с ядром.
Автор: dmbaturin ака Даниил Батурин
хакер.ру
Вспомним сигнатуру функции
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
%r10d — нижние четыре байта, %r10w — нижние два байта, %r10b — нижний байт.Что входит в соглашения ABI?
SystemV ABI, которой в большей или меньшей степени следуют почти все UNIX-подобные системы, состоит из двух частей. Первая часть, общая для всех систем, описывает формат исполняемых файлов ELF. Ее можно найти на сайте SCO.
К общей части прилагаются архитектурно зависимые дополнения. Они описывают:
- соглашение о системных вызовах;
- соглашение о вызовах функций;
- организацию памяти процессов;
- загрузку и динамическое связывание программ.
Формат ELF
Знать формат 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.
- Отрицательный результат означает, что это номер ошибки (errno).
- Регистры %rcx и %r11 должны быть сохранены пользователем.
/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 видимым для компоновщика.Соберем и запустим программу:
Bash:
$ 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 world\n");
}
Bash:
$ 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
main в исполнении GCC зависит от инициализаций из libc, поэтому финальную программу тоже придется писать на языке ассемблера, если мы не хотим эмулировать работу GCC.Где лежат аргументы командной строки?
Ответ на это можно найти в разделе 3.4.1, и он проще, чем можно было ожидать: на стеке процесса. При запуске процесса регистр
%rbp указывает на выделенный для него кадр стека, и первое значение на стеке — количество аргументов (argc). За ним следуют указатели на сами аргументы.Таким образом, все, что нам нужно, — это несложный цикл, который извлекает значения со стека по одному и передает их нашей функции
asm_puts.Финальная программа
Код:
## Константы
.section .rodata
hello_begin:
.ascii "Hello \0"
hello_end:
.ascii "!\n\0"
## Код программы
.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!
asm_strlen.Поскольку форматированный вывод нам недоступен, его отсутствие приходится компенсировать отдельным выводом сначала строки
Hello, затем аргумента и только затем восклицательного знака.Заключение
Мы успешно поговорили с ядром Linux без посредников на его собственном языке. Такие упражнения несут мало практического смысла, но приближают нас к пониманию того, как userspace работает с ядром.
Автор: dmbaturin ака Даниил Батурин
хакер.ру