Де закінчується програма й починається машина
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР1. Матеріали публікуються в Google Classroom разом із лекцією.
Коли ти пишеш print("hello") на Python або рендериш кнопку в React, ти працюєш у
зручному, підготовленому для тебе світі: інтерпретатор, збирач сміття, віртуальна
машина браузера — усе це шар за шаром ховає від тебе, як насправді влаштована машина
під ногами. Системне програмування — це дисципліна, яка знімає ці шари один за одним
і працює безпосередньо з тим, що надає операційна система: пам'яттю, процесами,
файловими дескрипторами, мережевими сокетами, сирими байтами на диску.
Ціна помилки тут інша, ніж у прикладному коді. Забутий free() у C — це витік
пам'яті, який за тижні роботи сервера з'їсть усю оперативку. Неправильно виставлені
права контейнера — це діра, крізь яку процес усередині «в'язниці» дотягнеться до
файлів хоста. Гонка (race condition) у роботі з файловим дескриптором — це
непередбачувана поведінка сотень процесів одночасно, а в гіршому разі — крах ядра.
Тому системне програмування вимагає не «запам'ятати синтаксис», а зрозуміти модель:
що саме гарантує операційна система, а що — ні.
Курс «Системне програмування» 2026 року побудований навколо однієї наскрізної ідеї — межі. У кожному з трьох модулів ми перетинаємо іншу межу між кодом і машиною: Docker — межу між процесом і операційною системою (модуль 1), Rust — межу між програмою та її власною пам'яттю (модуль 2), WebAssembly — межу між кодом і чужим, недовіреним середовищем виконання (модуль 3). Сьогоднішня лекція закладає фундамент для всіх трьох: що таке процес з погляду ядра, і як через один-єдиний механізм — системний виклик — програма взагалі отримує доступ до світу поза собою.
У побутовому сенсі ми кажемо «запустив програму» і маємо на увазі одне й те саме —
файл на диску й те, що виконується. Але ядро розрізняє ці поняття принципово. Файл
програми (наприклад, /bin/ls) — це пасивні байти на диску: машинний код, таблиці
символів, метадані ELF-формату. Він нічого не робить, поки лежить на диску.
Процес — це зовсім інша сутність: активний, супроводжуваний ядром об'єкт, що виникає
в момент, коли ядро виконує системний виклик (syscall) execve() над цим файлом. З цієї миті
ядро веде для процесу окрему бухгалтерію: унікальний ідентифікатор (PID), стан
виконання (running, sleeping, zombie, stopped), батьківський процес (PPID), список
відкритих файлових дескрипторів, таблицю відображення сторінок пам'яті, набір
облікових даних (UID, GID, capabilities) і багато іншого. Усе це в Linux зберігається
у структурі ядра task_struct — по одному екземпляру на кожен процес (і навіть на
кожен потік: у Linux потік — це «полегшений» процес, який ділить адресний простір з
батьківським).
Ключова ідея: кожен процес живе в ілюзії власного, ексклюзивного світу. Він «бачить» адресний простір (address space) 0…2⁶⁴, ніби машина належить лише йому. Він «бачить» процесор, ніби виконується безперервно. Насправді ядро підмінює йому цю картину: планувальник перемикає контекст між сотнями процесів десятки разів на секунду, а MMU (memory management unit) транслює адреси процесу у фізичну пам'ять непомітно для нього. Уся ця ілюзія тримається на одному фундаменті — ядро контролює межу.
У Unix-подібних системах немає окремого системного виклику «запустити нову програму» в один крок. Замість цього використовуються дві незалежні операції, кожна з яких вирішує свою задачу.
fork() створює копію поточного процесу: новий процес (дитина) отримує власний PID,
але спершу — практично ідентичний знімок пам'яті батька. Насправді ядро використовує
техніку copy-on-write (COW): фізичні сторінки не копіюються одразу, а лише
позначаються «тільки для читання» й спільно використовуються батьком і дитиною;
реальне копіювання конкретної сторінки відбувається лише в момент, коли хтось із них
намагається її змінити. Дитина повертається з fork() з кодом 0, батько — з PID
дитини. Це дозволяє одному й тому ж коду розгалужуватись на дві гілки виконання.
execve() — зовсім інша дія: вона повністю замінює образ пам'яті поточного процесу
новим виконуваним файлом. PID лишається той самий, але код, дані, стек — усе
перезаписується новою програмою. Класична оболонка виконує послідовність fork() +
execve(): спершу породжує копію себе, а потім у дитині викликає execve(), щоб
перетворити цю копію на нову програму (наприклад, ls). Батьківський процес (shell)
тим часом чекає завершення дитини через wait()/waitpid().
# Спостерігаємо це наживо через strace
strace -f -e trace=fork,vfork,clone,execve bash -c "ls > /dev/null"
У сучасних системах прямий fork() частіше реалізовано через гнучкіший системний
виклик clone(), який приймає прапорці, що визначають, скільки саме розділяти з
батьком: адресний простір, файлові дескриптори, простори імен. Саме clone() з
прапорцями CLONE_NEWPID, CLONE_NEWNET тощо — фундамент, на якому побудовані
контейнери, про що піде мова в лекції 2.
Процес не може напряму «попросити» диск прочитати сектор чи мережеву карту відправити пакет. Апаратне забезпечення в сучасних процесорах x86-64 та ARM організоване за принципом кілець захисту (protection rings) — рівнів привілеїв, які апаратно обмежують, які інструкції й регістри доступні коду.
| Кільце | Назва | Хто виконується | Приклад |
|---|---|---|---|
| Ring 0 | kernel mode | Ядро ОС | обробники переривань, планувальник, драйвери |
| Ring 1–2 | (майже не використовуються в Linux) | — | історично для гіпервізорів/драйверів |
| Ring 3 | user mode | Звичайні програми | твій ls, python3, nginx |
Код у Ring 3 фізично не має доступу до привілейованих інструкцій — спроба виконати
їх викликає апаратний виняток (general protection fault). Єдиний легальний спосіб
для user-space коду попросити ядро щось зробити — це системний виклик (syscall):
спеціальна інструкція процесора (syscall на x86-64), яка перемикає CPU в Ring 0,
передає керування заздалегідь визначеному обробнику ядра з номером бажаної операції
та аргументами в регістрах, і після виконання повертає керування назад у Ring 3.
Кожна дія, яку виконує програма поза власною пам'яттю — читання файлу, відкриття
сокета, створення процесу, виділення додаткової пам'яті через mmap — це виклик
однієї з кількох сотень syscalls, які надає ядро Linux (наразі трохи більше 450 на
x86-64). Бібліотека libc ховає це за зручними функціями (printf, malloc,
fopen), але зрештою все зводиться до тонкого шару syscalls.
# Побачити реальний перелік syscalls, які виконує проста команда
strace -c ls > /dev/null
# % time seconds usecs/call calls syscall
# ------ ----------- ----------- --------- ----------------
# 35.20 0.000234 12 19 openat
# 20.11 0.000134 7 19 read
# ...
Саме тому «межа» в назві курсу — не метафора. Docker обмежує, які syscalls і з яким
ефектом дозволені контейнеру (через namespaces, seccomp-профілі, capabilities). Rust
у момент компіляції гарантує, що небезпечний доступ до пам'яті неможливий без
явного unsafe, тобто контролює межу ще до того, як код узагалі дійде до syscall.
WebAssembly в браузері не має прямого доступу до syscalls операційної системи
взагалі — лише до того, що явно надав JavaScript-хост через «import»-функції. Три
різні технології, одна й та сама ідея контрольованої межі, реалізована на різних
рівнях стека.
Кожен процес отримує від ядра ілюзію лінійного адресного простору від 0 до 2⁶⁴−1 (на практиці на x86-64 використовується canonical-діапазон, ~128 ТБ користувацького простору). Цей простір поділений на сегменти з різним призначенням:
malloc/new; росте вгору через
syscall brk/mmap;libc.so тощо), відображений у
пам'ять процесу через mmap, часто спільний фізично між процесами, що
використовують ту саму бібліотеку;Ядро підтримує для кожного процесу таблицю сторінок (page table), яка відображає ці віртуальні адреси на реальні фізичні сторінки пам'яті. Саме завдяки цій непрямій адресації два різні процеси можуть мати абсолютно різний фізичний вміст за однаковою віртуальною адресою — і не бачити один одного, якщо ядро цього не дозволило.
Linux надає віртуальну файлову систему /proc, у якій кожен активний процес
представлений каталогом /proc/<pid>/. Це не файли на диску — вони генеруються
ядром «на льоту» при читанні й показують реальний внутрішній стан:
sleep 300 &
PID=$!
cat /proc/$PID/status # стан, використання пам'яті, батько
cat /proc/$PID/maps # список сегментів адресного простору з правами доступу
ls /proc/$PID/fd # відкриті файлові дескриптори (символьні посилання)
cat /proc/$PID/cmdline # аргументи запуску
/proc/<pid>/maps особливо показовий: кожен рядок — це один регіон адресного
простору з правами r/w/x і файлом, з якого він відображений (сам бінарник,
спільна бібліотека, heap, stack або анонімна mmap-область). Це буквально та сама
структура, яку ми щойно розглянули теоретично, побачена наживо для конкретного
процесу.
Розуміння процесу й syscall-межі — не самоціль, а фундамент для трьох модулів курсу:
task_struct, до якої PID є лише ключем пошуку.fork() не копіює «миттєво» все. Через copy-on-write фізичні сторінки не
дублюються одразу — тому fork() дешевший, ніж здається, але перше ж записування
в дочірньому процесі спричиняє реальне копіювання сторінки.printf)
не видно напряму — strace перехоплює лише межу user↔kernel, тому один printf
може розкластися на кілька або жодного syscall залежно від буферизації./proc — не статичний знімок. Значення там змінюються в реальному часі;
повторний cat того самого файлу за секунду може дати інші цифри.Процес — не файл, а активна абстракція, яку ядро підтримує через task_struct,
адресний простір і таблицю дескрипторів. Єдиний легальний спосіб для процесу вийти
за межі власної пам'яті — системний виклик, апаратно захищений переходом між Ring 3
і Ring 0. /proc дає змогу побачити цю внутрішню бухгалтерію ядра як звичайні
файли. Розуміння цієї межі — фундамент для всіх трьох модулів курсу: Docker ізолює
процес через namespaces і cgroups, Rust контролює межу пам'яті на етапі компіляції,
WebAssembly обмежує доступ до syscalls до явно імпортованого хостом. Наступна
лекція заглиблюється в перший з трьох механізмів — простори імен.
Фокус: одна ідея — межа середовища виконання
Мотивація: чому це важливо саме зараз
Ілюзію «я сам на машині» підтримує ядро: планувальник і MMU
Усе це видно через каталог /proc/<pid>/
sleep 300 &
PID=$! # напр. 3101
cat /proc/$PID/status | head -5
cat /proc/$PID/maps | head -5
ls /proc/$PID/fd/proc — файлова проекція внутрішнього стану ядра для кожного процесу
| Файл | Що показує | Частина task_struct |
|---|---|---|
status | стан, PPID, пам'ять, UID/GID | стан, батько, облікові дані |
maps | регіони адресного простору і права | таблиця сторінок (mm) |
fd/ | відкриті дескриптори як символьні посилання | таблиця файлів |
cmdline | аргументи запуску | образ програми |
ns/ | простори імен процесу | → Лекція 2 |
cgroup | група керування ресурсами | → Лекція 2 |
Файли генеруються ядром «на льоту» при читанні — це не диск
cat /proc/$PID/maps
# приклад рядка:
# 55f2a1000000-55f2a1001000 r--p 00000000 08:01 123 /bin/sleep
# 7f9c4b000000-7f9c4b028000 r-xp 00000000 08:01 456 /lib/x86_64-linux-gnu/libc.so.6Кожен рядок — один регіон адресного простору: адреси, права rwx, файл-джерело
Так shell запускає кожну команду: ls, cat, docker
На демо покажіть: PID дитини той самий до і після execve — змінився лише вміст пам'яті
Два незалежні кроки замість одного «запустити програму» — дизайн Unix
strace -f -e trace=fork,vfork,clone,execve,wait4 bash -c "ls > /dev/null"Видно послідовність clone() (нова дитина) → execve() (заміна образу) → wait4() (батько чекає)
libc ховає syscalls за printf, malloc, fopen — але знизу завжди вони
strace -c ls > /dev/null
# short summary: скільки викликів read/write/openat/execve...strace показує реальний діалог програми з ядром
Очікувано: Студенти бачать: процес у ps — це рядок, а в /proc — цілий каталог зі структурою пам'яті й дескрипторів
| Сегмент | Вміст | Права | Росте |
|---|---|---|---|
| text | машинний код програми | r-x | — |
| data / bss | глобальні змінні | rw- | — |
| heap | malloc / new; brk, mmap | rw- | ↑ вгору |
| shared libs | libc.so та інші, через mmap | r-x | — |
| stack | локальні змінні, адреси повернення | rw- | ↓ вниз |
Ті самі регіони — рядки у /proc/<pid>/maps; права видно в другій колонці
Мета абстракції — не бути розпливчастим, а створити новий семантичний рівень, на якому можна бути абсолютно точним. — Едсгер Дейкстра, «The Humble Programmer» (1972)
docker run --rm -it ubuntu:24.04 ps aux
# у контейнері PID 1 — це наш процес, а не системний init хостаАнонс Л2: той самий процес, інший PID-простір
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
sleepновеsleep infinity — назавжди; зручно, щоб контейнер «жив», поки ми його вивчаємо).cat/proc/....headhead -5 — перші п'ять).lsновеls -la — з правами, власником, розміром і прихованими файлами.docker rundocker run -it ubuntu:24.04 bash — інтерактивно з терміналом; -d — у фоні; --rm — видалити після виходу; -p 8080:80 — прокинути порт).dockerdocker <підкоманда> --help — довідка).psновеps aux — усі процеси з користувачем, PID і командою (у контейнері видно лише його власні).straceновеstrace -f -e trace=execve bash -c ls).echoновеecho $$ — PID поточної оболонки).grepновеps aux | grep bash).unshareновеunshare --pid --fork --mount-proc bash).python3новеpython3 -m http.server 80, найкоротший спосіб підняти тестовий HTTP-сервер.printfновеprintf '\xff' > bad.bin).💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.