User space и kernel space: почему программа сама ничего не может
Начнём с базовой картины мира. В Linux память и права поделены на две зоны. Есть user space (пространство пользователя) - там живут твои программы: nginx, python, postgres, bash. И есть kernel space (пространство ядра) - там живёт само ядро Linux, которое управляет железом: диском, сетевой картой, оперативной памятью, процессором.
Главная мысль урока: обычная программа из user space НЕ может сама читать диск, слать пакеты в сеть или брать память у железа. Процессор физически запрещает ей это - она крутится в непривилегированном режиме (на x86-64 его называют ring 3). Прямой доступ к железу - привилегия ядра (ring 0).
Аналогия. Ты клиент в банке, сидишь за бронированным стеклом. Тебе нужны деньги из хранилища, но сам туда не полезешь - там бронедверь. Ты пишешь заявку и просовываешь её в окошко кассиру. Кассир (ядро) идёт в хранилище (железо), делает работу и возвращает результат. Это окошко, эта заявка через стекло - и есть системный вызов. По-английски system call, коротко syscall.
То есть граница user space / kernel space - это и есть бронированное стекло. Всё, что программе нужно от внешнего мира, она получает только через окошко. Именно поэтому трассировка так ценна: если сидишь у окошка и записываешь все заявки - ты знаешь про программу всё, что она делает наружу. Внутренние вычисления (сложить два числа, прокрутить цикл) идут целиком в user space и в трассировке не видны - и это правильно, нам важна именно граница с внешним миром.

Как технически работает syscall в Linux
Теперь чуть глубже под капот, но без страха - это проще, чем кажется. Программа не звонит ядру и не шлёт письмо. Она кладёт данные в регистры процессора и выполняет одну специальную инструкцию.
На современных 64-битных процессорах (x86-64) это инструкция syscall. По шагам:
- Программа кладёт номер системного вызова в регистр rax. У каждого syscall свой номер, фиксированный для архитектуры. Например, на x86-64: read - 0, write - 1, open - 2, execve - 59, а openat - 257 (номера разбросаны, это не опечатка - они присваивались исторически).
- Аргументы кладутся в регистры строго по порядку: rdi, rsi, rdx, r10, r8, r9. Это первый, второй и так далее аргументы. Важная деталь: для syscall четвёртый аргумент идёт в r10, а не в rcx, как в обычном вызове функции - сама инструкция syscall затирает rcx и r11, поэтому ядро их не использует под аргументы.
- Выполняется инструкция syscall. Процессор переключается в режим ядра (ring 0), управление прыгает в заранее заданную точку входа в ядре.
- Ядро смотрит на номер в rax, находит обработчик, делает работу (читает диск, шлёт пакет) и кладёт результат обратно в rax.
- Возврат в user space, программа читает результат из rax и продолжает.
Маленькая деталь про имена: open ты пишешь в коде, но glibc на современных ядрах обычно дёргает более новый openat - он умеет относительные пути от дескриптора каталога. Не пугайся, когда увидишь openat вместо open в выводе - это тот же "открыть файл". Есть и ещё более новый, безопасный вариант openat2 (номер 437, доступен с ядра 5.6) - он встречается реже, но смысл тот же.
Зачем трассировать системные вызовы: рентген программы
Теперь главное - зачем это на практике. Раз ВСЁ общение программы с внешним миром идёт через syscall, перехватив их, мы видим всю активность наружу. Не косвенно по логам, а напрямую, по факту.
Что становится видно через linux syscall трассировку:
- Файлы: какие открывает (openat), читает (read), пишет (write), какие пути не нашлись. Конфиг не подхватился? Увидишь openat с ENOENT на нужном пути.
- Сеть: куда коннектится (connect), какой IP и порт, что шлёт (sendto, write) и принимает (recvfrom, read). Зависла на запросе к недоступному хосту - увидишь застрявший connect или recvfrom.
- Память: как выделяется (mmap, brk). Резкий рост числа mmap - возможна утечка или фрагментация.
- Запуск процессов: что и с какими аргументами запускается (execve, clone).
- Блокировки и ожидания: futex - примитив, на котором в Linux построены мьютексы и условные переменные в многопоточке. Программа висит на futex - значит ждёт другой поток.
- Метаданные: stat, fstat, newfstatat - проверка существования и атрибутов файла. ioctl - управление устройствами и терминалом.
Разберём реальный кусок вывода strace по полям. Запустим простую команду:
Код: Выделить всё
$ strace cat /etc/hostname
execve("/usr/bin/cat", ["cat", "/etc/hostname"], 0x7ffd.. /* 30 vars */) = 0
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
read(3, "myhost\n", 131072) = 7
write(1, "myhost\n", 7) = 7
read(3, "", 131072) = 0
close(3) = 0
- execve(...) = 0 - запустился сам cat. Результат 0 значит успех. Видны путь к бинарю и весь argv.
- openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3 - открыли файл только на чтение. AT_FDCWD значит "путь от текущего каталога". Результат 3 - это файловый дескриптор. Дескрипторы 0, 1, 2 заняты под stdin/stdout/stderr, поэтому первый свой файл обычно получает 3.
- read(3, "myhost\n", 131072) = 7 - читаем из дескриптора 3. Второй аргумент - что прочитали (strace показывает содержимое, по умолчанию обрезая длинные строки), третий - размер буфера, сколько байт максимум просили. Результат 7 - реально прочитано 7 байт.
- write(1, "myhost\n", 7) = 7 - пишем в дескриптор 1 (stdout, твой экран) те самые 7 байт. Результат 7 - всё записалось. Если бы вернулось меньше (короткая запись), это был бы повод разбираться.
- read(3, "", 131072) = 0 - читаем снова, результат 0. Ноль на read означает конец файла, данных больше нет.
- close(3) = 0 - закрыли дескриптор.
Код: Выделить всё
openat(AT_FDCWD, "/etc/app/config.yml", O_RDONLY) = -1 ENOENT (No such file or directory)
2026: strace это ptrace, а рядом давно живёт eBPF
Важная актуализация, без неё картина неполная. Классический strace работает через старый механизм ptrace: ядро останавливает процесс на входе в каждый syscall и на выходе, отдаёт управление strace, тот декодирует и будит процесс обратно. Это дорого: каждый перехваченный вызов добавляет десятки микросекунд, и под нагрузкой процесс замедляется в разы (на практике от 2x до 100x на syscall-тяжёлых программах). Поэтому держать полный strace на боевом высоконагруженном демоне - плохая идея.
Что есть в 2026 году как современная альтернатива (и куда индустрия ушла):
- eBPF - технология, позволяющая безопасно запускать мини-программы прямо в ядре и снимать события без остановки процесса. Накладные расходы кардинально ниже ptrace, можно работать на проде долго.
- bpftrace - высокоуровневый язык трассировки поверх eBPF (наследник идей DTrace). Один однострочник дёргает нужные точки ядра без пауз процесса. Для постоянного наблюдения за syscalls на нагруженной системе сегодня тянутся именно к нему и к BCC-инструментам.
- perf trace - встроенный в пакет perf аналог strace на базе perf-событий, заметно дешевле классического strace.
Типичные грабли и заблуждения
- "Системный вызов и функция libc - одно и то же". Нет. printf, malloc, fopen - функции glibc, они живут в user space. Внутри они уже дёргают syscall (write, mmap/brk, openat). Один fopen может породить несколько системных вызовов. Поэтому в strace ты видишь openat, а не fopen, и write, а не printf.
- "-1 в выводе - всегда катастрофа". Не всегда. Многие программы специально пробуют несколько путей и спокойно ловят ENOENT - это нормальная логика поиска (вспомни, как загрузчик ищет библиотеку в десятке каталогов). Тревожной ошибку делает контекст: EACCES (нет прав), ECONNREFUSED (коннект отбит), ETIMEDOUT (таймаут), EMFILE (кончились дескрипторы). Смотри не на сам факт -1, а на errno и на то, после какой именно ошибки программа сломалась.
- "Программа зависла на read - значит работает". Скорее наоборот: застряла на read или recvfrom - ждёт данные, которых нет. Висит на futex - ждёт блокировку от другого потока, возможный дедлок. Висит на connect или poll/epoll_wait - сеть не отвечает или нет событий. Последний системный вызов в выводе - это то, чего программа ждёт прямо сейчас. Это первое, на что смотрят при зависании.
- "strace бесплатен, можно вешать на прод". Нет, ptrace тормозит процесс ощутимо. Для лёгкого разового замера используй сводку strace -c (она дешевле полного лога), а для постоянного наблюдения - eBPF/bpftrace.
- "Это работает только на x86". Механизм один и тот же на всех архитектурах, меняются инструкция перехода в ядро и таблица номеров. На ARM64 (сейчас это уже массовая серверная платформа) свой номер syscall в регистре x8 и своя таблица, но идея окошка в ядро та же.
Открой терминал. strace ставится пакетом strace (sudo apt install strace или sudo dnf install strace).
- Посмотри полную жизнь команды:
Код: Выделить всё
strace cat /etc/hostname
- Посчитай, какие вызовы и сколько раз делает ls. Флаг -c даёт сводку и работает дешевле полного лога:
Код: Выделить всё
strace -c ls /
- Поймай ошибку специально - открой несуществующий файл и отфильтруй только openat:
Код: Выделить всё
strace -e trace=openat cat /no/such/file 2>&1 | grep ENOENT
- Подцепись к уже запущенному процессу (то, о чём спрашивают на проде). Возьми PID любого своего процесса и приклейся к нему, ограничив только сетью; Ctrl+C отцепит strace, процесс продолжит жить:
Код: Выделить всё
strace -p <PID> -e trace=network -f
Контрольные вопросы
- Почему обычная программа не может сама прочитать файл с диска и что ей для этого нужно сделать?
- Что лежит в регистре rax до инструкции syscall и что в нём оказывается после возврата из ядра? Как по rax отличить успех от ошибки?
- Ты видишь openat(...) = -1 ENOENT и рядом openat(...) = -1 EACCES. В чём разница и почему чинятся они по-разному?
- Программа зависла, последняя строка strace - futex(...). О чём это говорит и куда копать дальше? Почему для долгого наблюдения за этим на проде лучше взять bpftrace, а не strace?
Системный вызов - единственное окошко из user space в kernel space, через которое программа просит ядро поработать с железом: файлами, сетью, памятью. Технически это инструкция syscall с номером в rax и аргументами в rdi, rsi, rdx, r10, r8, r9; результат тоже в rax, а отрицательное значение - это -errno. Раз через окошко проходит всё общение программы с внешним миром, его трассировка - рентген: видны все файлы, коннекты и ожидания даже без исходников. Формат вывода прост - имя(аргументы) = результат, и читать его ты теперь умеешь. Классический strace делает это через ptrace и потому тормозит процесс - для разовой диагностики он идеален, а на проде под нагрузкой в 2026 берут eBPF-инструменты (bpftrace, perf trace), которые показывают те же системные вызовы дешевле. На этом фундаменте дальше мы и построим работу со strace.