Статья предназначена для тех, кто хочет понять процесс загрузки программ в 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)
Mike-M
12.11.2021 02:24+3В суть глубоко не вдавался, но с удовольствием поставил статье плюс за практически полное отсутствие ошибок правописания.
Как же приятно читать грамотные тексты! Спасибо переводчику!
Продублировал бы благодарность и в карму, но уже сделал это ранее.perfect_genius
17.11.2021 23:39Кто-то мог по ходу чтения посылать им опечатки на исправление. Конкретно эти отвечают и исправляют.
victor_1212
В оригинале статья Patrick Horgan называется "Linux x86 Program Start Up" надеюсь читающие обратят на это внимание, особенно те кто имеют дело с embedded systems
berez
Это просто автор разбирался на примере х86 и у него куча соответствующих ассемблерных листингов. А так-то общие принципы запуска программы, описанные в статье, работают и на других архитектурах:
dlinyj
Только там чуть другая организация системных вызовов и именования регистров. Но так-то да.
victor_1212
> автор разбирался на примере х86
конечно, Patrick Horgan что имел, то использовал, корректно упомянул архитектуру, но не версию кода, что не очень, таки не стал бы совсем уж обобщать без соответствующей проверки, достаточно много видел модификаций, pi это в общем капля в море :)
ps
imho, даже если все eval boards собрать, что открыто продаются (mouser и пр.), все равно немного, по сравнению с тем, что реально используется
victor_1212
pps
название перевода уже подправлено, т.е. соответствует названию оригинала, интересно, что при этом карма так же получила -1, вероятно как благодарность за указание на неточность в названии перевода
dlinyj
На данный момент больше 4-х поставить вам в плоюс не могу, сорян. За поправку, спасибо большое.
На будущее, по возможности лучше присылать личным сообщением, статью поправят, а комментарий останется. Это просто пожелание.
Bright_Translate Автор
Лично я не причастен к вашей карме. Видимо, есть другие доброжелатели.
victor_1212
тогда извините, прямой ответ это всегда хорошо, сам никогда и никому из принципа, imho habr только хуже от этой анонимности