Разработка → Тернистый путь Hello World

Skapix 12 октября в 12:55 15,7k

Вдохновение на написание данной статьи было получено после прочтения похожей публикации для архитектуры x86 [1].


Данный материал поможет тем, кто хочет понять, как устроены программы изнутри, что происходит до входа в main и для чего всё это делается. Также я покажу как можно использовать некоторые особенности библиотеки glibc. И в конце, как и в оригинальной статье [1] будет визуально представлен пройденный путь. В большинстве своём статья представляет собой разбор библиотеки glibc.


Итак, начнём наш поход. Будем использовать Linux x86-64, а в качестве инструмента отладки — lldb. Также иногда будем дизассемблировать программу при помощи objdump.


Исходным текстом будет обычный Hello, world (hello.cpp):


#include <iostream>
int main()
{
        std::cout << "Hello, world!" << std::endl;
}

На всякий случай информация о системе и программах
* Clang -- 4.0.1
* lldb -- 4.0.1
* glibc -- 2.25
* `uname -r` -- 4.12.10-1-ARCH

Компилируем код и начинаем отладку:


clang++ -stdlib=libc++ hello1.cpp -g -o hello1.out
lldb hello1.out

Примечание

Большинство рассматриваемого в программе кода почти не зависит выбранного компилятора и библиотеки c++. Просто так вышло, что мне чуть ближе инфраструктура llvm, чем gcc, поэтому рассматриваться будет компилятор clang с библиотекой libc++, но повторюсь, особой разницы нет, ведь большинство рассматриваемого кода будет разобрано из библиотеки glibc.


Программа при использовании bash (и не только) рождается при помощи вызова функции fork и создания нового процесса при помощи execve с передачей ей аргументов командной строки. Также до передачи управления первой инструкции исполняемого файла устанавливаются дескрипторы ввода и вывода(STDIN, STDOUT, STDERR), далее, в случае динамической линковки, подгружаются и инициализируются нужные программе библиотеки и вызываются функции секции ".preinit_array". Только после всего этого вызывается первая функция, находящаяся в исполняемом файле (не считая секции ".preinit_array"), традиционно именуемая _start, которая и считается началом программы. В случае со статической линковкой, работа линковщика, например инициализация секции ".preinit_array", находится внутри исполняемого файла и сами функции немного отличаются от динамически слинкованных программ. Мы же будем рассматривать именно динамически слинкованные программы.


Точка входа исполняемого файла указана в его заголовке:


readelf -h hello1.out | grep Entry

Далее проверим, какая именно функция находится по этому адресу при помощи objdump -d hello1.out. Это уже упомянутая функция _start, на которую мы ставим точку останова и запускаем отладку.


b _start
r

Немного об ABI

Определение Википедии:
ABI (aplication binary interface) — набор соглашений для доступа приложения к операционной системе и другим низкоуровневым сервисам, спроектированный для переносимости исполняемого кода между машинами, имеющими совместимые ABI. В отличие от API, который регламентирует совместимость на уровне исходного кода. ABI можно рассматривать как набор правил, позволяющих компоновщику объединять откомпилированные модули компонента без перекомпиляции всего кода, в то же время определяя двоичный интерфейс.


Уровень ABI скрыт для программистов c/c++ и вся работа этого уровня реализована компилятором и стандартной библиотекой libc. В моем случае, компилятор clang и библиотека glibc следуют всем правилам ABI. Правила ABI для Linux x86-64 указаны в документе System V AMD64 ABI [2]. Solaris, Linux, FreeBSD, OS X следуют соглашениям данного документа. У Microsoft свой конкретный ABI, который они тщательно скрывают. В первой же главе этого документа [2] сказано, что архитектура также подчиняется правилам ABI для 32-битных процессоров [3]. Поэтому это 2 основополагающих документа, на которые опираются разработчики низкоуровневых библиотек вроде glibc.


Согласно ABI, при старте программы все регистры не определены за исключением:


  • %rdx: Указатель на функцию, которая должна быть вызвана перед завершением программы.
  • %rsp: Стек выровнен по 16-байтной границе, содержит количество аргументов, сами аргументы и окружение:
    0(%rsp) argc
    8(%rsp) argv[0]

    8argc(%rsp) NULL
    8
    (argc+1)(%rsp) envp[0]

    8*(argc+k+1)(%rsp) envp[k]
    NULL
    auxiliary vectors

    NULL
    NULL

Вспомогательные вектора (auxiliary vectors) содержат в себе информацию о текущей машине. Посмотреть их значения можно при помощи LD_SHOW_AUXV=1 ./hello1.out. Полученные значения достаточно хорошо описаны в [4].


И на самом деле

x `$rsp` -s8 -fu -c1 — количество аргументов программы
p *(char**)($rsp+8) — имя программы. Далее на стеке идут аргументы программы, нулевой разделитель, аргументы окружения и вспомогательные вектора.


Вдобавок установлены регистры флагов, настроены SSE и x87 (§3.4.1 [2]).


Можно заметить, что аргументы изначально уже почти подготовлены для пользовательской функции main, осталось только задать правильные указатели. Но помимо настройки указателей до входа в основную процедуру нужно проделать ещё много работы. В дальнейшем, любая функция при её описании будет сопровождаться местоположением её исходников и самой функции в бинарном виде в виде всплывающей подсказки, например: main.


Посмотрим на функцию _start, она небольшая и основная её задача — передача управления функции __libc_start_main.


Дизассеблируем текущую функцию при помощи di (вывод здесь и далее отформатирован для наглядности):


_start:
xor   %ebp, %ebp
mov   %rdx, %r9
pop   %rsi
mov   %rsp, %rdx
and   $-0x10, %rsp
push  %rax
push  %rsp
lea   0x1aa(%rip), %r8    ; __libc_csu_fini
lea   0x133(%rip), %rcx   ; __libc_csu_init
lea   0xec(%rip), %rdi    ; main
call  *0x200796(%rip)     ; __libc_start_main
hlt

Функция _start подключается в нашу программу линковщиком в виде объектного файла Scrt1.o. Существует несколько разновидностей объектных файлов crt1 (gcrt1, Srct1, Mcrt1), выполняющих аналогичные функции, но использующихся в различных случаях. Например, Scrt1.o используется при генерации PIC кода [5]. В выборе объектного файла можно удостовериться, скомпилировав программу с ключом "-v". Заметьте, что в объектном файле не указаны смещения объектов __libc_csu_fini, __libc_csu_init и main так как смещения этих функций становятся известны только на этапе линковки.


Согласно требованиям ABI, нужно обнулить %ebp, чтобы пометить кадр, как начальный, что и делает инструкция xor %ebp, %ebp.


Дальше идёт подготовка к вызову функции __libc_start_main, сигнатура которой имеет вид:


int __libc_start_main(int (*main) (int, char **, char **),
                 int argc, char **argv,
                 __typeof (main) init,
                 void (*fini) (void),
                 void (*rtld_fini) (void), void *stack_end)

И аргументы функции, согласно ABI, должны быть положены в соответствующие места:


Аргумент Позиция для вызова функции Описание
main %rdi Основная функция программы
argc %rsi Количество аргументов программы
argv %rdx Массив аргументов. После аргументов идут переменные окружения, а после уже вспомогательные вектора
init %rcx Конструктор глобальных объектов, вызываемый до main. Тип этой функции такой же, как и у функции main.
fini %r8 Деструктор глобальных объектов, вызываемый после main
rtld_fini %r9 Деструктор динамического линковщика. Освобождает динамически выделенные библиотеки
stack_end %rsp Текущее положение выровненного стека

ABI требует, чтобы при вызове функции стек был выровнен по 16-байтной (иногда по 32, а иногда и по 64, зависит от типа аргументов) границе. Требование выполняется после исполнения инструкции and $-0x10, %rsp (?). Смысл этого выравнивания состоит в том, что SIMD инструкции (SSE, MMX) работают только с выровненными данными, а скалярные инструкции быстрее читаются/записываются с выровненными данными.


Для сохранения 16-байтного выравнивания, перед вызовом __libc_start_main на стек кладётся регистр %rax, в котором хранится неопределенное значение. Данная ячейка стека никогда не будет считана.


Программа не должна возвращаться из функции libc_start_main, и чтобы обозначить неверное поведение, используется инструкция hlt. Особенность этой инструкции в том, что в защищённом режиме процессора она может выполниться только в кольце защиты 0, то есть может её вызывать только операционная система. Мы же находимся в 3 кольце, а значит при попытке исполнения команды, к которой у программы нет прав, получаем segmentation fault.
После hlt инструкции находится ещё инструкция nopl 0x0(%rax,%rax,1), которая в свою очередь нужна для выравнивания следующей функции относительно 16-байтной границы. ABI этого не требует, но компиляторы выравнивают начало функции для улучшения производительности (1, 2).


Итак, идём дальше


b __libc_start_main
c

По исходному коду функции __libc_start_main видно, что для статически и динамически слинкованных библиотек генерируется различный код. Проверить, как выглядит код функции в библиотеке libc.so.6 можно при помощи gdb или же при помощи lldb:
lldb libc.so.6 -b -o 'di -n __libc_start_main'


Немного о __glibc_[un]likely

В коде библиотеки glibc присутствует множество вхождений __glibc_likely и __glibc_unlikely. Большое количество условных операций заменяется этим макросом. Макрос в конечном счёте преобразуется в следующие build-in функции:


# define __glibc_unlikely(cond)  __builtin_expect ((cond), 0)
# define __glibc_likely(cond)    __builtin_expect ((cond), 1)

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


Функция __libc_start_main немного громоздкая, достаточно кратко описать её основные действия:


  • регистрация rtld_fini при помощи __cxa_atexit
  • вызов __libc_csu_init
  • создание cancellation point
  • main
  • exit

__cxa_atexit


Функция __cxa_atexit, в отличие от atexit, которая является обёрткой над первой, может принимать параметры регистрируемой функции, но функция не должна вызываться напрямую из пользовательского пространства. Вызываться она не должна из-за того, что функция использует DSO-идентификатор, который известен только компилятору. Он нужен для того, чтобы при вызове __cxa_atexit(f,p,d), вызывалась функция f(p) при выгрузке DSO d [8].


Тем не менее, передача аргументов функции-параметру

Пример использования __cxa_atexit:


#include <cstdio>

extern "C" int __cxa_atexit (void (*func) (void *), void *arg, void *d);
extern void* __dso_handle;

void printArg(void *a)
{
        int arg = *static_cast<int*>(a);
        printf("%d\n",arg);
        delete (int*)a;
}

int main()
{
        int *k = new int(17);
        __cxa_atexit(printArg, k, __dso_handle);
}

Этот трюк рекомендую использовать только для ознокомления. Для вызова деструктора при выходе из программы надёжнее использовать любой аналогичный способ.


rtld_fini является указателем на функцию линковщика _dl_fini. И да, линковщик является частью библиотеки glibc. Функция _dl_fini занимается деинициализацией и выгрузкой всех загруженных библиотек.


__libc_csu_init


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


_init


Функция _init целиком находится в секции .init. Её код разбит на 2 части: вступление и эпилог. Вступление состоит из пролога и попытки вызова функции __gmon_start__.


_init
subq   $0x8, %rsp
leaq   0x105(%rip), %rax         ; __gmon_start__
testq  %rax, %rax
je     0x5555555548a2            ; je to addq instruction
callq  *%rax
addq   $0x8, %rsp
retq

Основная задача функции _init — инициализация профилировщика gprof. Инструкция "leaq 0x105(%rip), %rax" берёт адрес функции __gmon_start__ — функции, инициализирующей профилировщик. Если профилировщик не присутствует то в %rax будет находиться значение 0 и переход je сработает. Инструкции subq $0x8, %rsp и addq $0x8, %rsp производят выравнивание стека и возвращение его в исходное состояние. Это выравнивание нужно из-за того, что при вызове функции, мы кладём на стек обратный адрес, размер которого на архитектуре x86-64 равен 8 байтам.


В секцию .init можно добавить собственный участок кода. Рассмотрим пример hello2.cpp:


#include <cstdio>

extern "C" void my_init()
{
        puts("Hello from init");
}

__asm__(
        ".section .init\n"
        "call my_init"
);

int main(){}

Рассмотрим как теперь выглядит _init:


subq   $0x8, %rsp
movq   0x200835(%rip), %rax
testq  %rax, %rax
je     0x5555555547ba
callq  *%rax
callq  0x555555554990            ; ::my_init()
addq   $0x8, %rsp
retq

Как видно из листинга, между вступлением и эпилогом функции добавилась инструкция callq 0x555555554990, которая как раз и производит вызов my_init. Видимо функция _init и реализована таким образом, чтобы можно было легко добавить собственную инициализацию некоторых частей программы.


Интересный факт: Внимательный читатель наверняка заметил, что вывод в hello2.cpp выводится через функцию puts. Если же выводить через cout, то при компиляции с библиотекой libstdc++ будет ошибка сегментации, а при помощи библиотеки libc++ сообщение выведется нормально. Из-за чего это случается? Дело в том, что в libstdc++ cout инициализируется как обычный глобальный объект, а инициализация глобальных объектов происходит чуть позже. В случае же с libc++, инициализация происходит во время загрузки библиотек в функции _dl_init из библиотеки ld-linux-x86-64.so.2. Эта функция как раз вызывается из _dl_start_user прямо перед передачей управления функции _start.


Преимущества и недостатки есть у каждого метода. При подключении библиотеки libc++, даже если не будут использоваться стандартные средства вывода c++ вроде cout, конструкторы в любом случае будут вызваны. В случае же с библиотекой libstdc++, даже при включенных флагах оптимизации, конструктор будет вызываться столько раз, сколько подключён заголовочный файл iostream. Естественно, в самом конструкторе учитывается тот факт, что он может быть вызван несколько раз и повторная инициализация пропускается. Это, конечно, сильно не замедлит инициализацию программы, но всё равно неприятно. Видимо по этой причине многие высокопроизводительные проекты не используют, не рекомендуют и даже запрещают подключать заголовочный файл iostream и, как следствие, создают свои интерфейсы для ввода-вывода.


.init_array


Далее вызываются функции, указатели которых расположенные в секции .init_array.
Проверим содержимое секции:


objdump hello1.out -s -j .init_array

В моём случае, содержимое .init_array имеет следующее значение: a00f0000 00000000, что означает адрес 0x0fa0 в 64-битной системе с little-endian порядком байт. По этому адресу находится функция frame_dummy.


frame_dummy

Интересно, что frame_dummy является частью библиотеки gcc.


При чём тут вообще gcc? У нас же компилятор clang!

Не стоит забывать, что проект gcc является очень большим и уже пророс корнями в операционные системы linux. Проект gcc содержит не только компилятор, но и нужные для компиляции файлы. Таким образом, при линковке используются crt-файлы вроде crtbeginS.o и crtendS.o.
Поэтому, полностью избавиться от проекта gcc не удастся, и как минимум придётся оставить вспомогательные crt-файлы. Операционные системы unix, не использующие компилятор gcc в качестве основного так и делают.


frame_dummy выглядит следующим образом:


pushq  %rbp
movq   %rsp, %rbp
popq   %rbp
jmp    0x555555554cc0            ; register_tm_clones
nopw   (%rax,%rax)

Задачей frame_dummy является установка аргументов и запуск функции register_tm_clones. Эта прослойка нужна только для того, чтобы выставить аргументы. В данном случае аргументы не выставляются, но как можно увидеть по исходному коду, это не всегда так, зависит от архитектуры. Интересно, что первые 2 инструкции являются прологом, третья — эпилогом. Инструкция jmp же является хвостовой оптимизацией вызова функции. И как обычно, в конце выравнивание.


Функция register_tm_clones нужна для того, чтобы активировать транзакционную память.


Инициализация глобальных объектов

Глобальные объекты, в случае их наличия, инициализируются именно здесь.
При наличии глобальных объектов, в секцию .init_array добавляется адрес функции _GLOBAL__sub_I_<имя компилируемого файла>.


Рассмотрим пример инициализации глобальных переменных:
global1.cpp:


int k = printf("Hello from .init_array");

Переменная будет инициализирована следующим образом:


push  %rbp
mov   %rsp, %rbp
lea   0xf59(%rip), %rdi         ;  + 4
mov   $0x0, %al
call  0x555555554e80            ; symbol stub for: printf
mov   %eax, 0x202130(%rip)      ; k
pop   %rbp
ret 

Первые 2 инструкции являются прологом. Дальше мы подготавливаемся к вызову функции printf, положив в %rdi указатель на нашу строку и прировняв %al к нулю. Согласно ABI [2], функции с переменным количеством аргументов содержат скрытый параметр, хранящийся в %al, означающий количество переменных аргументов, содержащихся в векторных регистрах. Скорее всего это нужно для оптимизации некоторых функций, но printf использует эту информацию, для того, чтобы переместить данные из векторных регистров на стек.
После вызова printf, результат функции помещается в область памяти переменной k и вызывается эпилог.


global2.cpp:
Допустим у нас есть некий класс Global с недефолтными конструктором и деструктором:


Global g;

Тогда инициализация будет выглядить следующим образом:


push  %rbp
mov   %rsp, %rbp
sub   $0x10, %rsp
lea   0x202175(%rip), %rdi      ; g
call  0x5555555550e0            ; Global::Global()
lea   0x1c5(%rip), %rdi         ; Global::~Global()
lea   0x202162(%rip), %rsi      ; g
lea   0x202147(%rip), %rdx      ; __dso_handle
call  0x555555554f10            ; symbol stub for: __cxa_atexit
mov   %eax, -0x4(%rbp)
add   $0x10, %rsp
pop   %rbp
ret   

Здесь мы видим, как после вызова глобального конструктора, регистрируется деструктор при помощи __cxa_atexit. Это реализовано согласно Itanium ABI [8].


Инициализирующий вызов функций

Из glibc инициализация вызывается следующим образом: (*__init_array_start [i]) (argc, argv, envp);


Заметьте, что инициализирующей функции передаются параметры, аналогичные функции main, значит мы можем их использовать. В компиляторах gcc и clang имеется атрибут constructor, при помощи которого функция вызывается перед этапом инициализации объектов.


В него-то мы и можем передать эти аргументы. Проверьте вывод программы использующей следующую глобальную функцию:


void __attribute__((constructor)) hello(int argc, char **argv, char **env)
{
        printf("#args = %d\n", argc);
        printf("filename = %s\n", argv[0]);
}

Это можно использовать для более практичных целей (hello3.cpp):


#include <cstdio>

class C
{
public:
        C(int i)
        {
                printf("Program has %d argument(s)\n", i);
        }
};

int constructorArg;
const C c(constructorArg);

void __attribute__((constructor (65535))) hello(int argc, char ** argv, char **env){
        constructorArg = argc;
}
int main(){}

В параметрах атрибута constructor указан приоритет вызова.


Как вы наверняка уже догадались, программа выведет верное количество аргументов, и самое интересное, объект c является константным. Главный минус такого подхода — отсутствие поддержки стандарта и, как следствие, отсутствие кроссплатформенности. Также такой код сильно зависит от используемой библиотеки libc.


Хочется добавить, что глобальные переменные вида int x = 1 + 2 * 3; не инициализируются вовсе, их значения изначально записываются компилятором в память. Если же вы хотите, чтобы переменные, инициализируемые простыми функции вроде int s = sum(4, 5) также были изначально инициализированы, добавляйте к функции sum идентификатор constexpr из стандарта C++11.


Создание cancellation point


Точка отмены создаётся при помощи вызова setjmp и настройки глобальной переменной.
Сохранение контекста setjmp нужно для установки буфера отмены, чтобы при отмене основного потока, его можно было корректно завершить.


Пример отмены основного потока

Файл cancel.cpp.


#include <pthread.h>

pthread_t g_thr = pthread_self();

void * thread_start(void *)
{
        pthread_cancel(g_thr);
        return 0;
}
int main()
{
        pthread_t thr;
        pthread_create(&thr, NULL, thread_start, NULL);
        pthread_detach(thr);
        while (1)
        {
                pthread_testcancel();
        }
}

В примере cancel.cpp, произойдёт завершение основного потока при помощи отмены из вспомогательного, и, в последствии вызовется функция exit. Причём, если бы созданный нами поток продолжил бы существование после отмены основного потока, счётчик потоков сказал бы, что существуют ещё потоки процесса и тогда завершился бы только основной поток, а вспомогательный продолжил бы своё существование.


Можете проверить, что контекст действительно восстанавливается, поставив точку останова сразу после инструкции вызова setjmp:


br set -n __libc_start_main -R 162

Программа остановится дважды: первый раз при инициализации программы, второй — после отмены основного потока.


Вместо ожидаемого setjmp вызов происходит на __GI__setjmp. Данная функция является алиасом первой и создание подобного алиаса производится для каждого символа, используемого внутри библиотеки. Это делается в основном для сохранения работоспособности программы при замене глобальных символов и для увеличения производительности [7]. Производительность улучшается за счёт того, что происходит прямой вызов, а не через таблицу вызовов PLT.


main


Исходный код функции состоит из одной строчки.


std::cout << "Hello, world!" << std::endl;

То же самое можно записать, как:


operator<<(std::cout, "Hello, World!").operator<<(std::endl);

или же


operator<<(std::cout, "Hello, World!");
std::cout.operator<<(std::endl);

В языке C++ оператор << является обычной перегружаемой функцией. Реализован он может быть, как член класса, например вторая инструкция, так и вне класса, как в случае с выводом строки. Существуют даже некоторые правила, как реализовывать перегрузку.


endl является функцией как в libc++, так и в libstdc++ и имеет вид: ostream& endl(ostream&);


В классе ostream, для вызова данной функции объявлен оператор << и реализован, как паттерн проектирования visitor.


Вызов первой функции вначале вычисляет длину строки. В моём случае длина строки является IFUNC-символом, то есть реализация функции определяется во время выполнения программы между __strlen_avx2 и _strlen_sse2. В вашем же случае также возможен strlen без использования векторных регистров.


При первой записи данных в stdout выделяется память для внутреннего буфера в функции _IO_file_doallocate при помощи malloc, и в моём случае размер буфера равен 1 кб. Файловые дескрипторы могут иметь различную политику буферизации, которая настраивается при помощи setvbuf.


После кладётся строка кладётся в буфер stdout, поэтому строка сразу не выводится на экран. Вторая же процедура дописывает в буфер знак перевода строки и вызывает flush, означающий опустошение внутреннего буфера stdout и вывод стоки на экран.


В моём случае после добавления конца строки, даже без вызова flush, управление передаётся функции fwrite, которая вызывает __libc_write, которая вызывает syscall следующим образом (для наглядности, функция немного упрощена):


ssize_t __libc_write (int fd, const void *buf, size_t nbytes)
{
  return ({
            unsigned long int resultvar =
            ({ unsigned long int resultvar;
               long int __arg3 = (long int) (nbytes);
               long int __arg2 = (long int) (buf);
               long int __arg1 = (long int) (fd);
               register long int _a3 asm ("rdx") = __arg3;
               register long int _a2 asm ("rsi") = __arg2;
               register long int _a1 asm ("rdi") = __arg1;
               asm volatile ( "syscall\n\t"
               : "=a" (resultvar)
               : "0" (1) , "r" (_a1), "r" (_a2), "r" (_a3)
               : "memory", "cc", "r11", "cx"); (long int) resultvar; });
            resultvar;
          });
}

В библиотеке активно применяются statement expressions, являющиеся расширением компилятора gcc:


int l = ({int b = 4; int c = 8; c += b});

Результатом такого выражения будет результат вычисления последней инструкции, то есть c += b и в данном примере l == 12.


Функция __libc_write (в дизассемблированном виде имеет название __GI___libc_write по тем же причинам, что и _setjmp) для вывода в консоль использует интерфейс системных вызовов syscall, а чтобы вызвать syscall, используется расширение языка C для ассемблера. Номер системного вызова кладётся в регистр rax. В коде это делается при помощи архитектурного ограничения на используемый регистр =a, что означает копировать результат в регистр rax, а "0" (1) в данном случае требует, чтобы перед выполнением скрипта в регистре rax лежало значение 1 (sys_write).


Системные вызовы так же, как и обычные вызовы могут иметь аргументы, например для вызова sys_write аргументами являются файловый дескриптор, буфер данных и размер выводимого текста.


Соглашения о вызовах в ядре, согласно ABI [2], отличаются от обычных соглашений о вызове. Для ядра список параметров должен лежать в определённом порядке в следующих регистрах: %rdi, %rsi, %rdx, %r10, %r8, %r9.


Для архитектур x86 и x86-64 таблицы системных вызовов отличаются!


При отладке, вы наверняка могли заметить, что прежде чем попасть в какую-либо функцию разделяемой библиотеки из основной программы в первый раз, происходят переходы и вызовы различных функций, которых мы не писали. Так работает определение адресов функций при помощи технологии PIC (1, 2).


exit


Функция exit состоит из следующих вызовов:


  1. __call_tls_dtors — вызывает деструкторы thread local storage объектов, но в нашем случае мы их не использовали.
  2. Выполняет функции, зарегистрированные при помощи atexit
    • _dl_fini — та самая функция, которая передавалась в _start в регистре r9, которую нужно вызвать перед завершением работы программы.
    • Деструкторы глобальных объектов (в нашем случае их нет).
  3. Вызывает функции секции __libc_atexit
    • _IO_cleanup — очищает и освобождает буферы файловых дескрипторов.
  4. _exit — завершает исполнение всех потоков процесса.

Функция _exit вызывает системный вызов 231 (sys_exit_group), который принимает возвращаемое значение программы в качестве параметра в регистре %rdi. На этом программа и завершает свою работу.


В Linux также существует системный вызов sys_exit. Разница этих двух вызовов в том, что последний завершает только текущий поток, в то время как sys_exit_group завершает все потоки данного процесса. В случае однопоточного процесса, два данных вызова эквивалентны, но в случае многопоточной программы, при завершении программы при помощи sys_exit в процессе останутся незавершенные потоки и система не деинициализирует процесс, пока не завершатся все его потоки [6].


Это и есть обычный путь, который проходит процессор каждый раз, когда вы запускаете на нём "Hello, World!!!", написанный на языке C/C++, использующий библиотеку glibc. За рамками статьи осталось ещё множество вещей: работа загрузчика, инициализация транзакционной памяти, реализация функций setjmp, atexit...


Нагляднее будет показать проделанный путь в виде графа, полученного при помощи dot



[1] — http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html
[2] — https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-r252.pdf
[3] — https://github.com/hjl-tools/x86-psABI/wiki/intel386-psABI-1.1.pdf
[4] — https://habrahabr.ru/post/128111/
[5] — https://dev.gentoo.org/~vapier/crt.txt
[6] — http://syprog.blogspot.ru/2012/03/linux-threads-through-magnifier-local.html
[7] — https://sourceware.org/glibc/wiki/Style_and_Conventions#Double-underscore_names_for_public_API_functions
[8] — https://itanium-cxx-abi.github.io/cxx-abi/abi.html#dso-dtor

Проголосовать:
+74
Сохранить: