Сегодня я задумалась о том, что происходит, когда запускаешь в Linux простую программу «Hello World» на Python.
print("hello world")
Вот как это выглядит в командной строке:
$ python3 hello.py
hello world
Но внутри происходит гораздо больше. Я объясню, что там творится, и, что гораздо важнее, расскажу об инструментах, при помощи которых вы сами сможете исследовать происходящее. Мы воспользуемся readelf
, strace
, ldd
, debugfs
, /proc
, ltrace
, dd
и stat
. Я не буду рассматривать относящиеся к Python части, только объясню, что происходит при выполнении динамически компонуемых исполняемых файлов.
До execve
До того, как запустится интерпретатор Python, должно произойти ещё многое. Какой исполняемый файл мы вообще запускаем? Где он находится?
▍ 1: Оболочка парсит строку python3 hello.py в исполняемую команду и в список аргументов: python3 и ['hello.py']
Тут может произойти множество разных вещей, например, расширение шаблона поиска. Если вы запустите python3 *.py
, то оболочка развернёт это в python3 hello.py
▍ 2: Оболочка определяет полный путь до python3
Теперь мы знаем, что нам нужно запустить python3
. Но каков полный путь к двоичному файлу? Для этого есть специальная переменная среды PATH
.
Проверьте сами: выполните в оболочке echo $PATH
. У меня результат выглядит так:
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
При выполнении этой команды оболочка начинает поиск по каждой папке в этом списке (по порядку), пытаясь найти совпадение.
В fish
(моей оболочке) логика разрешения пути находится здесь. В ней используется системный вызов stat
для проверки существования файла.
Проверьте сами: выполните strace -e stat bash
, а затем выполните команду вида python3
. Вы должны получить следующий результат:
stat("/usr/local/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/local/bin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/bin/python3", {st_mode=S_IFREG|0755, st_size=5479736, ...}) = 0
Мы видим, что она нашла двоичный файл по пути /usr/bin/python3
и завершила исполнение: она не продолжает искать в /sbin
или в /bin
.
▍ 2.1: Примечание об execvp
Если вы хотите выполнить ту же логику поиска по PATH, что и оболочка, но не реализовывать её самостоятельно, то можно воспользоваться функцией libc execvp
(или одной из нескольких других функций exec*
с p
в имени).
▍ 3: Как работает stat
Возможно, вы зададитесь вопросом, что же делает stat
? Когда операционная система открывает файл, этот процесс разбивается на два этапа.
- Она сопоставляет имя файла с inode, который содержит метаданные о файле.
- Она использует inode для получения содержимого файла.
Системный вызов stat
просто возвращает содержимое всех inode файла, он вообще не читает содержимое. Преимущество в том, что это намного быстрее. Давайте совершим краткий экскурс в inode. Подробнее о них можно почитать в отличном посте «Диск — это просто куча битов».
$ stat /usr/bin/python3
File: /usr/bin/python3 -> python3.9
Size: 9 Blocks: 0 IO Block: 4096 symbolic link
Device: fe01h/65025d Inode: 6206 Links: 1
Access: (0777/lrwxrwxrwx) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2023-08-03 14:17:28.890364214 +0000
Modify: 2021-04-05 12:00:48.000000000 +0000
Change: 2021-06-22 04:22:50.936969560 +0000
Birth: 2021-06-22 04:22:50.924969237 +0000
Проверьте сами: давайте посмотрим, где конкретно этот inode находится на нашем жёстком диске.
Сначала нам нужно узнать имя устройства жёсткого диска.
$ df
...
tmpfs 100016 604 99412 1% /run
/dev/vda1 25630792 14488736 10062712 60% /
...
Похоже, это /dev/vda1
. Далее давайте узнаем, где на нашем жёстком диске находится inode для /usr/bin/python3
:
$ sudo debugfs /dev/vda1
debugfs 1.46.2 (28-Feb-2021)
debugfs: imap /usr/bin/python3
Inode 6206 is part of block group 0
located at block 658, offset 0x0d00
Понятия не имею, как debugfs
узнаёт местоположение inode для этого имени файла, но мы не будем в это углубляться.
Теперь нам нужно вычислить, на какой глубине в большом массиве байтов нашего диска находится «блок 658, смещение 0x0d00». Каждый блок — это 4096 байтов, то есть нам нужно переместиться на 4096 * 658 + 0x0d00
байтов. Калькулятор говорит мне, что это 2698496
.
$ sudo dd if=/dev/vda1 bs=1 skip=2698496 count=256 2>/dev/null | hexdump -C
00000000 ff a1 00 00 09 00 00 00 f8 b6 cb 64 9a 65 d1 60 |...........d.e.`|
00000010 f0 fb 6a 60 00 00 00 00 00 00 01 00 00 00 00 00 |..j`............|
00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.|
00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............|
00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000060 00 00 00 00 12 4a 95 8c 00 00 00 00 00 00 00 00 |.....J..........|
00000070 00 00 00 00 00 00 00 00 00 00 00 00 2d cb 00 00 |............-...|
00000080 20 00 bd e7 60 15 64 df 00 00 00 00 d8 84 47 d4 | ...`.d.......G.|
00000090 9a 65 d1 60 54 a4 87 dc 00 00 00 00 00 00 00 00 |.e.`T...........|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
Отлично! Вот наш inode! Мы видим, что в нём написано python3
, и это очень хороший знак. Мы не будем вдаваться в детали, но struct inode ext4 из ядра Linux даёт нам понять, что первые 16 байтов — это «режим», или разрешения. Так что давайте разберёмся, как ffa1
соответствует разрешениям файлов.
- Байты
ffa1
соответствуют числу0xa1ff
, или 41471 (потому что x86 имеет формат little endian). - 41471 в восьмеричном виде — это
0120777
. - Это немного странно — разрешения файла определённо должны быть
777
, но что такое первые три цифры? Я такого раньше не видела! Узнать, что значит012
, можно из man inode (дойдите до раздела «The file type and mode»). Там есть небольшая таблица, гласящая, что012
означает «символьная ссылка».
Давайте проверим файл при помощи list и убедимся, действительно ли это символьная ссылка с разрешениями 777
:
$ ls -l /usr/bin/python3
lrwxrwxrwx 1 root root 9 Apr 5 2021 /usr/bin/python3 -> python3.9
Это так! Ура, мы правильно его декодировали.
▍ 4: Время для форка
Но мы всё ещё не готовы к запуску python3
. Сначала оболочке нужно создать новый дочерний процесс для запуска. Способ запуска новых процессов в Unix немного странен: сначала процесс клонирует себя, а затем исполняет execve
, которая заменяет клонированный процесс новым.
*Проверьте сами: выполните strace -e clone bash
, а затем python3
. Вы должны увидеть нечто подобное:
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03788f1a10) = 3708100
3708100
— это PID нового процесса, который является дочерним процесса оболочки.
Вот ещё несколько инструментов для изучения происходящего с процессами:
pstree
показывает дерево всех процессов в системе;cat /proc/PID/stat
показывает информацию о процессе. Содержимое этого файла задокументировано вman proc
. Например, четвёртое поле — это PID родителя.
▍ 4.1: Что наследует новый процесс
Новый процесс (который станет python3
) наследовал у оболочки многое. Например, он унаследовал:
- переменные среды: их можно просмотреть при помощи
cat /proc/PID/environ | tr '' 'n'
; - дескрипторы файлов для stdout и stderr: их можно просмотреть при помощи
ls -l /proc/PID/fd
; - рабочую папку (которая является текущей);
- пространства имён и cgroups (если он находится в контейнере);
- пользователя и группу, которые его запустили;
- вероятно, что-то ещё, чего я не могу вспомнить.
▍ 5: Оболочка вызывает execve
Теперь мы готовы запустить интерпретатор Python!
Проверьте сами: выполните strace -e -f execve bash
, а затем запустите python3
. Аргумент -f
важен, потому что мы хотим следовать за всеми форкнутыми дочерними подпроцессами. Вы увидите что-то подобное:
[pid 3708381] execve("/usr/bin/python3", ["python3"], 0x560397748300 /* 21 vars */) = 0
Первый аргумент — это двоичный файл, а второй — это список аргументов командной строки. Аргументы командной строки размещаются в особом месте в памяти программы, чтобы при запуске она могла иметь доступ к ним.
А что же происходит внутри execve
?
▍ 6: Получаем содержимое двоичного файла
Первым делом нам нужно открыть двоичный файл python3
и прочитать его содержимое. Пока мы использовали только системный вызов stat
для доступа к метаданным, но теперь нам нужно его содержимое.
Давайте снова взглянем на результат выполнения stat
:
$ stat /usr/bin/python3
File: /usr/bin/python3 -> python3.9
Size: 9 Blocks: 0 IO Block: 4096 symbolic link
Device: fe01h/65025d Inode: 6206 Links: 1
...
Он занимает 0 блоков места на диске, потому что содержимое символьной ссылки (python3.9
) на самом деле находится в самом inode: мы можем увидеть его здесь (из содержимого двоичного файла показанного выше inode он разделён на две строки в выводе hexdump):
00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.|
00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............|
Вместо этого нам нужно будет открыть /usr/bin/python3.9
. Всё это происходит внутри ядра, поэтому мы не увидим ещё одного системного вызова.
Каждый файл составлен из блоков на жёстком диске. Думаю, каждый из этих блоков в моей системе занимает 4096 байтов, то есть минимальный размер файла составляет 4096 байтов — даже если в файле всего 5 байтов, он всё равно занимает на диске 4 КБ.
Проверьте сами: мы можем найти номера блоков при помощи debugfs
: (я взяла эти команды из поста «Диск — это просто куча битов»).
$ debugfs /dev/vda1
debugfs: blocks /usr/bin/python3.9
145408 145409 145410 145411 145412 145413 145414 145415 145416 145417 145418 145419 145420 145421 145422 145423 145424 145425 145426 145427 145428 145429 145430 145431 145432 145433 145434 145435 145436 145437
Теперь можно воспользоваться dd
, чтобы считать первый блок файла. Мы зададим размер блока 4096 байтов, пропустим 145408
блоков и считаем один блок.
$ dd if=/dev/vda1 bs=4096 skip=145408 count=1 2>/dev/null | hexdump -C | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....|
00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....|
00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |....@.8...@.....|
00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....|
00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......|
00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................|
00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....|
00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............|
Вы видите, что мы получили точно такой же результат, как если бы мы читали файл при помощи cat
:
$ cat /usr/bin/python3.9 | hexdump -C | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....|
00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....|
00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |....@.8...@.....|
00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....|
00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......|
00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................|
00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....|
00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............|
▍ Примечание о волшебных числах
Этот файл начинается с ELF
, то есть «волшебного числа», или последовательности байтов, сообщающей нам, что это файл ELF. Это формат двоичных файлов в Linux.
Разные форматы файлов имеют разные волшебные числа, например, для gzip это 1f8b
. Именно благодаря волшебному числу в начале file blah.gz
понимает, что это файл gzip.
Думаю, file
имеет множество эвристик для определения типа файла, а не только волшебные числа, но волшебные числа — это важная эвристика.
▍ 7: Поиск интерпретатора
Давайте спарсим файл ELF, чтобы понять, что внутри.
Проверьте сами: выполните readelf -a /usr/bin/python3.9
. Вот, что получилось у меня (пришлось многое вырезать):
$ readelf -a /usr/bin/python3.9
ELF Header:
Class: ELF64
Machine: Advanced Micro Devices X86-64
...
-> Entry point address: 0x5ea5c0
...
Program Headers:
Type Offset VirtAddr PhysAddr
INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
0x000000000000001c 0x000000000000001c R 0x1
-> [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
-> 1238: 00000000005ea5c0 43 FUNC GLOBAL DEFAULT 13 _start
Вот, что я поняла из происходящего здесь:
- Команда приказывает ядру выполнить
/lib64/ld-linux-x86-64.so.2
, чтобы запустить эту программу. Это называется динамическим компоновщиком (dynamic linker), о нём мы поговорим ниже. - Она указывает точку входа (
0x5ea5c0
, где начинается код программы).
Теперь давайте поговорим о динамическом компоновщике.
▍ 8: Динамическая компоновка
Отлично, мы считали байты с диска и запустили эту штуку под названием «интерпретатор». Что дальше? Если выполнить strace -o out.strace python3
, то сразу после системного вызова execve
можно увидеть кучу такой информации:
execve("/usr/bin/python3", ["python3"], 0x560af13472f0 /* 21 vars */) = 0
brk(NULL) = 0xfcc000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=32091, ...}) = 0
mmap(NULL, 32091, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f718a1e3000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "177ELF2113>1 l"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=149520, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f718a1e1000
...
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
Поначалу всё это выглядит пугающе, но нам стоит обратить внимание на openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0"
. Здесь открывается потоковая библиотека C под названием pthread
, которая требуется для исполнения интерпретатора Python.
Проверьте сами: если вы хотите знать, какие библиотеки двоичному файлу нужно загружать во время исполнения, то можете воспользоваться ldd
. Вот как это выглядело в моём случае:
$ ldd /usr/bin/python3.9
linux-vdso.so.1 (0x00007ffc2aad7000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2fd6554000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2fd654e000)
libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)
Мы видим, что первая библиотека в списке — это /lib/x86_64-linux-gnu/libpthread.so.0
, поэтому она и загружается первой.
▍ Про LD_LIBRARY_PATH
Честно говоря, я всё ещё не до конца разобралась с динамической компоновкой. Вот некоторые из известных мне фактов:
- Динамическая компоновка происходит в пользовательском пространстве, а динамический компоновщик в моей системе находится по пути
/lib64/ld-linux-x86-64.so.2
. Если у вас нет динамического компоновщика, то вы можете столкнуться со странными багами, например, с этой странной ошибкой «file not found». - Для поиска библиотек динамический компоновщик использует переменную среды
LD_LIBRARY_PATH
. - Также динамический компоновщик использует переменную среды
LD_PRELOAD
для переопределения любой нужной вам динамически компонуемой функции (можно использовать это для забавных хаков или для замены стандартного распределителя памяти на альтернативный, например, jemalloc). - В выводе strace есть несколько
mprotect
, которые для безопасности помечают код библиотеки как только для чтения. - На Mac вместо
LD_LIBRARY_PATH
используетсяDYLD_LIBRARY_PATH
.
Возможно, у вас возник вопрос: если динамическая компоновка происходит в пользовательском пространстве, почему мы не видим кучу системных вызовов stat
при поиске библиотек в LD_LIBRARY_PATH
, как это было, когда bash искал в переменной PATH
?
Причина в том, что у ld
есть кэш в /etc/ld.so.cache
, и все эти библиотеки уже были найдены ранее. Открытие кэша мы видим в выводе strace — openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
.
В полном выводе strace всё равно есть много системных вызовов после динамической компоновки, которые я пока не совсем понимаю. Что делает prlimit64
? Откуда берётся всё, связанное с локалью? Что такое gconv-modules.cache
? Что делает rt_sigaction
? Что такое arch_prctl
? Что такое set_tid_address
и set_robust_list
? Но мне кажется, начало неплохое.
▍ Примечание: на самом деле, ldd — это скрипт оболочки!
Пользователь mastodon сообщил, что ldd
— это скрипт оболочки, который просто задаёт переменную среды LD_TRACE_LOADED_OBJECTS=1
и запускает программу. То есть мы можем сделать то же самое следующим образом:
$ LD_TRACE_LOADED_OBJECTS=1 python3
linux-vdso.so.1 (0x00007ffe13b0a000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f01a5a47000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f01a5a41000)
libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)
Очевидно, ld
— это тоже двоичный файл, который можно просто исполнить, поэтому /lib64/ld-linux-x86-64.so.2 --list /usr/bin/python3.9
делает то же самое.
▍ Про init и fini
Давайте поговорим об этой строке в выводе strace
:
set_tid_address(0x7f58880dca10) = 3709103
Похоже, это как-то связано с потоками, и я думала, что это может происходить, потому что библиотека pthread
(и каждая другая динамически загружаемая) должна при загрузке выполнить код инициализации. Код, выполняемый при загрузке библиотеки, находится в разделе init
(а может, также в разделе .ctors
).
Проверьте сами: давайте взглянем на то, что использует readelf:
$ readelf -a /lib/x86_64-linux-gnu/libpthread.so.0
...
[10] .rela.plt RELA 00000000000051f0 000051f0
00000000000007f8 0000000000000018 AI 4 26 8
[11] .init PROGBITS 0000000000006000 00006000
000000000000000e 0000000000000000 AX 0 0 4
[12] .plt PROGBITS 0000000000006010 00006010
0000000000000560 0000000000000010 AX 0 0 16
...
У этой библиотеки нет раздела .ctors
, только .init
. Но что находится в разделе .init
? Мы можем воспользоваться objdump
для дизассемблирования кода:
$ objdump -d /lib/x86_64-linux-gnu/libpthread.so.0
Disassembly of section .init:
0000000000006000 <_init>:
6000: 48 83 ec 08 sub $0x8,%rsp
6004: e8 57 08 00 00 callq 6860 <__pthread_initialize_minimal>
6009: 48 83 c4 08 add $0x8,%rsp
600d: c3
То есть он вызывает __pthread_initialize_minimal
. Я нашла код этой функции в glibc, хоть мне и пришлось искать более старую версию glibc, потому что похоже, что в более новых версиях libpthread больше не является отдельной библиотекой.
Не уверена, действительно ли этот системный вызов set_tid_address
поступает от __pthread_initialize_minimal
, но, по крайней мере, мы узнали, что библиотеки могут выполнять код при запуске с помощью раздела .init
.
Вот заметка из man elf
о разделе .init
:
$ man elf
.init Этот раздел содержит исполняемые команды, участвующие в коде инициализации процесса. Когда программа начинает исполняться, система начинает исполнять код в этом разделе, прежде чем вызывать основную точку входа программы.
Также в файле ELF есть раздел .fini
, который выполняется в конце, а ещё могут существовать разделы .ctors
/ .dtors
(конструкторы и деструкторы).
Ну ладно, достаточно о динамической компоновке.
▍ 9: Переходим к _start
После завершения динамической компоновки мы переходим к _start
в интерпретаторе Python. Затем он выполняет все обычные действия интерпретатора Python.
Я не буду говорить об этом, потому что нас интересуют общие свойства исполнения двоичных файлов в Linux, а не конкретно интерпретатор Python.
▍ 10: Запись строки
Но нам всё-таки нужно вывести «hello world». Внутри функция print
Python вызывает некую функцию из libc. Но какую? Давайте выясним!
Проверьте сами: выполните ltrace -o out python3 hello.py
.
$ ltrace -o out python3 hello.py
$ grep hello out
write(1, "hello worldn", 12) = 12
Похоже, она вызывает write
Честно говоря, я всегда отношусь с небольшим подозрением к ltrace, в отличие от strace (которому бы я доверила свою жизнь) — я никогда полностью не уверена, что ltrace точно сообщает о вызовах библиотек. Но в данном случае он, похоже, сработал. А если посмотреть на исходный код cpython, то видно, что он действительно в некоторых случаях вызывает write()
. Так что давайте поверим в это.
▍ Что такое libc?
Мы только что сказали, что Python вызывает функцию write
из libc. Но что такое libc? Это стандартная библиотека C, и она отвечает за множество базовых действий, например:
- распределение памяти при помощи
malloc
; - ввод-вывод файлов (открытие/закрытие);
- исполнение программ (как мы говорили ранее, с помощью
execvp
); - поиск записей DNS при помощи
getaddrinfo
; - управление потоками при помощи
pthread
.
Программы не обязаны использовать libc (известно, что Linux язык Go не использует её и напрямую делает системные вызовы Linux), но большинство моих рабочих языков программирования её используют (node, Python, Ruby, Rust). Я не уверена насчёт Java.
Можно узнать, используете ли вы libc, выполнив для своего двоичного файла ldd
: если вы увидите что-то типа libc.so.6
, то это libc.
▍ Почему важна libc?
Возможно, вы задаётесь вопросом, почему важно, что Python вызывает write
библиотеки libc, а затем libc делает системный вызов write
? Почему я подчёркиваю, что посередине используется libc
?
Думаю, что в этом случае это не очень важно (если не ошибаюсь, функция write
libc достаточно напрямую отображается в системный вызов write
).
Однако есть различные реализации libc, и иногда они ведут себя по-разному. Две основные — это glibc (GNU libc) и musl libc.
Например, до недавнего времени getaddrinfo
musl не поддерживала TCP DNS. Вот пост, в котором рассказывается о вызываемом этим баге: https://christoph.luppri.ch/fixing-dns-resolution-for-ruby-on-alpine-linux.
▍ Небольшое отступление о stdout и терминалах
В этой программе stdout (дескриптор файла 1
) является терминалом. А с терминалами можно творить любопытные вещи! Вот один пример:
- В терминале выполните
ls -l /proc/self/fd/1
. Я получаю/dev/pts/2
. - В другом окне терминала напишите
echo hello > /dev/pts/2
. - Вернитесь в исходное окно терминала. Там должно появиться
hello
!
▍ Вот пока и всё!
Надеюсь, вы начали лучше понимать, как выводится hello world
! Пока я не буду добавлять новые подробности, потому что пост и так оказался довольно длинным, но очевидно, что можно сказать ещё многое. В особенности мне бы хотелось услышать о других инструментах, которые можно использовать для изучения частей описанного мной процесса.
Ещё несколько аспектов, которые бы мне хотелось добавить, если бы я научилась за ними шпионить:
- Загрузчик ядра и ASLR (пока я не поняла, как использовать bpftrace + kprobes для трассировки действий загрузчика ядра).
- TTY (пока не разобралась, как трассировать способ отправки
write(1, "hello world", 11)
на TTY, на который я смотрю).
Мне хотелось бы увидеть статью на эту тему про Mac
В Mac OS меня расстраивает то, что я не знаю, как изучать систему на этом уровне — когда я вывожу hello world
, то не могу шпионить за тем, что происходит внутри, как это возможно в Linux. Мне бы хотелось увидеть статью с глубоким объяснением.
Некоторые известные мне эквиваленты для Mac:
ldd
->otool -L
readelf
->otool
- Предположительно, на Mac вместо strace можно использовать
dtruss
илиdtrace
, но я так и не набралась смелости, чтобы отключить защиту целостности системы, чтобы это заработало. - Похоже,
strace
->sc_usage
способен собирать статистику об использовании системных вызовов, аfs_usage
— об использовании файлов.
Дополнительное чтение
Ещё немного ссылок:
- A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux.
- Исследование «hello world» в FreeBSD.
- Hello world под микроскопом для Windows.
- Статья с LWN «Как запускаются программы» (и её вторая часть) содержит гораздо больше подробностей о внутреннем устройстве
execve
. - Как запустить программу.
- «Hello, world» с нуля на 6502 (видео Бена Итера).
Автор:
ru_vds