Анатомия Hello World на языке C

в 13:00, , рубрики: Без рубрики

Анатомия Hello World на языке C - 1


Эта статья посвящена программе Hello World, написанной на C. Это максимальный уровень, на который можно добраться с языком высокого уровня, не беспокоясь при этом о том, что конкретно язык делает в интерпретаторе/компиляторе/JIT перед выполнением программы.

Изначально я хотел написать статью так, чтобы она была понятна любому, умеющему кодить, но теперь думаю, что читателю полезно иметь хотя бы некоторые знания по C или ассемблеру.

▍ Начало

Всем знакома программа Hello World. На Python самой первой программой обычно бывает такая:

print('Hello World!')

Она просто выводит на экран текст «Hello World!».

В этой статье мы рассмотрим Hello World на языке программирования C. Она имеет следующий вид:

#include <stdio.h>

int main() {
    printf("Hello World!n");
    return 0;
}

Эта программа делает ровно то же самое, что и программа на Python. Но в отличие от Python, здесь нельзя просто вызвать интерпретатор для запуска программы. Необходимо сначала запустить компилятор, чтобы преобразовать этот код в машинный код, который непосредственно сможет выполнять процессор компьютера. Все современные большие и важные программы пишутся таким образом.

Чтобы выполнить преобразование, необходима следующая команда:

gcc hello.c -o hello

Она берёт код на C из файла hello.c и генерирует программу на машинном языке в файле hello. Затем мы можем выполнить её при помощи такой команды:

./hello

И получить результат:

Hello World!

Отлично.

▍ Наша программа

Хорошо, но как мы этого добились? Во-первых, стоит взглянуть на программу. Из чего же она состоит?

$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b74da2c9c77d221eeaa98f87f4a7a529782db280, for GNU/Linux 3.2.0, not stripped

По большей мере эта информация для нас не важна или пока не важна. Самое важное — это:

ELF executable, x86-64

Эта строка сообщает нам, что программа — это исполняемый файл ELF для архитектуры набора команд x86_64. Что это значит?

Исполняемый файл ELF — это эквивалент файла Windows .exe, только для Linux. Это просто программа, которую может выполнять компьютер. Но это мы и так знали. Вторая часть сообщает нам, что это программа в машинном коде, которая должна выполняться в 64-битном процессоре x86, то есть в архитектуре CPU, использующейся в PC с момента появления IBM PC в 1981 году. Стоит отметить, что тогда процессоры не были 64-битными, но современные процессоры по-прежнему могут исполнять код, написанный для IBM PC (в определённой степени). Но мы отклонились от темы.

То есть в этом файле находится машинный код — своего рода язык, и это единственный язык, который может понимать CPU. Где же CPU начинает выполнять его код?

$ readelf -h hello
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1060
  Start of program headers:          64 (bytes into file)
  Start of section headers:          13976 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         31
  Section header string table index: 30

Самое важное здесь — это Entry point address:, имеющее значение 0x1060. Это шестнадцатеричное число, указывающее местоположение в нашей программе или после её загрузки в памяти компьютера. Что же там находится?

▍ Код

$ objdump -D hello

Я не буду приводить здесь полностью результат выполнения этой команды, потому что он слишком длинный. Но если просмотреть его, мы рано или поздно найдём несколько строк текста, в котором первая строка начинается с 1060:

Disassembly of section .text:

0000000000001060 <_start>:
    1060:	f3 0f 1e fa          	endbr64 
    1064:	31 ed                	xor    %ebp,%ebp
    1066:	49 89 d1             	mov    %rdx,%r9
    1069:	5e                   	pop    %rsi
    106a:	48 89 e2             	mov    %rsp,%rdx
    106d:	48 83 e4 f0          	and    $0xfffffffffffffff0,%rsp
    1071:	50                   	push   %rax
    1072:	54                   	push   %rsp
    1073:	45 31 c0             	xor    %r8d,%r8d
    1076:	31 c9                	xor    %ecx,%ecx
    1078:	48 8d 3d ca 00 00 00 	lea    0xca(%rip),%rdi        # 1149 <main>
    107f:	ff 15 53 2f 00 00    	call   *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
    1085:	f4                   	hlt    
    1086:	66 2e 0f 1f 84 00 00 	cs nopw 0x0(%rax,%rax,1)
    108d:	00 00 00

Что это значит? Первые числа перед двоеточиями — это адреса следующих за ними байтов, по сути, их позиция в файле. Следующие числа — это байты данных в файле программы, которые в данном случае обозначают машинный код. Последующий текст — это дизассемблированная версия этого кода. Язык ассемблера — это человекочитаемое представление машинного кода. Стоит отметить, что если байты слева не обозначают код, то дизассемблер всё равно попытается их дизассемблировать. Это приводит к появлению мусора и бессмысленного ассемблерного кода.

Итак, мы нашли некий код! Но это не код, который мы писали. Он автоматически добавлен в нашу программу компилятором (строго говоря, компоновщиком). По сути, этот код проводит инициализацию, а затем выполняет важную команду:

call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>

Эта команда приказывает компьютеру выполнить код где-то в другом месте, в данном случае — по адресу 0x2f53, который меняется на адрес 0x3fd8, когда нашу программу загружает динамический компоновщик. Здесь я не буду вдаваться в подробности.

Но как бы вы ни искали, вы не найдёте ни одного из этих адресов в нашем файле. Строго говоря, 0x3fd8 находится в глобальной таблице смещений (эта тема тоже выходит за рамки статьи), но пока он пуст, поэтому что этот код определяется не в нашей программе, а в другом месте.

▍ Библиотека C

Так где же он?

$ readelf -d hello

Dynamic section at offset 0x2dc8 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x1000
 0x000000000000000d (FINI)               0x1168
 0x0000000000000019 (INIT_ARRAY)         0x3db8
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x3dc0
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x3b0
 0x0000000000000005 (STRTAB)             0x480
 0x0000000000000006 (SYMTAB)             0x3d8
 0x000000000000000a (STRSZ)              141 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x3fb8
 0x0000000000000002 (PLTRELSZ)           24 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x610
 0x0000000000000007 (RELA)               0x550
 0x0000000000000008 (RELASZ)             192 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE
 0x000000006ffffffe (VERNEED)            0x520
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x50e
 0x000000006ffffff9 (RELACOUNT)          3
 0x0000000000000000 (NULL)               0x0

Это, среди прочего, список библиотек, используемых нашим кодом. В данном случае мы видим строку

0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

Это стандартная библиотека C нашей системы — сборник процедур и функций, используемых практически всеми программами на нашем компьютере. В мире Windows аналогом этого является среда исполнения C, msvcrt.dll или ucrt<что-то>.dll. Стоит здесь отметить, что в Linux файлы с расширением .so (Shared Object) эквивалентны файлам в Windows с расширением .dll (Dynamically Linked Library). Они содержат код, который может использовать множество различных программ.

Поэтому мы снова можем применить objdump, чтобы найти местонахождение этого кода в нашей библиотеке C и понять его предназначение; однако библиотека C огромна и сложна, а мы даже ещё не добрались до кода, который написали сами. Так что не буду вас мучить и просто скажу, что этот код выполняет инициализацию, например, получает параметры командной строки и переменные окружения нашей программы, а затем вызывает нашу функцию main(). Далее, когда мы выполняем возврат из main(), он производит выход из нашей программы с указанным нами кодом состояния.

Так где же находится наша функция main?

▍ main()

Разумеется, в нашей программе. Вернувшись к дизассемблированному коду, мы увидим:

0000000000001149 <main>:
    1149:	f3 0f 1e fa          	endbr64 
    114d:	55                   	push   %rbp
    114e:	48 89 e5             	mov    %rsp,%rbp
    1151:	48 8d 05 ac 0e 00 00 	lea    0xeac(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    1158:	48 89 c7             	mov    %rax,%rdi
    115b:	e8 f0 fe ff ff       	call   1050 <puts@plt>
    1160:	b8 00 00 00 00       	mov    $0x0,%eax
    1165:	5d                   	pop    %rbp
    1166:	c3                   	ret

Наконец-то, наш код! Но что же он делает? Он:

  1. Подготавливает фрейм стека.
  2. Настраивает аргументы для вызова нашей функции.
  3. Вызывает Hello World.
  4. Очищает фрейм стека.
  5. Выполняет возврат из функции с кодом выхода 0.

Это то, что мы видим в исходном коде. Но что такое фрейм стека? Это часть памяти компьютера, которую наша программа использует для хранения локальных переменных, то есть переменных, объявленных внутри нашей функции main. К счастью, мы не объявляем никаких переменных, поэтому беспокоиться об этом нам не нужно. Самое важное здесь:

lea    0xeac(%rip),%rax

call   1050 <puts@plt>

Эти команды:

  1. Задают адрес памяти нашей строки Hello World в качестве первого аргумента вызова нашей функции (косвенно).
  2. Вызывают функцию puts().

Постойте, а что такое puts()? Разве мы вызывали не printf()?

Да. Однако компилятор выполнил оптимизацию. Функция printf сложна, потому что она может выводить «форматированный вывод», то есть мы можем помещать в вывод переменные. Функция выполняет преобразование их в строки и вывод этих строк, но ничего этого нам не нужно. Поэтому компилятор заменяет printf() гораздо более простой функцией puts(), которая просто выводит строку неотформатированного текста. А где же наш текст?

▍ Строка

Согласно дизассемблеру, она находится по адресу 0x0eac, который при загрузке преобразуется в адрес 0x2004. Как это выглядит?

Disassembly of section .rodata:

0000000000002000 <_IO_stdin_used>:
    2000:	01 00                	add    %eax,(%rax)
    2002:	02 00                	add    (%rax),%al
    2004:	48                   	rex.W
    2005:	65 6c                	gs insb (%dx),%es:(%rdi)
    2007:	6c                   	insb   (%dx),%es:(%rdi)
    2008:	6f                   	outsl  %ds:(%rsi),(%dx)
    2009:	20 57 6f             	and    %dl,0x6f(%rdi)
    200c:	72 6c                	jb     207a <__GNU_EH_FRAME_HDR+0x66>
    200e:	64 21 00             	and    %eax,%fs:(%rax)

Помните, выше я говорил, что дизассемблер пытается дизассемблировать код, даже если это не код? Вот хороший пример этого. Можете не обращать внимания на ассемблерный код, это полная бессмыслица. Но если посмотреть на адрес 0x2004, мы увидим шестнадцатеричные байты 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00, преобразуемые в строку «Hello World!», за которыми идёт завершающий нулевой символ.

Но разве в нашей строке нет символа переноса строки n, который должен преобразоваться в ASCII-код 0x0a? Да, но это ещё один артефакт оптимизации компилятора. Функция puts() выводит строку с конечным символом переноса строки, а printf() этого не делает. Поэтому компилятор удаляет наш перенос строки, и в выводе остаётся только один.

Далее мы видим нулевой байт 0x00. Он называется завершающим нулевым символом (NULL terminator) и встречается в конце всех строк C. В языке C у строк нет никакой информации о длине, так что функция, получающая в качестве аргумента строку любой длины, обрабатывает её по байту за раз, пока не встретит NULL terminator. Если бы в памяти было несколько строк без NULL terminator между ними, то функции C обрабатывали бы все строки вместе. Постепенно функции дошли бы до конца и начали считывать память, которую им не разрешено считывать, а программа вылетела бы с пугающим сообщением «Segmentation Fault».

▍ Отслеживаем puts()

Итак, puts() расположена в 0x1050.

Disassembly of section .plt.sec:

0000000000001050 <puts@plt>:
    1050:	f3 0f 1e fa          	endbr64 
    1054:	f2 ff 25 75 2f 00 00 	bnd jmp *0x2f75(%rip)        # 3fd0 <puts@GLIBC_2.2.5>
    105b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

Теперь она выполняет обратный вызов в стандартной библиотеке (строго говоря, в глобальной таблице смещений, но в конечном итоге в стандартной библиотеке).

Мы не хотим читать дизассемблированный код стандартной библиотеки, но, к счастью, Glibc (наша стандартная библиотека C) имеет открытые исходники. Что же мы таким образом получим?

Псевдонимом puts() в стандартной библиотеке является _IO_puts.

int
_IO_puts (const char *str)
{
  int result = EOF;
  size_t len = strlen (str);
  _IO_acquire_lock (stdout);

  if ((_IO_vtable_offset (stdout) != 0
       || _IO_fwide (stdout, -1) == -1)
      && _IO_sputn (stdout, str, len) == len
      && _IO_putc_unlocked ('n', stdout) != EOF)
    result = MIN (INT_MAX, len + 1);

  _IO_release_lock (stdout);
  return result;
}

То есть эта функция получает длину строки, создаёт блокировку потока вывода, выполняет проверки и вызывает _IO_sputn. Затем она отключает блокировку и возвращает количество выведенных символов.

Я поискал эту функцию, но не смог её найти. Очевидно, она выполняет какую-то работу через функцию _IO_file_jumps и вызывает calls _IO_new_file_xsputn.

size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
  const char *s = (const char *) data;
  size_t to_do = n;
  int must_flush = 0;
  size_t count = 0;

  if (n <= 0)
    return 0;
  /* This is an optimized implementation.
     If the amount to be written straddles a block boundary
     (or the filebuf is unbuffered), use sys_write directly. */

  /* First figure out how much space is available in the buffer. */
  if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
    {
      count = f->_IO_buf_end - f->_IO_write_ptr;
      if (count >= n)
	{
	  const char *p;
	  for (p = s + n; p > s; )
	    {
	      if (*--p == 'n')
		{
		  count = p - s + 1;
		  must_flush = 1;
		  break;
		}
	    }
	}
    }
  else if (f->_IO_write_end > f->_IO_write_ptr)
    count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */

  /* Then fill the buffer. */
  if (count > 0)
    {
      if (count > to_do)
	count = to_do;
      f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
      s += count;
      to_do -= count;
    }
  if (to_do + must_flush > 0)
    {
      size_t block_size, do_write;
      /* Next flush the (full) buffer. */
      if (_IO_OVERFLOW (f, EOF) == EOF)
	/* If nothing else has to be written we must not signal the
	   caller that everything has been written.  */
	return to_do == 0 ? EOF : n - to_do;

      /* Try to maintain alignment: write a whole number of blocks.  */
      block_size = f->_IO_buf_end - f->_IO_buf_base;
      do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);

      if (do_write)
	{
	  count = new_do_write (f, s, do_write);
	  to_do -= count;
	  if (count < do_write)
	    return n - to_do;
	}

      /* Now write out the remainder.  Normally, this will fit in the
	 buffer, but it's somewhat messier for line-buffered files,
	 so we let _IO_default_xsputn handle the general case. */
      if (to_do)
	to_do -= _IO_default_xsputn (f, s+do_write, to_do);
    }
  return n - to_do;
}

Ого. И всё это ради одного Hello World. Я не буду пробовать разобраться, как работает этот код, даже с комментариями. На этом моменте я понял, что использовать Glibc, чтобы объяснить происходящее, будет очень мучительно. Поэтому я решил изучить libc musl, которая, как я знаю, должна быть меньше.

▍ musl

В musl функция puts() определена следующим образом:

int puts(const char *s)
{
	int r;
	FLOCK(stdout);
	r = -(fputs(s, stdout) < 0 || putc_unlocked('n', stdout) < 0);
	FUNLOCK(stdout);
	return r;
}

То есть она получает блокировку для потока вывода, вызывает fputs и разблокирует поток вывода.

Как определена fputs()?

#include "stdio_impl.h"
#include <string.h>

int fputs(const char *restrict s, FILE *restrict f)
{
	size_t l = strlen(s);
	return (fwrite(s, 1, l, f)==l) - 1;you.
}

Она получает длину нашей строки и вызывает fwrite() с потоком вывода, нашей строкой и её длиной.

Как определена fwrite()?

size_t fwrite(const void *restrict src, size_t size, size_t nmemb, FILE *restrict f)
{
	size_t k, l = size*nmemb;
	if (!size) nmemb = 0;
	FLOCK(f);
	k = __fwritex(src, l, f);
	FUNLOCK(f);
	return k==l ? nmemb : k/size;
}

Она получает ещё одну блокировку потока вывода, вызывает __fwritex() и разблокирует поток вывода.

Как определена __fwritex()?

size_t __fwritex(const unsigned char *restrict s, size_t l, FILE *restrict f)
{
	size_t i=0;

	if (!f->wend && __towrite(f)) return 0;

	if (l > f->wend - f->wpos) return f->write(f, s, l);

	if (f->lbf >= 0) {
		/* Match /^(.*n|)/ */
		for (i=l; i && s[i-1] != 'n'; i--);
		if (i) {
			size_t n = f->write(f, s, i);
			if (n < i) return n;
			s += i;
			l -= i;
		}
	}

	memcpy(f->wpos, s, l);
	f->wpos += l;
	return l+i;
}

Код довольно большой, но основное в нём то, что он вызывает write() для объекта FILE потока вывода. Наш поток определён как stdout, так где же он определяется?

hidden FILE __stdout_FILE = {
	.buf = buf+UNGET,
	.buf_size = sizeof buf-UNGET,
	.fd = 1,
	.flags = F_PERM | F_NORD,
	.lbf = 'n',
	.write = __stdout_write,
	.seek = __stdio_seek,
	.close = __stdio_close,
	.lock = -1,
};

То есть функция write определена как __stdout_write(). Как она определяется?

size_t __stdout_write(FILE *f, const unsigned char *buf, size_t len)
{
	struct winsize wsz;
	f->write = __stdio_write;
	if (!(f->flags & F_SVB) && __syscall(SYS_ioctl, f->fd, TIOCGWINSZ, &wsz))
		f->lbf = -1;
	return __stdio_write(f, buf, len);
}

Она получает TIOCGWINSZ ioctl потока вывода и вызывает функцию __stdio_write(). Как она определена?

size_t __stdio_write(FILE *f, const unsigned char *buf, size_t len)
{
	struct iovec iovs[2] = {
		{ .iov_base = f->wbase, .iov_len = f->wpos-f->wbase },
		{ .iov_base = (void *)buf, .iov_len = len }
	};
	struct iovec *iov = iovs;
	size_t rem = iov[0].iov_len + iov[1].iov_len;
	int iovcnt = 2;
	ssize_t cnt;
	for (;;) {
		cnt = syscall(SYS_writev, f->fd, iov, iovcnt);
		if (cnt == rem) {
			f->wend = f->buf + f->buf_size;
			f->wpos = f->wbase = f->buf;
			return len;
		}
		if (cnt < 0) {
			f->wpos = f->wbase = f->wend = 0;
			f->flags |= F_ERR;
			return iovcnt == 2 ? 0 : len-iov[0].iov_len;
		}
		rem -= cnt;
		if (cnt > iov[0].iov_len) {
			cnt -= iov[0].iov_len;
			iov++; iovcnt--;
		}
		iov[0].iov_base = (char *)iov[0].iov_base + cnt;
		iov[0].iov_len -= cnt;
	}
}

Мы почти добрались до конца. Она выполняет много действий, но вызывает syscall() с SYS_writev в качестве первого параметра. Как же определена syscall()?

long syscall(long n, ...)
{
	va_list ap;
	syscall_arg_t a,b,c,d,e,f;
	va_start(ap, n);
	a=va_arg(ap, syscall_arg_t);
	b=va_arg(ap, syscall_arg_t);
	c=va_arg(ap, syscall_arg_t);
	d=va_arg(ap, syscall_arg_t);
	e=va_arg(ap, syscall_arg_t);
	f=va_arg(ap, syscall_arg_t);
	va_end(ap);
	return __syscall_ret(__syscall(n,a,b,c,d,e,f));
}

syscall() получает в качестве первого аргумента номер системного вызова и переменное количество дополнительных аргументов. Вызовы va_arg() считывают эти аргументы в переменные a, b, c, d, e и f. Затем мы вызываем __syscall() с этими аргументами, после чего результат отправляется в __syscall_ret().

К сожалению, мне не удалось найти определение __syscall(). Но мне кажется, это вызвано тем, что мы зашли на территорию, относящуюся к конкретной платформе. Musl — это многоархитектурная библиотека C, поэтому дальше выполняемый код зависит от используемой нами архитектуры. Прежде чем углубляться в изучение, я рассмотрел __syscall_ret():

long __syscall_ret(unsigned long r)
{
	if (r > -4096UL) {
		errno = -r;
		return -1;
	}
	return r;
}

Эта функция просто проверяет валидность возвращаемого __syscall() значения; если оно невалидно, то системный вызов завершился неудачно, поэтому она возвращает -1.

▍ Системные вызовы

Итак, в последних нескольких этапах вызова Hello World задействованы системные вызовы. Что такое системный вызов? Как бы ни была велика библиотека C, некоторые вещи она ни за что не выполнит. Одна из таких вещей — общение с оборудованием. Эта способность оставлена для ядра — части операционной системы, управляющей доступом к устройствам ввода-вывода, памяти и времени CPU. В нашем случае это ядро Linux. В мире Windows это ntoskrnl.exe, которое отображается в Диспетчере задач как System.

Это значит, что в конечном итоге наш вызов puts() должен попросить операционную систему выполнить для нас некие действия. В данном случае мы просим операционную систему записать какой-то текст в поток вывода. Запись в поток выполняется при помощи системного вызова write. В Musl используется схожий системный вызов writev, способный записывать несколько буферов в массив. Давайте изучим, как musl выполняет системные вызовы.

static __inline long __syscall0(long n)
{
	unsigned long ret;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n) : "rcx", "r11", "memory");
	return ret;
}

static __inline long __syscall1(long n, long a1)
{
	unsigned long ret;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1) : "rcx", "r11", "memory");
	return ret;
}

static __inline long __syscall2(long n, long a1, long a2)
{
	unsigned long ret;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2)
						  : "rcx", "r11", "memory");
	return ret;
}

static __inline long __syscall3(long n, long a1, long a2, long a3)
{
	unsigned long ret;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
						  "d"(a3) : "rcx", "r11", "memory");
	return ret;
}

static __inline long __syscall4(long n, long a1, long a2, long a3, long a4)
{
	unsigned long ret;
	register long r10 __asm__("r10") = a4;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
						  "d"(a3), "r"(r10): "rcx", "r11", "memory");
	return ret;
}

static __inline long __syscall5(long n, long a1, long a2, long a3, long a4, long a5)
{
	unsigned long ret;
	register long r10 __asm__("r10") = a4;
	register long r8 __asm__("r8") = a5;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
						  "d"(a3), "r"(r10), "r"(r8) : "rcx", "r11", "memory");
	return ret;
}

static __inline long __syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
	unsigned long ret;
	register long r10 __asm__("r10") = a4;
	register long r8 __asm__("r8") = a5;
	register long r9 __asm__("r9") = a6;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
						  "d"(a3), "r"(r10), "r"(r8), "r"(r9) : "rcx", "r11", "memory");
	return ret;
}

Мы добрались до самого дна. Эти семь функций musl использует для выполнения системных вызовов на платформе x86_64. Каждая из них получает своё количество аргументов для системного вызова.

У каждой из функций есть директива __asm__. Она встраивает ассемблерный код в вывод машинного языка компилятора. Мы выполняем системные вызовы к операционной системе, задавая в отдельных регистрах CPU свои параметры и исполняя команду syscall. Затем управление передаётся ядру, которое считывает наши параметры и исполняет системный вызов.

▍ Ядро

Далее ядро Linux должно выполнить действие, запрошенное системным вызовом. Системный вызов write просит ядро открыть файл opened в файловой системе или записать его в поток, что мы и делаем в том случае.

Системный вызов write получает три параметра: дескриптор файла, в который нужно выполнять запись, записываемый буфер и количество записываемых байтов. Действие системного вызова writev библиотеки musl отличается, но пока давайте рассмотрим write.

Куда же конкретно мы выполняем запись?

$ ps
    PID TTY          TIME CMD
  15705 pts/0    00:00:00 bash
  23332 pts/0    00:00:00 ps
$ cd /proc/15705/fd
$ readlink 1
/dev/pts/0

Это зависит от ситуации.

В данном случае я выполняю программу hello в эмуляторе терминала GNOME (это графическое приложение). Для ядра он выглядит как псевдотерминал (pty). Поэтому ядро сохраняет сообщение Hello World в буфер, а при выполнении программы эмулятора терминала она считывает и отображает его.

Разумеется, мы ещё не закончили. Затем эмулятор терминала должен отрендерить текст во фрейм (потенциально использовав для этого GPU), отправить этот фрейм в X server/compositor, который комбинирует его с остальными запущенными приложениями (тоже использующими GPU), например, с текстовым редактором, в котором я пишу эту статью, а затем отправляет его снова в ядро, которое его отображает.

Ух! Я многое опустил, потому что это не имеет особого значения и в вашей системе может выглядеть совершенно иначе. Допустим, вы можете быть подключены удалённо, в таком случае ядро отправляет текст на sshd, который затем отправляет его (в зашифрованном виде) обратно в ядро в пакете, который должен быть передан через Интернет. Или же вы можете использовать физический терминал, подключённый к адаптеру serial-to-USB. Тогда ядро должно поместить текст в пакет USB и передать его дальше. Вы также можете использовать консоль буфера кадров, это стандартный способ взаимодействия с ОС, если не установлен GUI. В этом случае ядро должно отрендерить текст в фрейме и вывести его на дисплей.

Я имею в виду, что дальше может произойти что угодно, и нам это не важно. Отправляемое сообщение Hello World — это всего лишь системный вызов от одной программы, один из миллионов системных вызовов от тысяч программ, запущенных в данный момент на вашем компьютере.

▍ Заключение

Итак, современные программные системы на современном оборудовании настолько сложны и запутанны, что не имеет никакого смысла пытаться полностью разобраться в одной крошечной операции, выполняемой компьютером. Очевидно, что мне пришлось многое опустить. Я не рассмотрел пограничные случаи, дополнительную информацию и другие операции, выполняемые компьютером. Я не объяснил, как работает ядро. Всё это могут объяснить другие люди, а вы можете изучить в своё свободное время.

Если вы прочитали всю статью целиком, спасибо.

— Так как же на самом деле работает программа Hello World?
— Лучше не спрашивайте.

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js