Вдохновение на написание данной статьи было получено после прочтения похожей публикации для архитектуры x86 [1]. Данный материал поможет тем, кто хочет понять, как устроены программы изнутри, что происходит до входа в main и для чего всё это делается. Также я покажу как можно использовать некоторые особенности библиотеки glibc. И в конце, как и в оригинальной статье [1] будет визуально представлен пройденный путь. В большинстве своём статья представляет собой разбор библиотеки glibc. Итак, начнём наш поход. Будем использовать Linux x86-64, а в качестве инструмента отладки — lldb. Также иногда будем дизассемблировать программу при помощи objdump. Исходным текстом будет обычный Hello, world (hello.cpp): Компилируем код и начинаем отладку: Большинство рассматриваемого в программе кода почти не зависит выбранного компилятора и библиотеки c++. Просто так вышло, что мне чуть ближе инфраструктура llvm, чем gcc, поэтому рассматриваться будет компилятор clang с библиотекой libc++, но повторюсь, особой разницы нет, ведь большинство рассматриваемого кода будет разобрано из библиотеки glibc. Программа при использовании bash (и не только) рождается при помощи вызова функции fork и создания нового процесса при помощи execve с передачей ей аргументов командной строки. Также до передачи управления первой инструкции исполняемого файла устанавливаются дескрипторы ввода и вывода(STDIN, STDOUT, STDERR), далее, в случае динамической линковки, подгружаются и инициализируются нужные программе библиотеки и вызываются функции секции ".preinit_array". Только после всего этого вызывается первая функция, находящаяся в исполняемом файле (не считая секции ".preinit_array"), традиционно именуемая _start, которая и считается началом программы. В случае со статической линковкой, работа линковщика, например инициализация секции ".preinit_array", находится внутри исполняемого файла и сами функции немного отличаются от динамически слинкованных программ. Мы же будем рассматривать именно динамически слинкованные программы. Точка входа исполняемого файла указана в его заголовке: Далее проверим, какая именно функция находится по этому адресу при помощи objdump -d hello1.out. Это уже упомянутая функция _start, на которую мы ставим точку останова и запускаем отладку. Определение Википедии:
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, при старте программы все регистры не определены за исключением: Вспомогательные вектора (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 подключается в нашу программу линковщиком в виде объектного файла 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, сигнатура которой имеет вид: И аргументы функции, согласно ABI, должны быть положены в соответствующие места: 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). Итак, идём дальше По исходному коду функции __libc_start_main видно, что для статически и динамически слинкованных библиотек генерируется различный код. Проверить, как выглядит код функции в библиотеке libc.so.6 можно при помощи gdb или же при помощи lldb:
lldb libc.so.6 -b -o 'di -n __libc_start_main' В коде библиотеки glibc присутствует множество вхождений __glibc_likely и __glibc_unlikely. Большое количество условных операций заменяется этим макросом. Макрос в конечном счёте преобразуется в следующие build-in функции: __builtin_expect — своего рода оптимизация, помогающая компилятору правильным образом расположить участки кода в памяти. Мы говорим компилятору, какая ветвь вероятнее всего будет исполнена, и эту область памяти компилятор помещает сразу же после инструкции сравнения, тем самым, улучшая кэшируемость инструкций, а оставшуюся ветвь, при её наличии, компилятор прячет в конец функции. Функция __libc_start_main немного громоздкая, достаточно кратко описать её основные действия: Функция __cxa_atexit, в отличие от atexit, которая является обёрткой над первой, может принимать параметры регистрируемой функции, но функция не должна вызываться напрямую из пользовательского пространства. Вызываться она не должна из-за того, что функция использует DSO-идентификатор, который известен только компилятору. Он нужен для того, чтобы при вызове __cxa_atexit(f,p,d), вызывалась функция f(p) при выгрузке DSO d [8]. Пример использования __cxa_atexit: Этот трюк рекомендую использовать только для ознокомления. Для вызова деструктора при выходе из программы надёжнее использовать любой аналогичный способ. rtld_fini является указателем на функцию линковщика _dl_fini. И да, линковщик является частью библиотеки glibc. Функция _dl_fini занимается деинициализацией и выгрузкой всех загруженных библиотек. Попасть в функцию __libc_csu_init можно аналогично тому, как мы попали в предыдущую. __libc_csu_init вызывает _init и указатели функций, находящиеся в секции .init_array. Функция _init целиком находится в секции .init. Её код разбит на 2 части: вступление и эпилог. Вступление состоит из пролога и попытки вызова функции __gmon_start__. Основная задача функции _init — инициализация профилировщика gprof. Инструкция "leaq 0x105(%rip), %rax" берёт адрес функции __gmon_start__ — функции, инициализирующей профилировщик. Если профилировщик не присутствует то в %rax будет находиться значение 0 и переход je сработает. Инструкции subq $0x8, %rsp и addq $0x8, %rsp производят выравнивание стека и возвращение его в исходное состояние. Это выравнивание нужно из-за того, что при вызове функции, мы кладём на стек обратный адрес, размер которого на архитектуре x86-64 равен 8 байтам. В секцию .init можно добавить собственный участок кода. Рассмотрим пример hello2.cpp: Рассмотрим как теперь выглядит _init: Как видно из листинга, между вступлением и эпилогом функции добавилась инструкция 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 имеет следующее значение: a00f0000 00000000, что означает адрес 0x0fa0 в 64-битной системе с little-endian порядком байт. По этому адресу находится функция frame_dummy. Интересно, что frame_dummy является частью библиотеки gcc. Не стоит забывать, что проект gcc является очень большим и уже пророс корнями в операционные системы linux. Проект gcc содержит не только компилятор, но и нужные для компиляции файлы. Таким образом, при линковке используются crt-файлы вроде crtbeginS.o и crtendS.o.
Поэтому, полностью избавиться от проекта gcc не удастся, и как минимум придётся оставить вспомогательные crt-файлы. Операционные системы unix, не использующие компилятор gcc в качестве основного так и делают. frame_dummy выглядит следующим образом: Задачей frame_dummy является установка аргументов и запуск функции register_tm_clones. Эта прослойка нужна только для того, чтобы выставить аргументы. В данном случае аргументы не выставляются, но как можно увидеть по исходному коду, это не всегда так, зависит от архитектуры. Интересно, что первые 2 инструкции являются прологом, третья — эпилогом. Инструкция jmp же является хвостовой оптимизацией вызова функции. И как обычно, в конце выравнивание. Функция register_tm_clones нужна для того, чтобы активировать транзакционную память. Глобальные объекты, в случае их наличия, инициализируются именно здесь.
При наличии глобальных объектов, в секцию .init_array добавляется адрес функции _GLOBAL__sub_I_<имя компилируемого файла>. Рассмотрим пример инициализации глобальных переменных:
global1.cpp: Переменная будет инициализирована следующим образом: Первые 2 инструкции являются прологом. Дальше мы подготавливаемся к вызову функции printf, положив в %rdi указатель на нашу строку и прировняв %al к нулю. Согласно ABI [2], функции с переменным количеством аргументов содержат скрытый параметр, хранящийся в %al, означающий количество переменных аргументов, содержащихся в векторных регистрах. Скорее всего это нужно для оптимизации некоторых функций, но printf использует эту информацию, для того, чтобы переместить данные из векторных регистров на стек.
После вызова printf, результат функции помещается в область памяти переменной k и вызывается эпилог. global2.cpp:
Допустим у нас есть некий класс Global с недефолтными конструктором и деструктором: Тогда инициализация будет выглядить следующим образом: Здесь мы видим, как после вызова глобального конструктора, регистрируется деструктор при помощи __cxa_atexit. Это реализовано согласно Itanium ABI [8]. Из glibc инициализация вызывается следующим образом: (*__init_array_start [i]) (argc, argv, envp); Заметьте, что инициализирующей функции передаются параметры, аналогичные функции main, значит мы можем их использовать. В компиляторах gcc и clang имеется атрибут constructor, при помощи которого функция вызывается перед этапом инициализации объектов. В него-то мы и можем передать эти аргументы. Проверьте вывод программы использующей следующую глобальную функцию: Это можно использовать для более практичных целей (hello3.cpp): В параметрах атрибута constructor указан приоритет вызова. Как вы наверняка уже догадались, программа выведет верное количество аргументов, и самое интересное, объект c является константным. Главный минус такого подхода — отсутствие поддержки стандарта и, как следствие, отсутствие кроссплатформенности. Также такой код сильно зависит от используемой библиотеки libc. Хочется добавить, что глобальные переменные вида int x = 1 + 2 * 3; не инициализируются вовсе, их значения изначально записываются компилятором в память. Если же вы хотите, чтобы переменные, инициализируемые простыми функции вроде int s = sum(4, 5) также были изначально инициализированы, добавляйте к функции sum идентификатор constexpr из стандарта C++11. Точка отмены создаётся при помощи вызова setjmp и настройки глобальной переменной.
Сохранение контекста setjmp нужно для установки буфера отмены, чтобы при отмене основного потока, его можно было корректно завершить. Файл cancel.cpp. В примере cancel.cpp, произойдёт завершение основного потока при помощи отмены из вспомогательного, и, в последствии вызовется функция exit. Причём, если бы созданный нами поток продолжил бы существование после отмены основного потока, счётчик потоков сказал бы, что существуют ещё потоки процесса и тогда завершился бы только основной поток, а вспомогательный продолжил бы своё существование. Можете проверить, что контекст действительно восстанавливается, поставив точку останова сразу после инструкции вызова setjmp: Программа остановится дважды: первый раз при инициализации программы, второй — после отмены основного потока. Вместо ожидаемого setjmp вызов происходит на __GI__setjmp. Данная функция является алиасом первой и создание подобного алиаса производится для каждого символа, используемого внутри библиотеки. Это делается в основном для сохранения работоспособности программы при замене глобальных символов и для увеличения производительности [7]. Производительность улучшается за счёт того, что происходит прямой вызов, а не через таблицу вызовов PLT. Исходный код функции состоит из одной строчки. То же самое можно записать, как: или же В языке 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 следующим образом (для наглядности, функция немного упрощена): В библиотеке активно применяются statement expressions, являющиеся расширением компилятора gcc: Результатом такого выражения будет результат вычисления последней инструкции, то есть 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 вызывает системный вызов 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