habrahabr

Анализ сишного Hello World

  • четверг, 20 июня 2019 г. в 00:19:13
https://habr.com/ru/post/438044/
  • *nix
  • C
  • Разработка под Linux


Hello World — одна из первых программ, которые мы пишем на любом языке программирования.

Для 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

Звучит как небольшой перебор для настолько простой операции. Хорошо хоть, что это увидят только те, кто этого действительно захочет.