Доколко елементарна е програмката Hello World, написана на С

Материалът е на Kjetil Østerås, известен програмист, който от време на време публикува в своя блог интересни материали и анализи

17
2489
Hello World

Hello World е една от първите програми, които начинаещите програмисти пишат на който и да било език за програмиране.

При С програмката 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_puts е нашето текущо звено във веригата извиквания на програмката hello world. Намираме нейното определяне и се вижда, че това е макрос, определен в libio/libioP.h, който извиква друг макрос, който отново… Дървото макроси изглежда по следния начин:

 #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_, а необходимата ни функция се нарича __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 таблицата, директно свързана със 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, която се отнася за конкретния файл. В Linux това често се реализира като код в драйвера и сега трябва да си изясним какъв драйвер се извиква в нашия случай.

В своите експерименти използвам 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.

Драйверът за псевдо терминала се намира в Linux ядрото във файла 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

Не е ли малко прекалено за една толкова елементарна операция? Добре е все пак, че всичко това ще видят само хората, на които това е необходимо и наистина искат да вникнат в нещата.

0 0 глас
Оценете статията
Абонирай се
Извести ме за
guest
17 Коментара
стари
нови оценка
Отзиви
Всички коментари