Для 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:13MacIn
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/2tmin10
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
Проблема не в том, что мы порождаем абстракции, а в том, что мы порождаем абстракции для решения проблем, уже решённых существующими абстракциями! Вот это — настоящий перпетум мобиль, с которым нужно-таки бороться.