Абстракция — основа программирования. Многие вещи мы используем, не задумываясь об их внутреннем устройстве, и они отлично работают. Всем известно, что пользовательские программы взаимодействуют с ядром через системные вызовы, но задумывался ли ты, как это происходит на твоей машине?

Вспомним сигнатуру функции 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 — нижние два байта, %r10b — нижний байт.

 

Что входит в соглашения ABI?

SystemV ABI, которой в большей или меньшей степени следуют почти все UNIX-подобные системы, состоит из двух частей. Первая часть, общая для всех систем, описывает формат исполняемых файлов ELF. Ее можно найти на сайте SCO.

К общей части прилагаются архитектурно зависимые дополнения. Они описывают:

  • соглашение о системных вызовах;
  • соглашение о вызовах функций;
  • организацию памяти процессов;
  • загрузку и динамическое связывание программ.
 

Формат ELF

Знать формат ELF в деталях, особенно его двоичную реализацию, нужно только авторам ассемблеров и компоновщиков. Эти задачи мы в статье не рассматриваем. Тем не менее пользователю следует понимать организацию формата.

Файлы ELF состоят из нескольких секций. Компиляторы принимают решение о размещении данных по секциям автоматически, но ассемблеры оставляют это на человека или компилятор. Полный список можно найти в разделе Special Sections. Вот самые распространенные:

  • .text — основной исполняемый код программы;
  • .rodata — данные только для чтения (константы);
  • .data — данные для чтения и записи (инициализированные переменные);
  • .bss — неинициализированные переменные известного размера.
 

Соглашения о вызовах

Соглашение о вызовах — важная часть ABI, которая позволяет пользовательским программам взаимодействовать с ядром, а программам и библиотекам — друг с другом. В соглашении указывается, как передаются аргументы (в регистрах или на стеке), какие именно регистры используются и где хранится результат. Кроме того, оговаривается, какие регистры вызываемая функция обязуется сохранить нетронутыми (callee-saved), а какие может свободно перезаписать (caller-saved).

 

Соглашение о системных вызовах

Системные вызовы выполняются с помощью инструкции процессора syscall.

INFO

На старых 32-разрядных x86 использовалось программное прерывание 0x80 и поныне используется в 32-разрядном коде. Инструкция syscall из x86-64 передает управление напрямую в точку входа в пространстве ядра, без накладных расходов на вызов обработчика прерывания.

Через регистры в ядро передается номер системного вызова и его аргументы. Соглашение для Linux описано в параграфе A.2.1.

  • Номер вызова передается в регистре %rax.
  • Можно передавать до шести аргументов в регистрах %rdi, %rsi, %rdx, %r10, %r9, %r8.
  • Результат возвращается в регистре %rax.
  • Отрицательный результат означает, что это номер ошибки (errno).
  • Регистры %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.

INFO

Семейство команд условных переходов в 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");
}

Теперь соберем из этого всего программу:

$ 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 \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!

Логика достаточно проста. Число аргументов, которое находится на вершине стека, мы сохраняем в регистре %r12, а затем извлекаем указатели на аргументы из стека и уменьшаем значение в %r12 на единицу, пока оно не достигнет нуля. Основной цикл программы организован через те же команды сравнения и условного перехода, которые мы уже видели в asm_strlen.

Поскольку форматированный вывод нам недоступен, его отсутствие приходится компенсировать отдельным выводом сначала строки Hello, затем аргумента и только затем восклицательного знака.

 

Заключение

Мы успешно поговорили с ядром Linux без посредников на его собственном языке. Такие упражнения несут мало практического смысла, но приближают нас к пониманию того, как userspace работает с ядром.

Аватар

Даниил Батурин

Координатор проекта VyOS (https://vyos.io), «языковед», функциональщик, иногда сетевой администратор.

Оставить мнение

Check Also

Атака на хостинг. Как я раскрутил эскалацию привилегий в Plesk

Рут на shared-хостинге — не обязательно публичная уязвимость в ядре Linux. В этой статье б…