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

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

Вот предстоящий нам маршрут:


Схема получена с помощью dot-фильтра, используемого для рисования направленных графов

К концу статьи вам все это станет понятно.

Как мы попадаем в main?


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

int
main()
{
}

Если хотите, можете сохранить копию этой программы как prog1.c и повторять за мной. Первым делом я выполню ее сборку:

gcc -ggdb -o prog1 prog1.c

Прежде, чем переходить к отладке последующей версии этой программы (prog2) в gdb, мы ее дизассемблируем и узнаем некоторые детали о процессе запуска. Я покажу вам вывод objdump -d prog1, но не в порядке фактического вывода, а в порядке его выполнения. (В идеале вам следует сделать это самим. Например, сохранить копию с помощью objdump -d prog1 >prog1.dump, чтобы потом просмотреть ее в привычном редакторе).

Для начала разберемся, как мы попадаем в _start


При запуске программы оболочка или GUI вызывают execve(), которая выполняет системный вызов execve(). Если вы хотите побольше узнать об этом системном вызове, то просто введите в оболочке man execve. Он находится в разделе 2 мануала вместе со всеми остальными системными вызовами. Если кратко, то он настраивает стек и передает в него argc, argv и envp.

Дескрипторы файлов 0, 1 и 2 (stdin, stdout, stderr) остаются со значениями, установленными для них оболочкой. Загрузчик проделывает много работы, настраивая релокации и, как мы увидим позднее, вызывая пре-инициализаторы. Когда все готово, управление передается программе через вызов _start().

Вот соответствующий раздел objdump -d prog1:

080482e0 <_start>:
80482e0:       31 ed                   xor    %ebp,%ebp
80482e2:       5e                      pop    %esi
80482e3:       89 e1                   mov    %esp,%ecx
80482e5:       83 e4 f0                and    $0xfffffff0,%esp
80482e8:       50                      push   %eax
80482e9:       54                      push   %esp
80482ea:       52                      push   %edx
80482eb:       68 00 84 04 08          push   $0x8048400
80482f0:       68 a0 83 04 08          push   $0x80483a0
80482f5:       51                      push   %ecx
80482f6:       56                      push   %esi
80482f7:       68 94 83 04 08          push   $0x8048394
80482fc:       e8 c3 ff ff ff          call   80482c4 <__libc_start_main@plt>
8048301:       f4                      hlt

Операция XOR элемента с самим собой устанавливает этот элемент на нуль. Поэтому xor %ebp,%ebp устанавливает %ebp на нуль. Это предполагается спецификацией ABI (Application Binary Interface) для обозначения внешнего фрейма.

Далее мы извлекаем верхний элемент стека. Здесь у нас на входе argc, argv и envp, значит операция извлечения отправляет argc в %esi. Мы планируем просто сохранить его и вскоре вернуть обратно в стек. Так как argc мы извлекли, %esp теперь указывает на argv. Операция mov помещает argv в %ecx, не перемещая указатель стека.

Теперь мы выполняем для указателя стека операцию and с маской, которая обнуляет нижние четыре бита. В зависимости от того, где находился указатель, он переместится ниже на величину от 0 до 15 байт, что приведет к выравниванию кратно 16 байтам. За счет подобного выравнивания элементов стека повышается эффективность обработки памяти и кэша. В частности, это необходимо для SSE (Streaming SIMD Extensions), инструкций, способных одновременно обрабатывать вектора с плавающей точкой одинарной точности.

В конкретно этом случае %esp на входе в _start имел значение 0xbffff770. После того, как мы извлекли argc, %esp стал 0xbffff774, то есть сместился на более высокий адрес (добавление элементов в стек ведет к перемещению по памяти вниз, а их извлечение – вверх). После выполнения and значение указателя стека вновь стало 0xbffff770.

Далее устанавливаем значения для вызова __libc_start_main


Теперь мы начинаем передавать в стек аргументы для _libc_start_main. Первый, %eax, является мусором, который передается только потому, что в стек мы собираемся поместить 7 элементов, а для 16-байтового выравнивания требуется 8-й. Использоваться он не будет. Сама функция _libc_start_main линкуется из glibc. В дереве исходного кода glibc она находится в файле csu/libc-start.c. Определяется _libc_start_main так:

int __libc_start_main(  int (*main) (int, char * *, char * *),
			    int argc, char * * ubp_av,
			    void (*init) (void),
			    void (*fini) (void),
			    void (*rtld_fini) (void),
			    void (* stack_end));

Итак, мы ожидаем, что _start передаст обозначенные аргументы в стек в обратном порядке до вызова _libc_start_main.

Содержимое стека перед вызовом __libc_start_main

__libc_csu_fini линкуется в наш код из glibc и находится в файле csu/elf-init.c дерева исходного кода. Это деструктор нашей программы на уровне Си, и чуть позже я разберу его подробно.

Так, а где переменные среды?


void __libc_init_first(int argc, char *arg0, ...)
{
    char **argv = &arg0, **envp = &argv[argc + 1];
    __environ = envp;
    __libc_init (argc, argv, envp);
}

Вы заметили, что мы не получили из стека envp, указатель на наши переменные среды? Среди аргументов _libc_start_main его тоже нет. Но мы знаем, что main называется int main(int argc, char** argv, char** envp), так в чем же дело?

Что ж, _libc_start_main вызывает _libc_init_first, которая с помощью секретной внутренней информации находит переменные среды сразу после завершающего нуля вектора аргументов, после чего устанавливает глобальную переменную _environ, которую _libc_start_main при необходимости использует впоследствии, в том числе в вызовах main.

После установки envp функция _libc_start_main использует тот же трюк и…вуаля! Сразу за завершающим нулем в конце массива envp находится очередной вектор, а именно вспомогательный вектор ELF, который загрузчик использует для передачи процессу определенной информации. Для просмотра его содержимого достаточно просто установить перед запуском программы переменную среды LD_SHOW_AUXV=1. Вот результат для нашей prog1:

$ LD_SHOW_AUXV=1 ./prog1
AT_SYSINFO:      0xe62414
AT_SYSINFO_EHDR: 0xe62000
AT_HWCAP:    fpu vme de pse tsc msr pae mce cx8 apic
             mtrr pge mca cmov pat pse36 clflush dts
             acpi mmx fxsr sse sse2 ss ht tm pbe
AT_PAGESZ:       4096
AT_CLKTCK:       100
AT_PHDR:         0x8048034
AT_PHENT:        32
AT_PHNUM:        8
AT_BASE:         0x686000
AT_FLAGS:        0x0
AT_ENTRY:        0x80482e0
AT_UID:          1002
AT_EUID:         1002
AT_GID:          1000
AT_EGID:         1000
AT_SECURE:       0
AT_RANDOM:       0xbff09acb
AT_EXECFN:       ./prog1
AT_PLATFORM:     i686

Разве не интересно? Тут полно всяческой информации.

  • Здесь мы видим AT_ENTRY, представляющую адрес _start, где находится наш userid, действующий userid и groupid.
  • Очевидно, что используется платформа 686, а частота times() равна 100 тактов/с.
  • AT_PHDR указывает расположение ELF-заголовка программы, в котором хранится информация о нахождении всех сегментов этой программы в памяти, а также о записях релокаций и всем остальном, что нужно знать загрузчику.
  • AT_PHENT – это просто количество байт в записи заголовка.

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

__libc_start_main в общем


На этом я закончу разбор деталей _libc_start_main и лишь добавлю, что в общем он:

  • Реализует функционал безопасности с помощью вызововsetuid и stgid;
  • Запускает потоковую обработку;
  • Регистрирует аргументы fini (для нашей программы) и аргументы rtld_fini (для загрузчика среды выполнения), которые запустит at_exit для выполнения процедур очистки программы и загрузчика.
  • Вызывает аргумент init;
  • Вызывает main с передаваемыми ей аргументами argc и argv, а также аргументом global_environ, о чем я писал выше;
  • Вызывает exit с возвращаемым main значением.

Вызов аргумента init


Аргумент init для _libc_start_main устанавливается на _libc_csu_init, который также линкуется в наш код. Он компилируется из программы Си, расположенной в файле csu/elf-init.c дерева исходного кода glibc, и линкуется в нашу программу. Его код Си похож на (но содержит намного больше #ifdef)…

Конструктор нашей программы


void
__libc_csu_init (int argc, char **argv, char **envp)
{

  _init ();

  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);
}

_libc_csu_init для нашей программы очень важен, так как конструирует ее исполняемый файл. Я уже слышу, как вы говорите: «Это же не C++!». Все верно, но принцип конструкторов и деструкторов не принадлежит к C++ и предшествовал этому языку.

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

080483a0 <__libc_csu_init>:
 80483a0:       55                      push   %ebp
 80483a1:       89 e5                   mov    %esp,%ebp
 80483a3:       57                      push   %edi
 80483a4:       56                      push   %esi
 80483a5:       53                      push   %ebx
 80483a6:       e8 5a 00 00 00          call   8048405 <__i686.get_pc_thunk.bx>
 80483ab:       81 c3 49 1c 00 00       add    $0x1c49,%ebx
 80483b1:       83 ec 1c                sub    $0x1c,%esp
 80483b4:       e8 bb fe ff ff          call   8048274 <_init>
 80483b9:       8d bb 20 ff ff ff       lea    -0xe0(%ebx),%edi
 80483bf:       8d 83 20 ff ff ff       lea    -0xe0(%ebx),%eax
 80483c5:       29 c7                   sub    %eax,%edi
 80483c7:       c1 ff 02                sar    $0x2,%edi
 80483ca:       85 ff                   test   %edi,%edi
 80483cc:       74 24                   je     80483f2 <__libc_csu_init+0x52>
 80483ce:       31 f6                   xor    %esi,%esi
 80483d0:       8b 45 10                mov    0x10(%ebp),%eax
 80483d3:       89 44 24 08             mov    %eax,0x8(%esp)
 80483d7:       8b 45 0c                mov    0xc(%ebp),%eax
 80483da:       89 44 24 04             mov    %eax,0x4(%esp)
 80483de:       8b 45 08                mov    0x8(%ebp),%eax
 80483e1:       89 04 24                mov    %eax,(%esp)
 80483e4:       ff 94 b3 20 ff ff ff    call   *-0xe0(%ebx,%esi,4)
 80483eb:       83 c6 01                add    $0x1,%esi
 80483ee:       39 fe                   cmp    %edi,%esi
 80483f0:       72 de                   jb     80483d0 <__libc_csu_init+0x30>
 80483f2:       83 c4 1c                add    $0x1c,%esp
 80483f5:       5b                      pop    %ebx
 80483f6:       5e                      pop    %esi
 80483f7:       5f                      pop    %edi
 80483f8:       5d                      pop    %ebp
 80483f9:       c3                      ret


Что такое thunk?


Говорить здесь особо не о чем, но я подумал, что вы захотите это увидеть. Функция get_pc_thunk весьма интересна. Она вызывается для настройки позиционно-независимого кода. Чтобы все сработало, указатель базы должен иметь адрес GLOBAL_OFFSET_TABLE. Соответствующий код выглядел так:

__get_pc_thunk_bx:
movel (%esp),%ebx
return


push %ebx
call __get_pc_thunk_bx
add  $_GLOBAL_OFFSET_TABLE_,%ebx

Посмотрим на происходящее подробнее. Вызов _get_pc_thunk_bx, как и любой другой, помещает в стек адрес следующей функции, чтобы при возвращении выполнение продолжилось с очередной инструкции. В данном случае нам нужен тот самый адрес. Значит, в _get_pc_thunk_bx мы копируем адрес возврата из стека в %ebx. Когда происходит возврат, очередная инструкция прибавляет к нему _GLOBAL_OFFSET_TABLE_, разрешаясь в разницу между текущим адресом и глобальной таблицей смещений, используемую позиционно-независимым кодом.

В этой таблице хранится набор указателей на данные, к которым мы хотим обратиться, и нам лишь нужно знать их смещения. При этом загрузчик сам фиксирует для нас нужный адрес. Для обращения к процедурам существует аналогичная таблица. Было бы поистине утомительно программировать подобным образом в ассемблере, но можно просто написать нужный код на Си/С++ и передать аргумент -pic компилятору, который сделает это автомагически.
Встречая данный код в ассемблере, вы можете сделать вывод, что исходник был скомпилирован с флагом -pic.

Но что это за цикл?


Цикл из _libc_csu_init мы рассмотрим сразу после вызова init(), который фактически вызывает _init. Пока же просто имейте ввиду, что он вызывает для нашей программы любые инициализаторы уровня Си.

Вызов _init


Хорошо. Загрузчик передал управление _start, которая вызвала _libc_start_main, которая вызвала _libc_csu_init, которая теперь вызывает _init:

08048274 <_init>:
 8048274:       55                      push   %ebp
 8048275:       89 e5                   mov    %esp,%ebp
 8048277:       53                      push   %ebx
 8048278:       83 ec 04                sub    $0x4,%esp
 804827b:       e8 00 00 00 00          call   8048280 <_init+0xc>
 8048280:       5b                      pop    %ebx
 8048281:       81 c3 74 1d 00 00       add    $0x1d74,%ebx        (.got.plt)
 8048287:       8b 93 fc ff ff ff       mov    -0x4(%ebx),%edx
 804828d:       85 d2                   test   %edx,%edx
 804828f:       74 05                   je     8048296 <_init+0x22>
 8048291:       e8 1e 00 00 00          call   80482b4 <__gmon_start__@plt>
 8048296:       e8 d5 00 00 00          call   8048370 <frame_dummy>
 804829b:       e8 70 01 00 00          call   8048410 <__do_global_ctors_aux>
 80482a0:       58                      pop    %eax
 80482a1:       5b                      pop    %ebx
 80482a2:       c9                      leave
 80482a3:       c3                      ret

Начинается она с регулярного соглашения о вызовах Си


Если вы хотите побольше узнать об этом соглашении, почитайте Basic Assembler Debugging with GDB. Если коротко, то мы сохраняем указатель базы вызывающего компонента в стеке и направляем указатель базы на верхушку стека, после чего резервируем место для своего рода 4-байтовой локальной переменной.

Интересен здесь первый вызов. Его задача во многом аналогична вызову get_pc_thunk, который мы видели ранее. Если посмотреть внимательно, то он направлен к следующему по порядку адресу. Это переносит нас к очередному адресу, как если бы мы просто продолжили, но при этом в качестве побочного эффекта данный адрес оказывается в стеке. Он помещается в %ebx, а затем используется для установки доступа к глобальной таблице доступа.

Покажи мне свой профиль


Далее мы захватываем адрес gmon_start. Если он равен нулю, то мы его просто проскакиваем. В противном случае он вызывается для запуска профилирования. В этом случае происходит запуск процедуры для начала профилирования и вызов at_exit, чтобы по завершению сработала другая процедура и записала gmon.out.

Вызов frame_dummy


В любом из случаев дальше мы вызываем frame_dummy. Вообще нам нужно вызвать _register_frame_info, а frame_dummy просто устанавливает для этой функции аргументы. Конечная цель – настроить разворачивание стековых фреймов для обработки исключений. Это интересно, но к нашему разбору не относится, и в данном случае все равно не используется.

Переходим к конструкторам!


В завершении мы вызываем _do_global_ctors_aux. Если у вас сложности с программой, которые возникают до запуска main, то искать, возможно, нужно именно здесь. Конечно, сюда помещаются конструкторы для глобальных объектов С++, но кроме них тут могут находиться и другие компоненты.

Создадим пример


Теперь давайте изменим prog1, создав prog2. Самая интересная часть – это __attribute__ ((constructor)), который сообщает gcc, что компоновщик должен поместить соответствующий указатель в таблицу, используемую _do_global_ctors_aux. Как видите, наш фиктивный конструктор выполняется. (компилятор заполняет _FUNCTION_ именем функции. Это магия gcc).

#include <stdio.h>

void __attribute__ ((constructor)) a_constructor() {
    printf("%s\n", __FUNCTION__);
}

int
main()
{
    printf("%s\n",__FUNCTION__);
}


$ ./prog2
a_constructor
main
$

_init в prog2 практически не изменяется


Чуть позже мы подключим к процессу gdb и разберем эту программу. Ну а пока же рассмотрим ее _init.

08048290 <_init>:
 8048290:       55                      push   %ebp
 8048291:       89 e5                   mov    %esp,%ebp
 8048293:       53                      push   %ebx
 8048294:       83 ec 04                sub    $0x4,%esp
 8048297:       e8 00 00 00 00          call   804829c <_init+0xc>
 804829c:       5b                      pop    %ebx
 804829d:       81 c3 58 1d 00 00       add    $0x1d58,%ebx
 80482a3:       8b 93 fc ff ff ff       mov    -0x4(%ebx),%edx
 80482a9:       85 d2                   test   %edx,%edx
 80482ab:       74 05                   je     80482b2 <_init+0x22>
 80482ad:       e8 1e 00 00 00          call   80482d0 <__gmon_start__@plt>
 80482b2:       e8 d9 00 00 00          call   8048390 <frame_dummy>
 80482b7:       e8 94 01 00 00          call   8048450 <__do_global_ctors_aux>
 80482bc:       58                      pop    %eax
 80482bd:       5b                      pop    %ebx
 80482be:       c9                      leave
 80482bf:       c3                      ret

Как видите, адреса немного отличаются от prog1. Похоже, дополнительный элемент данных сместил все на 28 байт. Итак, здесь у нас имена двух функций, a_constructor (14 байт с завершающим нулем) и main (5 байт с завершающим нулем), а также две форматирующих строки %s\n (2*4 байта с символом переноса строки и завершающим нулем).

Итого получается 14+5+4+4 = 27. Хмм…одного не хватает. Хотя это просто предположение, проверять я не стал. Позже мы все равно сделаем остановку на вызове _do_global_ctors_aux, сделаем один шаг и проанализируем происходящее.

А вот и код, который будет вызван


Чисто в качестве подсказки приведу код для _do_global_ctors_aux, взятый из файла gcc/crtstuff.c исходного кода gcc.

__do_global_ctors_aux (void)
{
  func_ptr *p;
  for (p = __CTOR_END__ - 1; *p != (func_ptr) -1; p--)
    (*p) ();
}

Как видите, он инициализирует p из глобальной переменной _CTOR_END_ и вычитает из нее 1. Напомню, что это арифметика указателей, а указатель указывает на функцию, значит в данном случае -1 приводит к смещению на один указатель функции назад или на 4 байта. Мы также увидим это в ассемблере.

Несмотря на то, что указатель не имеет значения -1 (приведение к указателю), мы вызовем функцию, на которую указываем, после чего снова переведем указатель функции назад. Очевидно, что таблица начинается с -1, после чего идет некоторое количество (возможно даже 0) указателей функции.

То же самое в ассемблере


Вот код ассемблера, соответствующий полученному из objdump -d результату. Прежде, чем переходить к трассировке с помощью отладчика, мы внимательно по нему пройдемся, чтобы вам было понятнее.

08048450 <__do_global_ctors_aux>:
 8048450:       55                      push   %ebp
 8048451:       89 e5                   mov    %esp,%ebp
 8048453:       53                      push   %ebx
 8048454:       83 ec 04                sub    $0x4,%esp
 8048457:       a1 14 9f 04 08          mov    0x8049f14,%eax
 804845c:       83 f8 ff                cmp    $0xffffffff,%eax
 804845f:       74 13                   je     8048474 <__do_global_ctors_aux+0x24>
 8048461:       bb 14 9f 04 08          mov    $0x8049f14,%ebx
 8048466:       66 90                   xchg   %ax,%ax
 8048468:       83 eb 04                sub    $0x4,%ebx
 804846b:       ff d0                   call   *%eax
 804846d:       8b 03                   mov    (%ebx),%eax
 804846f:       83 f8 ff                cmp    $0xffffffff,%eax
 8048472:       75 f4                   jne    8048468 <__do_global_ctors_aux+0x18>
 8048474:       83 c4 04                add    $0x4,%esp
 8048477:       5b                      pop    %ebx
 8048478:       5d                      pop    %ebp
 8048479:       c3                      ret

Сначала пролог


Здесь у нас типичный пролог с добавлением резервирования %ebx, так как мы собираемся использовать его в функции. Помимо этого, мы резервируем место для указателя p. Вы заметите, что несмотря на резервирование под него места в стеке, хранить мы его там не будем. Вместо этого p будет размещаться в %ebx, а *p в %eax.

Далее подготовка к циклу


Похоже, произошла оптимизация. Вместо загрузки _CTOR_END_ с последующим вычитанием из него 1 и разыменовыванием мы переходим далее и загружаем *(__CTOR_END__ - 1), который представляет непосредственное значение 0x8049f14. Его значение мы помещаем в %eax (помните, что инструкция $0x8049f14 означала бы помещение этого значения, а ее вариант без $ — помещение содержимого этого адреса).

Следом мы сравниваем это первое значение с -1, и если они равны, то заканчиваем и переходим к адресу 0x8049f14, где очищаем стек, извлекая все сохраненные в нем элементы и делая возврат.

Предполагая, что в таблице функций есть хотя бы один элемент, мы также перемещаем непосредственное значение $8049f14 в %ebx, который является нашим указателем функции f, после чего выполняем xchg %ax,%ax.

Что это вообще такое? Эта команда используется в качестве NOP (No OPeration) в 16- и 32-битных x86. По факту она ничего не делает. В нашем случае ее задача в том, чтобы цикл (верхняя его часть – это вычитание на следующей строке) начинался не с 8048466, а с 8048468. Смысл здесь в выравнивании начала цикла с 4-байтовой границей, в результате чего весь цикл с большей вероятностью впишется в одну строку кэша, не разбиваясь на две. Это все ускорит.

И вот мы у вершины цикла


Далее мы вычитаем 4 из %ebx, подготавливаясь к очередному циклу, вызываем функцию, адрес которой получили в %eax, перемещаем следующий указатель функции в %eax и сравниваем его с -1. Если они не равны, возвращаемся к операции вычитания и повторяем цикл.

Эпилог


В противном случае мы достигаем эпилога функции и возвращаемся к _init, которая сразу достигает своего эпилога и возвращается к _libc_csu_init_, о котором вы уже наверняка забыли. Здесь все еще остается один цикл для завершения, но сначала…

Как я и обещал, мы займемся отладкой prog2.

Напомню, что gdb всегда показывает очередную строку или инструкцию, которая будет выполнена.

$ !gdb
gdb prog2
Reading symbols from /home/patrick/src/asm/prog2...done.
(gdb) set disassemble-next-line on
(gdb) b *0x80482b7
Breakpoint 1 at 0x80482b7

Мы запустили программу в отладчике, включили disassemble-next-line, чтобы он всегда показывал очередную строку в дизассемблированном виде, и установили точку останова на строку в _init, где будем вызывать _do_global_ctors_aux.

(gdb) r
Starting program: /home/patrick/src/asm/prog2 

Breakpoint 1, 0x080482b7 in _init ()
=> 0x080482b7 <_init+39>:	 e8 94 01 00 00	call   0x8048450 <__do_global_ctors_aux>
(gdb) si
0x08048450 in __do_global_ctors_aux ()
=> 0x08048450 <__do_global_ctors_aux+0>:	 55	push   %ebp

Здесь я ввел r, чтобы запустить программу и достичь точки останова. Очередной командой для gdb стала si, инструкция шага, указывающая отладчику шагнуть на одну инструкцию вперед.

Теперь мы вошли в _do_global_ctors_aux. Далее вы заметите моменты, когда я будто бы не ввожу команды для gdb, хотя это не так. Дело в том, что при нажатии Ввода отладчик повторяет последнюю инструкцию. То есть, если я нажму Ввод сейчас, то еще раз выполню si.

(gdb)
0x08048451 in __do_global_ctors_aux ()
=> 0x08048451 <__do_global_ctors_aux+1>:	 89 e5	mov    %esp,%ebp
(gdb) 
0x08048453 in __do_global_ctors_aux ()
=> 0x08048453 <__do_global_ctors_aux+3>:	 53	push   %ebx
(gdb) 
0x08048454 in __do_global_ctors_aux ()
=> 0x08048454 <__do_global_ctors_aux+4>:	 83 ec 04	sub    $0x4,%esp
(gdb) 
0x08048457 in __do_global_ctors_aux ()

Хорошо, с прологом мы закончили, пришло время реального кода.

(gdb)
=> 0x08048457 <__do_global_ctors_aux+7>:	 a1 14 9f 04 08	mov    0x8049f14,%eax
(gdb) 
0x0804845c in __do_global_ctors_aux ()
=> 0x0804845c <__do_global_ctors_aux+12>:	 83 f8 ff	cmp    $0xffffffff,%eax
(gdb) p/x $eax
$1 = 0x80483b4

После загрузки указателя мне стало любопытно, и я ввел p/x $eax, то есть попросил gdb вывести hex-содержимое регистра %eax. Это не -1, значит можно предположить, что цикл мы проходим.

Теперь, поскольку последней командой был вывод, я не могу повторить si нажатием Ввода, и мне придется ее ввести.

(gdb) si
0x0804845f in __do_global_ctors_aux ()
=> 0x0804845f <__do_global_ctors_aux+15>:	 74 13	je     0x8048474 <__do_global_ctors_aux+36>
(gdb) 
0x08048461 in __do_global_ctors_aux ()
=> 0x08048461 <__do_global_ctors_aux+17>:	 bb 14 9f 04 08	mov    $0x8049f14,%ebx
(gdb) 
0x08048466 in __do_global_ctors_aux ()
=> 0x08048466 <__do_global_ctors_aux+22>:	 66 90	xchg   %ax,%ax
(gdb) 
0x08048468 in __do_global_ctors_aux ()
=> 0x08048468 <__do_global_ctors_aux+24>:	 83 eb 04	sub    $0x4,%ebx
(gdb) 
0x0804846b in __do_global_ctors_aux ()
=> 0x0804846b <__do_global_ctors_aux+27>:	 ff d0	call   *%eax
(gdb) 
a_constructor () at prog2.c:3
3	void __attribute__ ((constructor)) a_constructor() {
=> 0x080483b4 <a_constructor+0>:	 55	push   %ebp
   0x080483b5 <a_constructor+1>:	 89 e5	mov    %esp,%ebp
   0x080483b7 <a_constructor+3>:	 83 ec 18	sub    $0x18,%esp

Вот здесь очень интересно. Мы шагнули в вызов и теперь находимся в функции a_constructor. Поскольку у gdb есть для нее исходный код, он показывает исходник Си для следующей строки. А так как я включил disassemble-next-line, он также покажет нам соответствующий код ассемблера.

В данном случае это пролог функции, и мы получаем все его три строки. Разве не интересно? Далее я переключусь на команду n (next), потому что скоро покажется printf. Первая n пропустит пролог, вторая printf, а третья эпилог. Если вас когда-нибудь интересовало, почему при пошаговом продвижении с помощью gdb нужно делать дополнительный шаг в начале и конце функции, то теперь вы знаете почему.

(gdb) n
4	    printf("%s\n", __FUNCTION__);
=> 0x080483ba <a_constructor+6>:	 c7 04 24 a5 84 04 08	movl   $0x80484a5,(%esp)
   0x080483c1 <a_constructor+13>:	 e8 2a ff ff ff	call   0x80482f0 <puts@plt>

Мы переместили адрес строки a_constructor в стек в качестве аргумента для printf, но он вызывает puts, поскольку компилятор догадался, что нас интересует только puts.

 (gdb) n
a_constructor
5	}
=> 0x080483c6 <a_constructor+18>:	 c9	leave  
   0x080483c7 <a_constructor+19>:	 c3	ret    

Раз мы трассируем программу, то она, естественно, выполняется, в связи с чем выше мы видим вывод a_constructor. Закрывающая скобка } соответствует эпилогу, поэтому он выводится сейчас. К слову отмечу, если вам не знакома инструкция leave, то выполняет она то же, что и:

    movl %ebp, %esp
    popl %ebp

Очередной шаг выводит нас из функции с возвращением ее результата. Здесь мне потребуется снова переключиться на si.

(gdb) n
0x0804846d in __do_global_ctors_aux ()
=> 0x0804846d <__do_global_ctors_aux+29>:	 8b 03	mov    (%ebx),%eax
(gdb) si
0x0804846f in __do_global_ctors_aux ()
=> 0x0804846f <__do_global_ctors_aux+31>:	 83 f8 ff	cmp    $0xffffffff,%eax
(gdb) 
0x08048472 in __do_global_ctors_aux ()
=> 0x08048472 <__do_global_ctors_aux+34>:	 75 f4	jne    0x8048468 <__do_global_ctors_aux+24>
(gdb) p/x $eax
$2 = 0xffffffff

Мне снова стало интересно, и я решил еще раз проверить значение указателя функции. На этот раз он равен -1, значит из цикла мы выходим.

(gdb) si
0x08048474 in __do_global_ctors_aux ()
=> 0x08048474 <__do_global_ctors_aux+36>:	 83 c4 04	add    $0x4,%esp
(gdb) 
0x08048477 in __do_global_ctors_aux ()
=> 0x08048477 <__do_global_ctors_aux+39>:	 5b	pop    %ebx
(gdb) 
0x08048478 in __do_global_ctors_aux ()
=> 0x08048478 <__do_global_ctors_aux+40>:	 5d	pop    %ebp
(gdb) 
0x08048479 in __do_global_ctors_aux ()
=> 0x08048479 <__do_global_ctors_aux+41>:	 c3	ret    
(gdb) 
0x080482bc in _init ()
=> 0x080482bc <_init+44>:	 58	pop    %eax

Заметьте, что мы снова вернулись в _init.

 (gdb) 
0x080482bd in _init ()
=> 0x080482bd <_init+45>:	 5b	pop    %ebx
(gdb) 
0x080482be in _init ()
=> 0x080482be <_init+46>:	 c9	leave  
(gdb) 
0x080482bf in _init ()
=> 0x080482bf <_init+47>:	 c3	ret    
(gdb) 
0x080483f9 in __libc_csu_init ()
=> 0x080483f9 <__libc_csu_init+25>:	 8d bb 1c ff ff ff	lea    -0xe4(%ebx),%edi
(gdb) q
A debugging session is active.

	Inferior 1 [process 17368] will be killed.

Quit anyway? (y or n) y
$

Обратите внимание, что мы перепрыгнули обратно к _libc_csu_init, и здесь я ввел q для выхода из gdb. Вот и вся отладка, которую я обещал.

Теперь, когда мы вернулись в _libc_csu_init_, нужно разобраться еще с одним циклом, через который я уже не буду шагать, а просто его проговорю.

Возвращаемся в __libc_csu_init__


Поскольку мы итак провели немало времени за работой с циклом в ассемблере, а ассемблерный код для этого конструктора еще более утомителен, то я оставлю эту задачу для тех, кому она будет интересна. Просто напомню, как он выглядит в Си:

void
__libc_csu_init (int argc, char **argv, char **envp)
{

  _init ();

  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);
}

Еще один цикл вызова функции


Что такое массив _init_? Я уж думал, вы и не спросите. На этом этапе вы также можете выполнять код. Поскольку идет он сразу после возвращения из _init, которая запускала наши конструкторы, то содержимое этого массива будет выполняться после завершения конструкторов. Вы можете сообщить компилятору, что хотите выполнить на этом этапе функцию, которая в результате получит те же аргументы, что и main.

void init(int argc, char **argv, char **envp) {
 printf("%s\n", __FUNCTION__);
}

__attribute__((section(".init_array"))) typeof(init) *__init = init;

Мы пока этого делать не будем, потому что есть и другие подобные моменты. Давайте просто вернем результат из _lib_csu_init. Помните, куда это нас приведет?

Мы вернемся аж к __libc_start_main__


Теперь он вызывает наш main, а результат передает в exit().

exit() выполняет функции, зарегистрированные с помощью at_exit в порядке их добавления. Затем она выполняет очередной цикл функций, на этот раз из массива fini. Далее она выполняет еще один цикл функций, теперь уже деструкторов. (В реальности она находится во вложенном цикле и работает с массивом списков функций, но поверьте мне, завершаются они именно в этом порядке). Вот смотрите.

Эта программа, hooks.c, связывает все воедино


#include <stdio.h>

void preinit(int argc, char **argv, char **envp) {
 printf("%s\n", __FUNCTION__);
}

void init(int argc, char **argv, char **envp) {
 printf("%s\n", __FUNCTION__);
}

void fini() {
 printf("%s\n", __FUNCTION__);
}

__attribute__((section(".init_array"))) typeof(init) *__init = init;
__attribute__((section(".preinit_array"))) typeof(preinit) *__preinit = preinit;
__attribute__((section(".fini_array"))) typeof(fini) *__fini = fini;

void  __attribute__ ((constructor)) constructor() {
 printf("%s\n", __FUNCTION__);
}

void __attribute__ ((destructor)) destructor() {
 printf("%s\n", __FUNCTION__);
}

void my_atexit() {
 printf("%s\n", __FUNCTION__);
}

void my_atexit2() {
 printf("%s\n", __FUNCTION__);
}

int main() {
 atexit(my_atexit);
 atexit(my_atexit2);
}

Если собрать и выполнить эту программу (я зову ее hook.c), то выводом будет:

$ ./hooks
preinit
constructor
init
my_atexit2
my_atexit
fini
destructor
$

Конец


Еще раз продемонстрирую вам весь путь, который мы прошли, только теперь он должен быть вам уже более понятен.


Комментарии (10)


  1. victor_1212
    10.11.2021 16:35
    +2

    В оригинале статья Patrick Horgan называется "Linux x86 Program Start Up" надеюсь читающие обратят на это внимание, особенно те кто имеют дело с embedded systems


    1. berez
      10.11.2021 18:32
      +2

      Это просто автор разбирался на примере х86 и у него куча соответствующих ассемблерных листингов. А так-то общие принципы запуска программы, описанные в статье, работают и на других архитектурах:

      pi@raspberrypi:~/test/c_startup $ gcc hooks.c
      hooks.c: In function ‘main’:
      hooks.c:36:2: warning: implicit declaration of function ‘atexit’; did you mean ‘my_atexit’? [-Wimplicit-function-declaration]
         36 |  atexit(my_atexit);
            |  ^~~~~~
            |  my_atexit
      pi@raspberrypi:~/test/c_startup $ LD_SHOW_AUXV=1 ./a.out
      AT_SYSINFO_EHDR: 0x7ec6c000
      AT_HWCAP:        half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt vfpd32 lpae evtstrm
      AT_PAGESZ:       4096
      AT_CLKTCK:       100
      AT_PHDR:         0x10034
      AT_PHENT:        32
      AT_PHNUM:        9
      AT_BASE:         0x76f24000
      AT_FLAGS:        0x0
      AT_ENTRY:        0x10324
      AT_UID:          1000
      AT_EUID:         1000
      AT_GID:          1000
      AT_EGID:         1000
      AT_SECURE:       0
      AT_RANDOM:       0x7eba37ff
      AT_HWCAP2:       crc32
      AT_EXECFN:       ./a.out
      AT_PLATFORM:     v7l
      preinit
      init
      constructor
      my_atexit2
      my_atexit
      destructor
      fini
      


      1. dlinyj
        10.11.2021 18:40
        +1

        Только там чуть другая организация системных вызовов и именования регистров. Но так-то да.


      1. victor_1212
        10.11.2021 19:11

        > автор разбирался на примере х86

        конечно, Patrick Horgan что имел, то использовал, корректно упомянул архитектуру, но не версию кода, что не очень, таки не стал бы совсем уж обобщать без соответствующей проверки, достаточно много видел модификаций, pi это в общем капля в море :)

        ps

        imho, даже если все eval boards собрать, что открыто продаются (mouser и пр.), все равно немного, по сравнению с тем, что реально используется


        1. victor_1212
          10.11.2021 22:12
          +1

          pps

          название перевода уже подправлено, т.е. соответствует названию оригинала, интересно, что при этом карма так же получила -1, вероятно как благодарность за указание на неточность в названии перевода


          1. dlinyj
            11.11.2021 13:46
            +1

            На данный момент больше 4-х поставить вам в плоюс не могу, сорян. За поправку, спасибо большое.
            На будущее, по возможности лучше присылать личным сообщением, статью поправят, а комментарий останется. Это просто пожелание.


          1. Bright_Translate Автор
            11.11.2021 14:08
            +1

            Лично я не причастен к вашей карме. Видимо, есть другие доброжелатели.


            1. victor_1212
              11.11.2021 17:03
              +2

              тогда извините, прямой ответ это всегда хорошо, сам никогда и никому из принципа, imho habr только хуже от этой анонимности


  1. Mike-M
    12.11.2021 02:24
    +3

    В суть глубоко не вдавался, но с удовольствием поставил статье плюс за практически полное отсутствие ошибок правописания.
    Как же приятно читать грамотные тексты! Спасибо переводчику!
    Продублировал бы благодарность и в карму, но уже сделал это ранее.


    1. perfect_genius
      17.11.2021 23:39

      Кто-то мог по ходу чтения посылать им опечатки на исправление. Конкретно эти отвечают и исправляют.