Источник: Linux fundamentals: user space, kernel space, and the syscalls API surface
Ядро Linux всегда занимало в моём сознании какое-то мистическое место. Это внутреннее святилище компьютерной магии, благодаря которому программы работают. Каким-то образом.
Люди, обладающие тайными знаниями о ядре Linux, часто упоминают программы «пользовательского пространства» (user space), но я никогда толком не понимал, что они под этим имеют в виду. Как, впрочем, и того, что на самом деле представляет собой «ядро».
В конечном счёте, когда я пишу программу, которая взаимодействует с файловой системой, обменивается данными по сети или как-то ещё соприкасается с внешним миром, я никогда по-настоящему не знал, как эти операции на самом деле выполняются компьютером / операционной системой / языком программирования. Магия!
Если вы, как и я, чувствуете себя пользователем Linux первого уровня, которому остро не хватает очков опыта, — не бойтесь! Этот пост постарается дать вам общий обзор следующих загадочных тем:
- Программный стек Linux в целом.
- Программы пользовательского пространства.
- Программы пространства ядра.
- Граница между пространством пользователя и ядра.
- Системные вызовы ядра Linux.
Познакомив вас с этими понятиями, я попробую показать, как программа пользуется сервисами, предоставляемыми операционной системой. Я продемонстрирую пару небольших приложений на Go, которые обращаются к сервисам операционной системы напрямую. Надеюсь, это немного рассеет мистику вокруг компьютеров. Если вы дочитаете этот пост до конца — вы получите сто очков опыта Linux!
В Linux существует целый стек сервисов, на которых держатся запускаемые пользователями процессы. Процессы, запускаемые пользователем (обычно их называют процессами пользовательского пространства, см. раздел о пользовательском пространстве ниже), опираются на сервисы, предоставляемые ядром. Ядро — это особая часть операционной системы, которая выполняет множество низкоуровневых операций в привилегированном режиме (см. раздел о пространстве ядра ниже).
Это иллюстрирует диаграмма ниже, которую я позаимствовал с linux-kernel-labs.github.io:
Эта диаграмма показывает, как процессы пользовательского пространства полагаются на ядро для доступа к оборудованию и как они обращаются к нему через интерфейс системных вызовов (syscall). Однако ядро — это не только API системных вызовов для низкоуровневых операций. Помимо обслуживания этого интерфейса для пользовательских процессов, ядро содержит планировщик процессов, сетевой стек, виртуальную файловую систему и драйверы устройств — и это лишь несколько примеров. Приведённая выше диаграмма — некоторое упрощение, и следующая диаграмма (снова позаимствованная с linux-kernel-labs.github.io) показывает более полную картину:
Остальная часть этого поста будет посвящена более подробному разбору пользовательского пространства и пространства ядра, обсуждению интерфейса системных вызовов и практическим примерам использования системных вызовов.
Процесс пользовательского пространства запускается пользователем в операционной системе и не является частью самой операционной системы. Его также может запустить система инициализации (например, systemd), но он не является частью ядра. Пользовательское пространство — это область памяти, в которой выполняются приложения, не относящиеся к ядру. Процессы пользовательского пространства в буквальном смысле выполняются в пользовательской части памяти. Процесс пользовательского пространства работает в пользовательском режиме — непривилегированном режиме выполнения инструкций процесса. Когда процессу пользовательского режима нужны сервисы ядра (например, дисковый ввод-вывод или доступ к сети), он должен переключиться в режим ядра. Переключение в режим ядра происходит через запуск системного вызова, который исполняет ядро. Этот механизм подробнее описан ниже.
Выполнение пользовательских процессов в пользовательском режиме гарантирует, что процесс пользовательского пространства не может получить доступ к памяти, управляемой ядром, или изменить её, а также не может вмешиваться в выполнение других процессов. Это важный механизм безопасности, который не позволяет пользовательским процессам повреждать операционную систему или вмешиваться в её работу.
Пространство ядра — это область системной памяти, зарезервированная для ядра. Именно в ней ядро работает и выполняет свои инструкции. Режим ядра — это режим выполнения CPU, в котором ядро работает в привилегированном режиме с root-доступом. Когда приложению из пользовательского пространства нужны сервисы ядра, оно сигнализирует ядру о необходимости выполнить системный вызов и переключается в режим ядра на время его выполнения.
На большинстве современных архитектур CPU пользовательские процессы используют специальные инструкции процессора для вызова системных вызовов. Процесс пользовательского пространства выполняет инструкцию CPU, когда хочет осуществить системный вызов, что переключает выполнение процесса из пользовательского режима в режим ядра. Системный вызов выполняется в режиме ядра, после чего выполнение возвращается процессу пользовательского пространства.
Системные вызовы — это функции в ядре, предоставляющие сервисы приложению пользовательского пространства. Это API, который ядро открывает программам пользовательского пространства и который позволяет программе использовать функциональность ядра. Примеры: запуск новых процессов, дисковый ввод-вывод и работа с сетью.
Полный список системных вызовов можно получить, выполнив man syscalls в Linux. У каждого системного вызова есть подробная man-страница, все они находятся в разделе 2. Например, man-страницу системного вызова chdir можно прочитать, выполнив man 2 chdir. Man-страницы системных вызовов — очень полезный первоисточник документации, так что если вы не знакомы с man, попробуйте выполнить man man и начните исследовать документацию по системным вызовам.
Многие системные вызовы сопровождаются небольшими программами Linux, которые их оборачивают. Например, системный вызов chdir, меняющий рабочий каталог, можно вызвать напрямую, выполнив cd в оболочке. Другие системные вызовы задуманы для совместного использования. Существует целый набор системных вызовов для работы с сокетами (например, socket, bind, listen и accept), которые в совокупности дают пользовательским программам полноценный набор функций для работы с сокетами.
Как упоминалось выше, системные вызовы запускаются через прерывание или инструкцию, выполняемую процессом пользовательского пространства с последующим исполнением в режиме ядра. Эта система обычно обёрнута в библиотеку (например, glibc), которая даёт программам чуть более высокоуровневую абстракцию в виде вызываемых функций. Более того, в большинстве языков программирования есть куда более высокие абстракции, позволяющие оперировать логическими операциями, а не физическими системными вызовами.
Тем не менее, разложение простых операций на системные вызовы может оказаться интересным. Далее в этом посте я покажу пару простых примеров на Go и продемонстрирую, как пользователь может наблюдать сами системные вызовы.
Запись в файл — простая операция в большинстве языков программирования, и она же проста на уровне системных вызовов. В запись в файл вовлечены три системных вызова:
open— открывает файловый дескриптор для использования процессом.write— позволяет процессу записать байты в файловый дескриптор.close— закрывает файловый дескриптор.
Использование этих системных вызовов иллюстрирует следующий пример на Go:
package main
import (
"os"
"golang.org/x/sys/unix"
)
func main() {
fd, err := unix.Open("test.txt", os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
panic(err)
}
_, err = unix.Write(fd, []byte("hello world\n"))
if err != nil {
panic(err)
}
err = unix.Close(fd)
if err != nil {
panic(err)
}
}Эта программа открывает файл test.txt, записывает в него строку hello world и закрывает файловый дескриптор. Обычно эти операции скрыты за более высокоуровневым API, но прямое использование системных вызовов наглядно показывает, как запись в файл происходит на границе пользовательского пространства и пространства ядра.
Использование ядра этой программой можно дополнительно продемонстрировать с помощью диагностического инструмента strace. Системные вызовы можно наблюдать напрямую через strace как для процесса, который вы хотите запустить, так и для уже работающего. Он позволяет видеть системные вызовы, используемые программой, что очень полезно для понимания, какие операции программа выполняет, опираясь на системные ресурсы.
Если скомпилировать пример выше и запустить его под strace, вы увидите примерно следующее:
$ go build -o file-syscall-example main.go
$ strace file-syscall-example
...
openat(AT_FDCWD, "test.txt", O_WRONLY|O_CREAT, 0600) = 3
write(3, "hello world\n", 12) = 12
close(3) = 0В выводе strace числа после знака равенства — это значения, возвращённые самим системным вызовом. В этом примере openat() = 3 говорит нам, что открытый файловый дескриптор имеет номер 3, write() = 12 — что записано 12 байт, а close() = 0 — что операция завершилась успешно.
Этот пример демонстрирует использование системных вызовов для доступа к файлам с внешней точки зрения, и та же техника применима к любой программе, чтобы понять, как она взаимодействует с ядром.
В ядре Linux есть множество системных вызовов для работы с сокетами: unix-сокеты, TCP-сокеты, UDP-сокеты и ряд более экзотических сетевых протоколов. Все эти системные вызовы построены вокруг файлового дескриптора сокета и (в случае TCP) файлового дескриптора соединения. Эти дескрипторы можно использовать для чтения байтов в буфер (или отправки данных) и закрывать, когда сокет больше не нужен.
Пример на Go ниже иллюстрирует все эти шаги:
package main
import (
"fmt"
"golang.org/x/sys/unix"
)
func main() {
// Создаём сокет.
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
if err != nil {
fmt.Println("error calling SOCKET")
panic(err)
}
// Привязываем сокет к адресу.
err = unix.Bind(fd, &unix.SockaddrInet4{
Port: 8080,
Addr: [4]byte{127, 0, 0, 1},
})
if err != nil {
fmt.Println("error calling BIND")
panic(err)
}
// Начинаем прослушивание сокета.
err = unix.Listen(fd, 0)
if err != nil {
fmt.Println("error calling LISTEN")
panic(err)
}
// Принимаем TCP-соединения на сокете.
connectionFd, _, err := unix.Accept(fd)
if err != nil {
fmt.Println("error calling ACCEPT")
panic(err)
}
bytes := make([]byte, 8)
oobBytes := make([]byte, 8)
// Читаем данные из соединения.
_, _, _, _, err = unix.Recvmsg(connectionFd, bytes, oobBytes, 0)
if err != nil {
fmt.Println("error calling READ")
panic(err)
}
fmt.Printf("received bytes:\n%v\n", string(bytes))
// Закрываем соединение.
err = unix.Close(connectionFd)
if err != nil {
fmt.Println("error calling CLOSE for the connection file descriptor")
panic(err)
}
// Закрываем сокет.
err = unix.Close(fd)
if err != nil {
fmt.Println("error calling CLOSE for the socket file descriptor")
panic(err)
}
}Использование системных вызовов этой программой снова можно наблюдать с помощью strace:
$ go build -o socket-syscalls main.go
$ strace socket-syscalls > syscalls.log &
$ netcat localhost 8080
hello world!
$ cat syscalls.log
...
socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
listen(3, 0) = 0
accept4(3, {sa_family=AF_INET, sin_port=htons(58868), sin_addr=inet_addr("127.0.0.1")}, [112->16], 0) = 4
...
recvmsg(4, {msg_name=0x400004e0e0, msg_namelen=112->0, msg_iov=[{iov_base="hello\n", iov_len=8}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, 0) = 6
...
close(4) = 0
close(3) = 0Этот пример немного сложнее примера с записью в файл, поскольку нам нужно открыть TCP-соединение к программе и отправить ей данные. Для этого удобен инструмент netcat: в этом примере мы просто отправляем текст «hello world», а затем закрываем соединение с клавиатуры.
Надеюсь, этот пост немного приоткрыл завесу тайны над работой ядра Linux и его связью с пользовательскими программами. Главные выводы для меня такие:
- Ядро Linux предоставляет программы сервисы через системные вызовы — функции, позволяющие программе взаимодействовать с системными ресурсами.
- Системные вызовы выполняются в пространстве ядра, в отличие от обычных инструкций программы, которые выполняются в пользовательском пространстве.
- Сами системные вызовы покрывают широкий спектр функциональности, включая дисковый ввод-вывод и работу с сетью.
- Использование приложением системных вызовов можно наблюдать напрямую с помощью
strace— полезного диагностического инструмента.
Поздравляем, вы получили свои сто очков опыта Linux!

