[Перевод] Что происходит, когда запускаешь «Hello World» в Linux

sqv358sbar5osudzalgqz4ax2ae.png


Сегодня я задумалась о том, что происходит, когда запускаешь в 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? Когда операционная система открывает файл, этот процесс разбивается на два этапа.

  1. Она сопоставляет имя файла с inode, который содержит метаданные о файле.
  2. Она использует 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, смещение 0×0d00». Каждый блок — это 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) наследовал у оболочки многое. Например, он унаследовал:

  1. переменные среды: их можно просмотреть при помощи cat /proc/PID/environ | tr '\0' '\n';
  2. дескрипторы файлов для stdout и stderr: их можно просмотреть при помощи ls -l /proc/PID/fd;
  3. рабочую папку (которая является текущей);
  4. пространства имён и cgroups (если он находится в контейнере);
  5. пользователя и группу, которые его запустили;
  6. вероятно, что-то ещё, чего я не могу вспомнить.


▍ 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


Вот, что я поняла из происходящего здесь:

  1. Команда приказывает ядру выполнить /lib64/ld-linux-x86-64.so.2, чтобы запустить эту программу. Это называется динамическим компоновщиком (dynamic linker), о нём мы поговорим ниже.
  2. Она указывает точку входа (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, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 l\0\0\0\0\0\0"..., 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 world\n", 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) является терминалом. А с терминалами можно творить любопытные вещи! Вот один пример:

  1. В терминале выполните ls -l /proc/self/fd/1. Я получаю /dev/pts/2.
  2. В другом окне терминала напишите echo hello > /dev/pts/2.
  3. Вернитесь в исходное окно терминала. Там должно появиться hello!


▍ Вот пока и всё!


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

Ещё несколько аспектов, которые бы мне хотелось добавить, если бы я научилась за ними шпионить:

  • Загрузчик ядра и ASLR (пока я не поняла, как использовать bpftrace + kprobes для трассировки действий загрузчика ядра).
  • TTY (пока не разобралась, как трассировать способ отправки write(1, "hello world", 11) на TTY, на который я смотрю).


Мне хотелось бы увидеть статью на эту тему про Mac


В Mac OS меня расстраивает то, что я не знаю, как изучать систему на этом уровне — когда я вывожу hello world, то не могу шпионить за тем, что происходит внутри, как это возможно в Linux. Мне бы хотелось увидеть статью с глубоким объяснением.

Некоторые известные мне эквиваленты для Mac:

  • lddotool -L
  • readelfotool
  • Предположительно, на Mac вместо strace можно использовать dtruss или dtrace, но я так и не набралась смелости, чтобы отключить защиту целостности системы, чтобы это заработало.
  • Похоже, stracesc_usage способен собирать статистику об использовании системных вызовов, а fs_usage — об использовании файлов.


Дополнительное чтение


Ещё немного ссылок:

Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх

© Habrahabr.ru