Для C hello world выглядит просто и коротко:
#include <stdio.h>
void main() {
printf("Hello World!\n");
}Поскольку программа такая короткая, должно быть элементарно объяснить, что происходит «под капотом».
Во-первых, посмотрим, что происходит при компиляции и линковке:
gcc --save-temps hello.c -o hello--save-temps добавлено, чтобы gcc оставил hello.s, файл с ассемблерным кодом.Вот примерный ассемблерный код, который я получил:
.file "hello.c"
.section .rodata
.LC0:
.string "Hello World!"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
movl $.LC0, %edi
call puts
popq %rbp
retИз ассемблерного листинга видно, что вызывается не
printf, а puts. Функция puts также определена в файле stdio.h и занимается тем, что печатает строку и перенос строки. Хорошо, мы поняли, какую функцию на самом деле вызывает наш код. Но где
puts реализована?Чтобы определить, какая библиотека реализует
puts, используем ldd, выводящий зависимости от библиотек, и nm, выводящую символы объектного файла.$ ldd hello
libc.so.6 => /lib64/libc.so.6 (0x0000003e4da00000)
$ nm /lib64/libc.so.6 | grep " puts"
0000003e4da6dd50 W putsФункция находится в сишной библиотеке, называемой
libc, и расположенной в /lib64/libc.so.6 на моей системе (Fedora 19). В моём случае, /lib64 — симлинк на /usr/lib64, а /usr/lib64/libc.so.6 — симлинк на /usr/lib64/libc-2.17.so. Этот файл и содержит все функции. Узнаем версию
libc, запустив файл на выполнение, как будто он исполнимый:$ /usr/lib64/libc-2.17.so
GNU C Library (GNU libc) stable release version 2.17, by Roland McGrath et al.
...В итоге, наша программа вызывает функцию
puts из glibc версии 2.17. Давайте теперь посмотрим, что делает функция puts из glibc-2.17.В коде glibc достаточно сложно ориентироваться из-за повсеместного использования макросов препроцессора и скриптов. Заглянув в код, видим следующее в
libio/ioputs.c:weak_alias (_IO_puts, puts)На языке glibc это означает, что при вызове
puts на самом деле вызывается _IO_puts. Эта функция описана в том же файле, и основная часть функции выглядит так:int _IO_puts (str)
const char *str;
{
//...
_IO_sputn (_IO_stdout, str, len)
//...
}Я выкинул весь мусор вокруг важного нам вызова. Теперь
_IO_sputn — наше текущее звено в цепочке вызовов hello world. Находим определение, это имя — макрос, определённый в libio/libioP.h, который вызывает другой макрос, который снова… Дерево макросов содержит следующee: #define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)
//...
#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
//...
#define JUMP2(FUNC, THIS, X1, X2) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1, X2)
//...
# define _IO_JUMPS_FUNC(THIS) (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS ((struct _IO_FILE_plus *) (THIS)) + (THIS)->_vtable_offset))
//...
#define _IO_JUMPS(THIS) (THIS)->vtableЧто за хрень тут происходит? Давайте развернём все макросы, чтобы посмотреть на финальный код:
((*(struct _IO_jump_t **) ((void *) &((struct _IO_FILE_plus *) (((_IO_FILE*)(&_IO_2_1_stdout_)) ) )->vtable+(((_IO_FILE*)(&_IO_2_1_stdout_)) )->_vtable_offset))->__xsputn ) (((_IO_FILE*)(&_IO_2_1_stdout_)), str, len)
Глаза болеть. Давайте я просто объясню, что тут происходит? Glibc использует jump-table для вызова функций. В нашем случае таблица лежит в структуре, называемой
_IO_2_1_stdout_, a нужная нам функция называется __xsputn. Структура объявлена в файле
libio/libio.h:extern struct _IO_FILE_plus _IO_2_1_stdout_;А в файле
libio/libioP.h лежат определения структуры, таблицы, и её поля:struct _IO_FILE_plus
{
_IO_FILE file;
const struct _IO_jump_t *vtable;
};
//...
struct _IO_jump_t
{
//...
JUMP_FIELD(_IO_xsputn_t, __xsputn);
//...
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
//...
};Если копнуть ещё глубже, увидим, что таблица
_IO_2_1_stdout_ инициализируется в файле libio/stdfiles.c, а сами реализации функций таблицы определяются в libio/fileops.c:/* from libio/stdfiles.c */
DEF_STDFILE(_IO_2_1_stdout_, 1, &_IO_2_1_stdin_, _IO_NO_READS);
/* from libio/fileops.c */
# define _IO_new_file_xsputn _IO_file_xsputn
//...
const struct _IO_jump_t _IO_file_jumps =
{
//...
JUMP_INIT(xsputn, _IO_file_xsputn),
//...
JUMP_INIT(read, _IO_file_read),
JUMP_INIT(write, _IO_new_file_write),
JUMP_INIT(seek, _IO_file_seek),
JUMP_INIT(close, _IO_file_close),
JUMP_INIT(stat, _IO_file_stat),
//...
};Всё это означает, что если мы используем jump-table, связанную с
stdout, мы в итоге вызовем функцию _IO_new_file_xsputn. Уже ближе, не так ли? Эта функция перекидывает данные в буфера и вызывает new_do_write, когда можно выводить содержимое буфера. Так выглядит new_do_write:static _IO_size_t new_do_write (fp, data, to_do)
_IO_FILE *fp;
const char *data;
_IO_size_t to_do;
{
_IO_size_t count;
..
count = _IO_SYSWRITE (fp, data, to_do);
..
return count;
}Разумеется, вызывается макрос. Через тот же jump-table механизм, что мы видели для
__xsputn, вызывается __write. Для файлов __write маппится на _IO_new_file_write. Эта функция в итоге и вызывается. Посмотрим на неё?_IO_ssize_t _IO_new_file_write (f, data, n)
_IO_FILE *f;
const void *data;
_IO_ssize_t n;
{
_IO_ssize_t to_do = n;
_IO_ssize_t count = 0;
while (to_do > 0)
{
// ..
write (f->_fileno, data, to_do));
// ..
}Наконец-то функция, которая вызывает что-то, не начинающееся с подчёркивания! Функция
write известная и определена в unistd.h. Это — вполне стандартный способ записи байтов в файл по файловому дескриптору. Функция write определена в самом glibc, так что мы должны найти код.Я нашёл код
write в sysdeps/unix/syscalls.list. Большинство системных вызовов, обёрнутых в glibc, генерируются из таких файлов. Файл содержит имя функции и аргументы, которые она принимает. Тело функции создаётся из общего шаблона системных вызовов.# File name Caller Syscall name Args Strong name Weak names
...
write - write Ci:ibn __libc_write __write write
...Когда glibc код вызывает
write (либо __libcwrite, либо __write), происходит syscall в ядро. Код ядра гораздо читабельнее glibc. Точка входа в syscall write находится в fs/readwrite.c:SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
struct fd f = fdget(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos = file_pos_read(f.file);
ret = vfs_write(f.file, buf, count, &pos);
if (ret >= 0)
file_pos_write(f.file, pos);
fdput(f);
}
return ret;
}Сначала находится структура, соответствующая файловому дескриптору, затем вызывается функция
vfs_write из подсистемы виртуальной файловой системы (vfs). Структура в нашем случае будет соответствовать файлу stdout. Посмотрим на vfs_write:ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
//...
ret = file->f_op->write(file, buf, count, pos);
//...
return ret;
}Функция делегирует выполнение функции
write, принадлежащей конкретному файлу. В линуксе это часто реализовано в коде драйвере, так что надо бы выяснить, какой драйвер вызовется в нашем случае.Я использую для экспериментов Fedora 19 с Gnome 3. Это, в частности, означает, что мой терминал по умолчанию —
gnome-terminal. Запустим этот терминал и сделаем следующее:~$ tty
/dev/pts/0
~$ ls -l /proc/self/fd
total 0
lrwx------ 1 kos kos 64 okt. 15 06:37 0 -> /dev/pts/0
lrwx------ 1 kos kos 64 okt. 15 06:37 1 -> /dev/pts/0
lrwx------ 1 kos kos 64 okt. 15 06:37 2 -> /dev/pts/0
~$ ls -la /dev/pts
total 0
drwxr-xr-x 2 root root 0 okt. 10 10:14 .
drwxr-xr-x 21 root root 3580 okt. 15 06:21 ..
crw--w---- 1 kos tty 136, 0 okt. 15 06:43 0
c--------- 1 root root 5, 2 okt. 10 10:14 ptmxКоманда
tty выводит имя файла, привязанного к стандартному вводу, и, как видно из списка файлов в /proc, тот же файл связан с выводом и потоком ошибок. Эти файлы устройств в /dev/pts называются псевдотерминалами, точнее говоря, это slave псевдотерминалы. Когда процесс пишет в slave псевдотерминал, данные попадают в master псевдотерминал. Master псевдотерминал — это девайс /dev/ptmx.Драйвер для псевдотерминала находится в ядре линукса в файле
drivers/tty/pty.c:static void __init unix98_pty_init(void)
{
//...
pts_driver->driver_name = "pty_slave";
pts_driver->name = "pts";
pts_driver->major = UNIX98_PTY_SLAVE_MAJOR;
pts_driver->minor_start = 0;
pts_driver->type = TTY_DRIVER_TYPE_PTY;
pts_driver->subtype = PTY_TYPE_SLAVE;
//...
tty_set_operations(pts_driver, &pty_unix98_ops);
//...
/* Now create the /dev/ptmx special device */
tty_default_fops(&ptmx_fops);
ptmx_fops.open = ptmx_open;
cdev_init(&ptmx_cdev, &ptmx_fops);
//...
}
static const struct tty_operations pty_unix98_ops = {
//...
.open = pty_open,
.close = pty_close,
.write = pty_write,
//...
};При записи в
pts вызывается pty_write, которая выглядит так:static int pty_write(struct tty_struct *tty, const unsigned char *buf, int c)
{
struct tty_struct *to = tty->link;
if (tty->stopped)
return 0;
if (c > 0) {
/* Stuff the data into the input queue of the other end */
c = tty_insert_flip_string(to->port, buf, c);
/* And shovel */
if (c) {
tty_flip_buffer_push(to->port);
tty_wakeup(tty);
}
}
return c;
}Комментарии помогают понять, что данные попадают во входную очередь master псевдотерминала. Но кто читает из этой очереди?
~$ lsof | grep ptmx
gnome-ter 13177 kos 11u CHR 5,2 0t0 1133 /dev/ptmx
gdbus 13177 13178 kos 11u CHR 5,2 0t0 1133 /dev/ptmx
dconf 13177 13179 kos 11u CHR 5,2 0t0 1133 /dev/ptmx
gmain 13177 13182 kos 11u CHR 5,2 0t0 1133 /dev/ptmx
~$ ps 13177
PID TTY STAT TIME COMMAND
13177 ? Sl 0:04 /usr/libexec/gnome-terminal-serverПроцесс
gnome-terminal-server порождает все gnome-terminal'ы и создаёт новые псевдотерминалы. Именно он слушает master псевдотерминал и, в итоге, получит наши данные, которые "Hello World". Сервер gnome-terminal получает строку и отображает её на экране. Вообще, на подробный анализ gnome-terminal времени не хватило :)Заключение
Общий путь нашей строки «Hello World»:
0. hello: printf("Hello World")
1. glibc: puts()
2. glibc: _IO_puts()
3. glibc: _IO_new_file_xsputn()
4. glibc: new_do_write()
5. glibc: _IO_new_file_write()
6. glibc: syscall write
7. kernel: vfs_write()
8. kernel: pty_write()
9. gnome_terminal: read()
10. gnome_terminal: show to userЗвучит как небольшой перебор для настолько простой операции. Хорошо хоть, что это увидят только те, кто этого действительно захочет.
Комментарии (136)

MagisterLudi
18.06.2019 00:34Я всегда подозревал, что-то не то с этими сями…
Поэтому:


Dmitri-D
18.06.2019 03:27+2Собственно, «C» закончился вызовом библиотечной функции.
Бейсик, думаете сможет сделать что-то лучше?
dedalqq
18.06.2019 21:07+1Вообще, если говорить о урощении, то можно же напрямую дернуть системный вызов write()
void main() { write(1, "Hello World!\n", 13); }
В этом случае мы сразу сразу пропускаем пункты с 0 по 6 из заключения статьи.
P.S.
Правда в этом случае линтер ругаться будет если мы не заинклудим unistd.h
Но в этом случае достаточно просто определить этот write.
В итоге это:
int write(int fildes, const void *buf, int nbytes); void main() { write(1, "Hello World!\n", 13); }
И компилится и работает без каких либо проблем.
alexeyknyshev
18.06.2019 23:09Компилируются и сегфолтился )
int main;
Просто забавный оффтоп
MaxVetrov
18.06.2019 23:51(gdb) disassemble
Dump of assembler code for function main:
=> 0x0000555555755014 <+0>: add %al,(%rax)
0x0000555555755016 <+2>: add %al,(%rax)
End of assembler dump.
(gdb) i r rax
rax 0x555555755014 93824994332692
тоже компилится.double main;
А на
long long long main;
ругается
aaa.c:1:11: error: ‘long long long’ is too long for GCC

staticlab
18.06.2019 09:35Зачем же номера строк до сих пор писать?

IronHead
18.06.2019 09:38Чтобы можно было написать GOTO 10

MaxVetrov
18.06.2019 11:52Или GOSUB 10 :)

staticlab
18.06.2019 16:18Для GOSUB интерпретатор QB64 позволяет использовать текстовые метки.

MaxVetrov
18.06.2019 16:36Ну, если текстовые метки есть, то и для GOTO они тоже подойдут и номера строк не нужны.

MacIn
18.06.2019 20:09А зачем писать GOTO 10, если можно написать
DO .. LOOP WHILE TRUE
или
DO WHILE TRUE .. LOOP
Dmitri-D
Ну, если так же развернуть для какого-нибудь Quick Basic, или старого MSCC под DOS, то там будет простое ah=9h, int21h. В рамках «посмотреть, как под капотом работает HW для новичка» — да, это нагляднее, чем десять оберток над обертками. Не лучше или хуже; нагляднее. Но это так, отвлеченный коммент в сторону — ведь эта статья как раз имеет задачу показать эту самую цепочку, а не просто внутреннее устройство.
MaxVetrov
18.06.2019 20:13
MacIn
18.06.2019 21:17В таком случае — да, но просто метка 10, как традиционно метка первой строки, не кажется подходящей для вашей интерпретации. Скорее, изначально имелся в виду бесконечный цикл в классическом 10 print… goto 10.
А для выхода из цикла можно так же текстовую метку использовать.

melodictsk
18.06.2019 21:08Зачем подчекивание в строке 20?
Зачем табуляция перед 30?
Зачем 50 END?
Зачем заставлять писать на бэйские человека для которого это не первый язык программирования?

Dmitri-D
18.06.2019 03:26+1Хорошо, это был файловый дескриптор, ассоциированный с терминалом. А мог быть stdin другого приложения или вообще символьное устройство /dev/null. Т.е. другими словами printf, как и puts — это разновидность межпроцессного взаимодействия. Что если не ядро должно заниматься межпроцессным взаимодействием?
Так что не вижу никаких излишеств.

HEKOT
18.06.2019 06:59Спасибо, я теперь могу совершенно чётко сформулировать, за что я люблю embedded…

osmanpasha
18.06.2019 08:49+3Ну, конкретно для отладочного printf в embedded путь может быть ещё длиннее)

HEKOT
18.06.2019 15:09Если он отладочный, то пофиг. А другого printf в embedded просто нет.Хотя нет, отладочного тоже нет. :)
Недавно на эту тему на linkedin:
— Чем отличается C от Embedded C?
— Нет printf?
— Нет malloc!
alexey_public
18.06.2019 15:43А вот я бы не стал так утверждать, ибо регулярно использую sprintf :-)
Но да я знаю что он за собой тянет, тем не менее писать свой вариант sprintf еще более накладно. А нужно для того же вебсервера регулярно (и не только его, запись в файлы, общение с gsm-модулями и прочим, прочим). Да, можно и иначе сделать, но тогда придется что-то заметно урезать. Пока хватает ресурсов — применение sprintf весьма оправданно. ИМХО конечно. Например в высоконагруженном месте печать float сделал по собственной схеме, что позволило существенно ускорить процесс.
masai
18.06.2019 15:56Я
sprintfпобаиваюсь, предпочитаюsnprintf.
alexey_public
18.06.2019 17:06Вы абсолютно правы. Но практически у меня бывали и исключения, хотя вот вспоминаешь и думаешь — а не вернуться ли и не переделать ли :-)

tmin10
18.06.2019 16:36Хм, надвно наблюдал терминал оплаты сбера с ошибкой malloc, хотя это тоже должен быть embedded…

HEKOT
18.06.2019 17:32В первую очередь, это Сбер :D
Вообще, это не совсем эмбеддед или совсем не ембеддед.
Ещё ~100 лет назад в банкоматах стояла OS/2
tmin10
18.06.2019 17:52+2Не совсем верно написал.
Я про такую железку говорил

talbot
18.06.2019 18:54Так то не Сбер, а Верифон, с его прошивками. Да и там такой себе эмбеддед вроде, это довольно мощная железка уже. Вот например чип банковской карты тоже эмбеддед, в котором вполне себе Java приложение, да ещё и не одно.

khim
18.06.2019 19:11Вот например чип банковской карты тоже эмбеддед, в котором вполне себе Java приложение, да ещё и не одно.
Там такая себе Java, спцфцкая. Без GC и free — отличный подход к выделению памяти! Malloc там, фактически, аналог Sbrk…

mikelavr
18.06.2019 12:32За то, что весь этот путь информации хоть куда нибудь наружу надо проделывать самостоятельно? Даже если устройством показа пользователю является осциллограф на ножке GPIO.

HEKOT
18.06.2019 16:20Это было увлекательное занятие программировать 24-процессоную DSP систему реального времени, в которой к каждому процессору подключено по одному светодиоду. Процессоров было больше, 24 было только у меня. А наши мужики ещё умудрились найти багу в проприетарной ОС!
Не стОит недооценивать осциллограф! Вот например, вывод логотипа альмаматери по следующему методу:
Растровый цифровой логотип загружатеся в память микроконтроллера.
С помощью ЦАП формируется аналоговый сигнал строчной развертки.
Цифровой осциллограф оцифровывает аналоговый сигнал.
Сигнал развёртки преобразуется в растровое изображение на экране.
MechanicZelenyy
И эти люди ругают нас за наворачивание абстракции над абстракцией.
netch80
Так тут всё вполне просто, никаких наворотов.
namikiri
В том и дело, что в системе итак достаточно абстракций, хватит плодить ещё больше.
khim
Проблема не в том, что мы порождаем абстракции, а в том, что мы порождаем абстракции для решения проблем, уже решённых существующими абстракциями! Вот это — настоящий перпетум мобиль, с которым нужно-таки бороться.