void main() {
printf("Hello World!n");
}Това е толкова малка и къса програма, че би трябвало да е елементарно да се обясни какво се случва „под капака“
.td_uid_41_5d0b4b20b8514_rand.td-a-rec-img{text-align:left}.td_uid_41_5d0b4b20b8514_rand.td-a-rec-img img{margin:0 auto 0 0} Да погледнем какво се случва след като програмата мине през компилатора и линкера:
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Не е ли малко прекалено за една толкова елементарна операция? Добре е все пак, че всичко това ще видят само хората, на които това е необходимо и наистина искат да вникнат в нещата.