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

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

16
2361
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

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

16
ДОБАВИ КОМЕНТАР

avatar
12 Коментари
4 Отговори на коментарите
0 Последователи
 
Коментарът с най-много реакции
Най-горещият коментар
  Абонирай се  
нови стари оценка
Извести ме за
жик-так
жик-так

мисля си… че си имал бая свободно време… 😀

Георги
Георги

Нещата всъщност са доста по-сложни, като се започне от там, че GCC е оптимизирал printf(), извикан с прост стринг (без параметри), в извикване на puts().

Функцията printf() би трябвало да осъществи обработка (parsing) и форматиране на параметри, зададени със знака % (за форматиране) и самите параметри, разделени със запетайки след форматиращият стринг. Спецификациите и вариантите на форматиране са доста сложни и кода на printf() е доста голям – в някои варианти достига 25 KB.

GCC обаче е видял, че подаденият стринг е обикновен и няма форматиране и параметри, и е заменил извикването на printf() с puts().

Справка за printf(): https://en.cppreference.com/w/cpp/io/c/fprintf

star_dodo
star_dodo

Какво ще го оптимизираме, хардуера ще навакса.
И така 40 години.
На Луната с калкулатор, за фейса и тубата – поне 4-риядре и 8 GB RAM (дори са малко).

Калин
Калин

Ненужно упражнение.

Калин
Калин

Никой програмист/кодер няма да нарече дори Hello World програмКА.
Поправете го на програма.

Colombino
Colombino

Нещо ми се губи бакслаша преди последния ‘n’ в printf-a.

kolon
kolon

C езика е труден за изучаване, обаче който е майстор – блазе му!

kolon
kolon

на python програмката би изглеждала доста по-просто:
print(‘hello, world!’)

обаче там какво ли ще е ‘под капака’?

sw.hw
sw.hw

Пък да не говорим за сложните програми през колко много неща минават излишно. Вместо да подобряваме софтуера(има голяма нужда, според моите набюдения), то ние подобряваме хардуера.

Калин
Калин

Ако сега стартираш проект, който трябва да предадеш след 24 месеца, няма смисъл да правиш софтуер оптимизация. Според закона на Моор след 24 месеца ще имаш 2 пъти повече изчислителна мощност.
Или както казваше един мой професор на времето – задачата не е кой ще напише най-красивия код. А кой ще изпълни задачата за поставеното време на договорената цена.
За романтика място няма.

gotqn
gotqn

Като пишеш калпав код и на някой супер компютър да го пуснеш, пак ще е бавен. Твоя професор е учител в университет, защото няма знания да прави софтуер за бизнес.

иБаси
иБаси

На BASIC е още по-елементарно:

10 PRINT „Hello World!“