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

Вспомним сигнатуру функции 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).

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    3 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии