Розуміння основ роботи комп’ютерів. Вичерпний посібник

16 лютого 2024 9 хвилин Автор: Lady Liberty

Ви поринете у захоплюючий світ комп’ютерних технологій і дізнаєтеся, як влаштовані комп’ютери. Від основних компонентів, з яких складається будь-який комп’ютер, до складних операцій з обробки даних – ми розкриємо всі секрети, які роблять ці машини незамінними у нашому повсякденному житті.

Подорож у серце комп’ютера

Досліджуючи глибини комп’ютерних технологій, часто можна зіткнутися з прогалинами у знаннях, особливо коли мова йде про те, що конкретно відбувається при запуску програми на комп’ютері. Цікавість до того, як програми виконуються безпосередньо в центральному процесорі (CPU), як працюють системні виклики (syscalls) і що вони насправді означають, а також як кілька програм можуть виконуватися одночасно, часто залишається без відповідей.

Архітектура комп’ютера

ЦП – це місце, де відбуваються всі обчислення. Він починає “пихкати” як тільки ви включаєте комп’ютер, виконуючи інструкцію за інструкцією.

Першим масовим ЦП був Intel 4004, спроектований наприкінці 60-х італійським фізиком та інженером Федеріко Фарджіном . Це була 4-бітна архітектура, а не 64-бітна, яка використовується сьогодні. 4-бітна архітектура була набагато менш складною, ніж сучасні процесори, але багато залишилося майже незмінним.

“Інструкції”, що виконуються ЦП – всього лише двійкові (binary) дані: байт або два для подання інструкції, що запускається (код операції – opcode), за якими слідують дані, необхідні для виконання інструкції. Те, що ми називаємо машинним кодом (machine code) – лише серія цих бінарних інструкцій у вигляді рядка. Assemble – це корисний синтаксис, що полегшує читання та запис сирих (raw) бітів людьми. Він завжди компілюється у двійкові дані, зрозумілі ЦП.

Ремарка: інструкції в машинному коді не завжди представлені 1:1, як у прикладі. Наприклад, add eax, 512перетворюється на 05 00 02 00 00.

Перший байт ( 05) – це код операції, що представляє додавання регістру EAX до 32-бітного числа. Інші байти – це 512 ( 0x200) з порядком байтів little-endian .

Defuse Security розробили корисний інструмент для перетворення мови асемблера на машинний код.

Оперативна пам’ять або оперативний запам’ятовуючий пристрій (Random Access Memory, RAM) – це основне сховище комп’ютера, велике багатоцільове місце, де зберігаються всі дані, використовувані програмами, запущеними на комп’ютері. Це включає код програм, а також код ядра (kernel) операційної системи. ЦП завжди читає машинний код прямо із ВП. Код, що не завантажений в ОП, не може бути виконаний.

ЦП зберігає покажчик інструкції (instruction pointer), який вказує місце у ВП, де є наступна інструкція. Після виконання всіх інструкцій ЦП повертає покажчик на початок, і процес повторюється. Це називається циклом вибірки-виконання (fetch-execute cycle).

Після виконання інструкції покажчик пересувається за неї в ОП і вказує на таку інструкцію. Вказівник інструкції рухається вперед, і машинний код виконується в порядку зберігання в пам’яті. Деякі інструкції можуть змусити покажчик переміститися (перестрибнути – jump) в інше місце (виконати іншу інструкцію замість поточної або виконати одну з інструкцій залежно від певної умови). Це уможливлює повторне та умовне виконання коду.

Покажчик інструкції зберігається у регістрі . Регістри – це маленькі сховища, які є дуже продуктивними для читання та запису ЦП. Кожна архітектура ЦП має фіксований набір регістрів, які використовуються для всього: від зберігання тимчасових значень у процесі обчислень до налаштування процесора.

Деякі регістри доступні з машинного коду безпосередньо, наприклад, ebxна наведеній вище діаграмі.

Інші регістри призначені лише для внутрішнього використання ЦП, але можуть оновлюватися або читатися за допомогою спеціальних інструкцій. Одним із прикладів такого регістру є покажчик інструкції, який не може читатись безпосередньо, але може оновлюватися, наприклад, для виконання іншої інструкції замість поточної.

Процесори

Повернемося до питання, що відбувається під час запуску програми на комп’ютері. Спочатку відбувається деяка магія – ми поговоримо про це пізніше – і ми отримуємо файл із машинним кодом, який десь зберігається. ОС завантажує їх у ОП і вказує ЦП перемістити покажчик на певну позицію ОП. Починається цикл вибірки-виконання, та програма запускається!

Ваш ЦП отримує послідовні інструкції браузера з ОП та виконує їх, що призводить до рендерингу цієї статті.

ЦП мають дуже обмежений кругозір. Вони бачать лише покажчик поточної інструкції та трохи внутрішнього стану. Процеси – це абстракції ОС, а чи не щось, що розуміють чи відстежують ЦП.

Це викликає більше запитань, ніж відповідей:

  1. Якщо ЦП не знає про багатозадачність (multiprocessing) і виконує інструкції послідовно, чому він не застрягне у програмі, що виконується? Як можна одночасно виконувати кілька програм?

  2. Якщо програма виконується в ЦП, і ЦП має прямий доступ до ОП, чому код з інших процесів або, вибач, ядра не має доступу до пам’яті?

  3. Який механізм, що запобігає виконанню будь-якої інструкції будь-яким процесом? Що таке системний виклик?

Більшість звернень до пам’яті проходить через шар неправильного напрямку (misdirection), який перепризначає весь адресний простір. Поки ми будемо виходити з припущення, що програми мають прямий доступ до всієї ОП, і комп’ютери можуть запускати лише один процес за один раз. Згодом ми позбудемося обох цих припущень.

Настав час стрибнути в нашу першу кролячу нору – в країну, повну системних викликів та кілець безпеки (security rings).

  • Що таке ядро?

  • ОС вашого комп’ютера, така як MacOS, Windows або Linux – це колекція програмного забезпечення, що виконує всю основну роботу. “Основна робота” – це загальне поняття, і, залежно від ОС, може містити такі речі, як додатки, шрифти, іконки, які постачаються з комп’ютером за промовчанням.

  • Кожна ОС має ядро. Коли ви вмикаєте комп’ютер, покажчик інструкції запускає якусь програму. Цією програмою є ядро. Ядро має майже повний доступ до пам’яті, периферійних пристроїв та інших ресурсів і відповідає за запуск ПЗ, встановленого на комп’ютері (програми користувача).

  • Linux – це всього лише ядро, якому для повноцінної роботи потрібно безліч програм, таких як оболонки (shells) і сервери відображення (display servers). Ядро macOS називається XNU , а сучасне ядро ​​Windows- NT Kernel.

Два кільця, щоб правити всіма

Режим (mode) (іноді називається рівнем привілеїв (privilege level) чи кільцем (ring)), у якому перебуває процесор, управляє тим, що можна робити. У сучасних архітектурах є, як мінімум, два варіанти: режим ядра/адміністратора (kernel/supervisor mode) та режим користувача (user mode). Незважаючи на те, що архітектура може підтримувати більше двох режимів, зазвичай використовуються тільки режими ядра і користувача.

У режимі ядра дозволено все: ЦП може виконувати будь-яку інструкцію, що підтримується, і звертатися до будь-якої пам’яті. У режимі користувача дозволено лише певний набір інструкцій, введення/виведення (input/output, I/O) та доступ до пам’яті обмежені, багато параметрів ЦП заблоковані. Як правило, ядро ​​та драйвери запускаються в режимі ядра, а програми – у режимі користувача.

Процесори запускаються як ядра. Перед виконанням програми ядро ​​ініціалізує перемикання в режим користувача.

Приклад того, як режими процесора виявляються в реальній архітектурі: у x86-64 поточний рівень привілеїв (current privilege level, CPL) може читатися з регістру cs(code segment – ​​сегмент коду). Зокрема, CPL міститься у 2 найменших бітах регістру cs. Ці 2 біти можуть зберігати 4 можливі кільця x86-64: кільце 0 – це режим ядра, а кільце 3 – режим користувача. Кільця 1 та 2 призначені для запуску драйверів, але використовуються лише кількома нішевими ОС. Якщо, наприклад, бітами CPL є 11ЦП запускається в режимі користувача.

Системний виклик

Програми запускаються в режимі користувача, тому що не можна надавати повний доступ до комп’ютера. Режим користувача робить свою роботу, запобігаючи доступу до більшої частини комп’ютера, але програмам потрібно введення/виведення, пам’ять і можливість взаємодії з ОС! Для цього програмне забезпечення, запущене в режимі користувача, звертається за допомогою до ядра ОС. ОС потім реалізують власні заходи захисту.

Якщо ви писали код, що взаємодіє з ОС, повинні бути знайомі з функціями openreadforkі exit. Під кількома шарами абстракцій ці функції використовують системні виклики звернення до ОС за допомогою. Системний виклик – це спеціальна процедура, що дозволяє програмі запускати перехід із простору користувача до простору ядра, перестрибувати з коду програми до коду ОС.

Передача управління з простору користувача до простору ядра виконується за допомогою функцій процесора, які називаються програмними перериваннями (software interrupts):

  1. При запуску ОС зберігає “таблицю векторів переривань” (interrupt vector table, IVT) (x86-64 вона називається “таблицею дескрипторів переривань” (interrupt descriptor table)) в ОП і реєструє її за допомогою ЦП. IVT є таблицею зіставлення номера переривання (interrupt number) і покажчика оброблювача коду (handler code pointer).

  2. Потім програми користувача можуть використовувати такі інструкції, як INT , що вказують процесору знайти заданий номер переривання в IVT, переключитися в режим ядра і перемістити вказівник інструкції на адресу пам’яті, вказаний в IVT.

Після завершення цього коду, ядро ​​вказує ЦП перейти назад в режим користувача і повернути покажчик інструкції в місце, де відбулося переривання. Це робиться за допомогою таких інструкцій, як IRET .

Якщо вам цікаво, то ідентифікатором переривання для системних викликів на Linux є 0x80. Список системних викликів Linux можна знайти тут .

Інтерфейси оболонки: абстрагування переривань

Ось що ми дізналися про системні виклики:

  • програми, запущені в режимі користувача, не мають доступу до вводу/виводу або пам’яті. Їм доводиться звертатися до ОС за допомогою у взаємодії із зовнішнім світом;

  • програми можуть передавати керування ОС за допомогою спеціальних інструкцій, що містять машинний код, таких як INT та IRET;

  • програми не можуть перемикати рівні привілеїв безпосередньо. Програмні переривання є безпечними, оскільки процесор попередньо налаштовується ОС щодо того, якого коду ОС слід звертатися. Векторна таблиця переривань може налаштовуватися лише у режимі ядра.

Програми повинні передавати дані ОС під час системного виклику. ОС повинна знати не тільки те, який системний виклик виконувати, але також мати дані, необхідні для його виконання, такі як назва файлу (filename). Механізм передачі залежить від ОС і архітектури, але, зазвичай, це робиться у вигляді приміщення даних у певні регістри чи стек (stack) перед запуском переривання.

Розбіжності у тому, як системні виклики викликаються різних пристроях означає, що реалізація системних викликів кожної програми розробниками є, щонайменше, непрактичною. Це також означало б неможливість зміни ОС своєї обробки переривань, щоб уникнути поломки програм, розрахованих використання старих систем. Нарешті, ми більше пишемо програми мовою асемблера – не можна очікувати від програмістів переходу на assembly при кожному читанні файлу чи виділення пам’яті.

Тому ОС надають шар абстракції поверх цих переривань. Високорівневі функції, що “перевикористовуються”, обертають необхідні інструкції на мові асемблера, надаються libc в Unix-подібних системах і частиною бібліотеки під назвою ntdll в Windows. Простий виклик цих функцій не тягне за собою перемикання в режим ядра. Усередині бібліотек код assembly передає керування ядру. Цей код набагато більше залежить від платформи, ніж бібліотечна обгортка.

Коли ми викликаємо exit(1)з C, запущеного на Unix, ця функція виконує машинний код для запуску переривання після розміщення коду операції системного виклику та аргументів у правильний регістр/стек/що завгодно. Комп’ютери такі кльові!

Жага швидкості / CISC

Багато архітектур CISC , такі як x86-64, містять інструкції, призначені для системних викликів, створені через переважання парадигми системних викликів.

Intel та AMD не дуже добре координували свої дії при роботі над x86-64. Тому ми маємо 2 набори оптимізованих інструкцій системних викликів. SYSCALL та SYSENTER є оптимізованими альтернативами таких інструкцій, як INT 0x80. Їх інструкції повернення, SYSRET і SYSEXIT , призначені для швидкого зворотного переходу в простір користувача та продовження виконання програмного коду.

Процесори AMD та Intel мають трохи різну сумісність із цими інструкціями. SYSCALLЯк правило, краще підходить для 64-бітових програм, а SYSENTERкраще підтримується 32-бітними програмами.

Архітектури RISC не мають таких спеціальних інструкцій. AArch64, архітектура RISC, яка застосовується в Apple Silicon, використовує лише одну інструкцію переривання для системних викликів та програмних переривань.

Ось що ми дізналися у цьому розділі:

  • процесори виконують інструкції в нескінченному циклі вибірки-виконання і не мають ні найменшого поняття про ОС чи програми. Режим процесора, який зазвичай зберігається в регістрі, визначає, які інструкції можуть виконуватися. Код операційної системи виконується в режимі ядра і перемикається на режим користувача для запуску програм;

  • для запуску виконуваного файлу ОС перемикається в режим користувача та повідомляє процесору вхідну точку коду в ОП. Оскільки програми мають низькі привілеї, вони змушені звертатися за допомогою до коду ОС для взаємодії із зовнішнім світом. Системні виклики – це стандартизований спосіб перемикання програм з режиму користувача в режим ядра та код ОС;

  • програми зазвичай використовують ці системні виклики шляхом виклику функцій спеціальних бібліотек. Ці функції є обгортками над машинним кодом для програмних переривань або специфічних для архітектури інструкцій системних викликів, які передають керування ядру ОС та перемикають кільця безпеки. Ядро робить свою справу, перемикається назад у режим користувача та повертається до коду програми.

Повернемося до питання, що якщо ЦП не відстежує більше одного процесу і просто виконує одну інструкцію за іншою, чому він не застрягне в запущеній програмі. Як кілька програм можуть працювати одночасно? Вся справа у таймерах.

2. Нарізка часу

Припустимо, що ми розробляємо ОС і хочемо, щоб користувачі могли одночасно запускати кілька програм. У нас немає модного багатоядерного процесора, наш ЦП може виконувати лише одну інструкцію за один раз.

На щастя, ми дуже розумні розробники ОС. Ми з’ясували, що конкурентність може імітуватися шляхом виконання процесів по черзі. Якщо ми циклічно перебираємо процеси і виконуємо кілька інструкцій з кожного, всі процеси будуть чуйними і жоден з них не завантажить ЦП повністю.

Як повернути керування з програмного коду? Після невеликого дослідження з’ясовується, більшість комп’ютерів містять мікросхеми (чіпи) таймерів (timer chips). Ми можемо запрограмувати чіп таймера для перемикання на обробник переривань ОС після певного часу.

Програмні переривання

Раніше ми розглянули, як програмні переривання використовуються для передачі управління від програми користувача до ОС. Вони називаються “програмними”, оскільки довільно запускаються програмою – машинний код, що виконується процесором у нормальному циклі вибірки-виконання, вказує йому передати управління ядру.

Планувальники (schedulers) ОС використовують чіпи таймерів, такі як PIT для запуску програмних переривань з метою багатозадачності:

  1. Перед початком виконання коду програми, ОС встановлює чіп таймера для запуску переривання після закінчення певного часу.

  2. ОС перемикається в режим користувача та переходить до наступної інструкції програми.

  3. Коли таймер спрацьовує, ОС запускає програмне переривання для перемикання в режим ядра, і переходить до коду ОС.

  4. ОС запам’ятовує, де зупинилося виконання програми, завантажує іншу програму та повторює процес.

Це називається “витісняючою багатозадачністю” (preemptive multitasking); переривання процесу називається “витісненням” або “вивантаженням” (preemptive). Якщо ви, наприклад, читаєте цю статтю в браузері та слухаєте музику на тій же машині, ваш комп’ютер, ймовірно, виконує цей цикл тисячі разів на секунду.

Розрахунок часового інтервалу

Тимчасовий інтервал (timeslice) – це період, протягом якого планувальник ОС дозволяє процесу виконуватися до його витіснення. Найпростішим способом визначення часових інтервалів є виділення кожному процесу однакового інтервалу, наприклад, у межах 10 мс, та перебір завдань по порядку. Це називається “циклічним плануванням з фіксованим інтервалом часу” (fixed timeslice round-robin scheduling).

Ремарка: кумедні факти жаргону.

Тимчасові інтервали часто називають “квантами” (quantums).

Розробники ядра Linux використовують одиницю виміру jiffy для підрахунку тиків таймера (timer ticks) з фіксованою частотою. Окрім іншого, ця одиниця використовується для вимірювання довжини часового інтервалу. Частота jiffy зазвичай становить 1000 Гц, але може налаштовуватись при компіляції ядра.

Невеликим поліпшенням планування з фіксованими часовими інтервалами є вибір цільової затримки (target latency) – оптимального найбільшого періоду часу, необхідного для відповіді процесору. Цільова затримка – це час, необхідний процесору відновлення виконання коду після витіснення з урахуванням розумної кількості процесів.

Тимчасові інтервали обчислюються шляхом поділу цільової затримки кількість завдань. Такий підхід кращий за фіксовані інтервали, оскільки він усуває непотрібне перемикання з меншою кількістю процесів. З цільовою затримкою 15 мс і 10 процесами кожен процес отримує 15/10 або 1.5 мс для виконання. З 3 процесами кожен процес отримує 5 мс, а цільова затримка залишиться незмінною.

Перемикання між процесами є дорогим з погляду обчислень, оскільки вимагає збереження всього стану поточної програми та відновлення іншого стану. У певний момент занадто маленькі часові інтервали можуть призвести до проблем із продуктивністю із дуже швидким перемиканням між процесами. Поширеною практикою є встановлення нижнього порогу (minimum granularity – мінімального ступеня деталізації). Це означає, що цільова затримка перевищується, якщо є достатньо процесів, щоб мінімальний ступінь деталізації набув чинності.

На момент написання статті планувальник Linux використовує цільову затримку 6 мс і мінімальний ступінь деталізації 0,75 мс.

Циклічне планування з цим базовим розрахунком кванта часу близьке до того, що нині робить більшість комп’ютерів. Більшість ОС, як правило, мають складніші планувальники, які враховують пріоритети процесів та терміни (deadlines). Починаючи з 2007 р., Linux має планувальник під назвою Completely Fair Scheduler (повністю чесний планувальник). CFS робить багато дуже химерних речей з галузі комп’ютерних наук, щоб розставити пріоритети та розділити час ЦП.

При кожному витісненні процесу ОС повинна завантажити збережений контекст виконання нової програми, включаючи середовище пам’яті. Це досягається за рахунок використання ЦП іншої таблиці сторінок (page table) – зв’язки (відображення – mapping) між “віртуальними” та фізичними адресами. Це також запобігає доступу однієї програми до пам’яті іншої.

Примітка 1: вивантаження ядра

Досі ми говорили тільки про розвантаження та планування користувальницьких процесів. Код ядра може змусити програми “класти”, якщо обробка системного виклику або виконання коду драйвера відбуваються занадто довго.

Сучасні ядра, включаючи Linux, є . Це означає, що вони програмуються так, що код ядра може бути перерваний і запланований за аналогією з процесами користувача.

Це не дуже важливо, якщо ви не збираєтеся писати ядро ​​або щось таке, але знання ніколи не бувають зайвими.

Нотатка 2: урок історії

Стародавні ОС, включаючи класичну macOS і версії Windows задовго до NT, використовували попередника багатозадачності, що витісняє. Тоді не ОС вивантажувала програми, а програми “поступалися” (yield) ОС. Вони запускали програмне переривання, як би кажучи “Гей, ти можеш запустити іншу програму”. Ці явні поступки були єдиним для ОС способом відновлення контролю та перемикання до наступного процесу.

Це називається “спільною багатозадачністю” (cooperative multitasking). Основним недоліком такого підходу є те, що шкідливі або погано спроектовані програми можуть легко заморозити (freeze) всю ОС і майже неможливо забезпечити узгодженість завдань, що виконуються в реальному часі.

3. Запуск програми

Ми розглянули, як процесори виконують машинний код, що завантажується з файлів, що виконується, що таке безпеку на основі кілець і як працюють системні виклики. У цьому розділі ми глибоко поринемо в ядро ​​Linux для того, щоб з’ясувати, як програми завантажуються та виконуються.

Ми розглядатимемо Linux x86-64. Чому?

  • Linux – це повнофункціональна виробнича ОС для десктопного, мобільного та серверного середовищ. У Linux відкритий вихідний код, що уможливлює його вивчення у всіх подробицях;

  • x86-64 – це архітектура, яка використовується у більшості сучасних комп’ютерів.

Тим не менш, більшість з того, про що ми говоритимемо, поширюється на інші ОС та архітектури.

Стандартне виконання системного виклику

Почнемо з дуже важливого системного виклику execve. Він завантажує програму і якщо завантаження пройшло успішно, замінює поточний процес цією програмою. Існує кілька інших системних викликів ( execlpexecvpeта ін), але всі вони так чи інакше засновані на execve.

Ремарка: execveat.

execveнасправді побудований на основі execveat, більш загальному системному виклику, що запускає програму з деякими налаштуваннями. Для простоти ми будемо в основному говорити про execve, який є викликом execveatз налаштуваннями за замовчуванням.

Цікаво, що означає vevозначає, що одним параметром є вектор (список) аргументів ( argv), а eозначає, що іншим параметром є вектор змінних середовища ( envp). Інші системні виклики мають інші суфікси для позначення різних сигналів виклику. atв execveat– це просто “at”, що визначає локацію (location) для запуску execve.

Сигнатура виклику execveвиглядає так:

int execve(const char *filename, char *const argv[], char *const envp[]);
  • аргумент filenameвизначає шлях до програми, що запускається;

  • argv– це нулем (null-terminated), що завершується (останнім елементом є нульовий покажчик (null pointer)) список аргументів програми. Аргумент argc, який часто передається в основні функції C, фактично обчислюється пізніше системним викликом для нульового завершення;

  • аргумент envpмістить інший завершується нулем список змінних середовища оточення, які використовуються як контекст для програми. Вони… умовно (за згодою) є парами KEY=VALUE(ключ = значення). Умовно. Я обожнюю комп’ютери.

Смішний факт! Чи знаєте ви, що правило, згідно з яким першим аргументом має бути назва програми, це лише угода ? execveне виконує жодних перевірок щодо цього! Першим аргументом буде те, що передається execveяк перший елемент у списку argv, навіть якщо цей елемент не має нічого спільного з назвою програми.

Що цікаво, execveмістить деякий код, який передбачає, що argv[0]це назва програми. Ми детальніше поговоримо про це пізніше.

Крок 0. Визначення

Ми знаємо, як працюють системні виклики, але ми не розглядали приклади реального коду! Подивимося як визначається execveу вихідному коді ядра Linux:

// fs/exec.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/fs/exec.c#L2105-L2111
SYSCALL_DEFINE3(execve,
        const char __user *, filename,
        const char __user *const __user *, argv,
        const char __user *const __user *, envp)
{
    return do_execve(getname(filename), argv, envp);
}

SYSCALL_DEFINE3– це макрос (macro) визначення коду системного виклику з трьома аргументами.

Аргумент filenameпередається у функцію getname(), яка копіює рядок з простору користувача до простору ядра і виконує деякі дії щодо відстеження використання. Вона повертає структуру (struct) filename, визначену у include/linux/fs.h. Ця структура зберігає вказівник на оригінальний рядок в просторі користувача, а також новий покажчик на значення, скопійоване в простір ядра:

// include/linux/fs.h
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/include/linux/fs.h#L2294-L2300
struct filename {
    const char *name;	/* указатель на скопированную строку в пространстве ядра */
    const __user char	*uptr;	/* указатель на оригинальную строку в пространстве пользователя */
    int refcnt;
    struct audit_names *aname;
    const char iname[];
};

Потім execveвикликає функцію do_execve(). Це, у свою чергу, призводить до виклику функції do_execve_common()з дефолтними налаштуваннями. Системний виклик execveat, згадуваний раніше, також викликає do_execve_common(), але передає їй більше налаштувань користувача.

Нижче представлені визначення do_execve()та do_execveat():

// fs/exec.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/fs/exec.c#L2028-L2046
static int do_execve(struct filename *filename,
    const char __user *const __user *__argv,
    const char __user *const __user *__envp)
{
    struct user_arg_ptr argv = { .ptr.native = __argv };
    struct user_arg_ptr envp = { .ptr.native = __envp };
    return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

static int do_execveat(int fd, struct filename *filename,
        const char __user *const __user *__argv,
        const char __user *const __user *__envp,
        int flags)
{
    struct user_arg_ptr argv = { .ptr.native = __argv };
    struct user_arg_ptr envp = { .ptr.native = __envp };

    return do_execveat_common(fd, filename, argv, envp, flags);
}

У execveatфайловий дескриптор (тип ідентифікатора, що вказує на деякий ресурс) передається спочатку в системний виклик, а потім в do_execveat_common(). Це визначає директорію, стосовно якої виконується програма.

Для execveаргументу файлового дескриптора використовується спеціальне значення AT_FDCWD. Це загальна (розподілена – shared) константа ядра Linux, яка вказує функціям інтерпретувати назви шляхів стосовно поточної робочої директорії. Функції, які приймають файлові дескриптори, зазвичай містять явні перевірки типу if (fd == AT_FDCWD) { /* ... */ }.

Крок 1. Налаштування

Ми дісталися do_execveat_common()– головної функції обробки виконання програми.

Першим важливим завданням do_execveat_common()є встановлення структури під назвою linux_binprm. Ми не наводитимо повне визначення цієї структури , але ось кілька важливих зауважень:

  • такі структури даних, як mm_structі vm_area_structвизначено для підготовки управління віртуальною пам’яттю для нової програми;

  • argcі envcобчислюються та зберігаються для передачі в програму;

  • filenameі interpзберігають назву файлу програми та її інтерпретатора, відповідно. Спочатку вони рівні один одному, але в деяких випадках можуть змінюватися: одним із випадків, коли виконуваний двійковий файл відрізняється від назви програми, є запуск програм, що інтерпретуються, таких як скрипти Python , з шебангом (shebang). В даному випадку filenameбуде містити назву файлу, а interpшлях до інтерпретатора Python;

  • buf– Це масив, заповнений першими 256 байтами виконуваного файлу. Він використовується для визначення формату файлу та завантаження скриптів шебангів.

“binprm” розшифровується як “binary program” (двійкова програма, бінарник).

Придивимося до буфера buf:

// include/linux/binfmts.h
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/include/linux/binfmts.h#L64
char buf[BINPRM_BUF_SIZE];

Як бачимо, його довжина визначається через константу BINPRM_BUF_SIZE. Визначення цієї константи знаходиться в include/uapi/linux/binfmts.h

// include/uapi/linux/binfmts.h
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/include/uapi/linux/binfmts.h#L18-L19
/* sizeof(linux_binprm->buf) */
#define BINPRM_BUF_SIZE 256

Таким чином, ядро ​​завантажує 256 байт виконуваного файлу в буфер пам’яті.

Що таке UAPI?

Ви могли помітити, що шлях коду вище містить /uapi/. Чому розмір буфера не визначається в тому ж файлі include/linux/binfmts.h, в якому визначається структура linux_binprm?

UAPI розшифровується як “userspace API” (інтерфейс простору користувача). У цьому випадку це означає, що хтось вирішив, що довжина буфера має бути частиною публічного інтерфейсу ядра. Теоретично все UAPI є публічним, проте не UAPI є приватним для коду ядра.

Код ядра та простору користувача спочатку співіснували в одному місці. У 2012 р. код UAPI був переміщений в окрему директорію як спробу покращення підтримуваності коду.

Крок 2. Binfmt

Наступним важливим завданням ядра є перебір обробників (handlers) “binfmt” (binary format – двійковий формат). Ці обробники визначаються таких файлах, як fs/binfmt_elf.cі fs/binfmt_flat.cМодулі ядра також можуть додавати свої обробники binfmt до пулу (pool).

Кожен обробник надає функцію load_binary(), яка ” бере ” структуру linux_binprmі перевіряє, чи розуміє обробник формат програми.

Це часто включає пошук магічних чисел в буфері, спробу декодувати запуск програми (також з буфера) і/або перевірку розширення файлу. Якщо обробник підтримує формат, він готує програму для запуску та повертає код успіху (success code). Інакше відбувається ранній вихід (early exit) і повернення коду помилки (error code).

Ядро перебирає функції load_binary()кожного binfmt доти, доки досягне успіху. Іноді це відбувається рекурсивно. Наприклад, якщо скрипт має певний інтерпретатор, і сам інтерпретатор є скриптом, ієрархія може бути такою: binfmt_scriptbinfmt_scriptbinfmt_elf(де ELF – це формат, що виконується в кінці ланцюжка).

Скрипти

Зупинимось на binfmt_script.

Ви колись читали чи писали шебанг? Цей рядок на початку скрипту, що визначає шлях до інтерпретатора?

#!/bin/bash

Я завжди вважала, що це обробляється оболонкою, але ні! Насправді шебанги є “фічею” ядра, і скрипти виконуються за допомогою тих самих системних викликів, що інші програми. Комп’ютери такі кльові!

Погляньмо на те, як fs/binfmt_script.cперевіряє, чи починається файл із шебангу:

// fs/binfmt_script.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/fs/binfmt_script.c#L40-L42
/* Файл не выполняется, если не начинается с "#!". */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
    return -ENOEXEC;

Якщо файл починається з шебангу, обробник binfmt читає шлях інтерпретатора та будь-які розділені пробілами аргументи після шляху. Він зупиняється після досягнення нового рядка або кінця буфера.

Тут відбувається дві цікаві речі.

По-перше, пам’ятаєте буфер в linux_binprm, який заповнюється першими 256 байт файлу? Він використовується не тільки для визначення формату файлу, але з нього також читаються шебанги в binfmt_script.

У ході дослідження ми прочитали статтю, яка описує цей буфер завдовжки 128 байт. У якийсь момент після публікації цієї статті розмір буфера було збільшено до 256 байт! Чому? Перевірили логи редагування рядка коду, де визначається BINPRM_BUF_SIZEу вихідному коді Linux:

Оскільки шебанги обробляються ядром, і читання відбувається замість bufзавантаження всього файлу, вони завжди обрізаються до довжини buf. Ймовірно, 4 роки тому когось дуже дратувало, що його шляхи обрізаються до 128 байт, і вони вирішили подвоїти довжину. Сьогодні, якщо на Linux у вас є шебанг, довший за 256 символів, все, що слідує за ними, буде повністю втрачено.

Уявіть, що через це у вас стався баг. Уявіть, що ви намагаєтеся з’ясувати причину поломки коду. Уявіть, що ви відчуватимете, коли виявите, що причина лежить глибоко в ядрі Linux. Горе наступному розробнику великої компанії, який виявить, що частина дороги таємниче зникла.

По-друге, пам’ятайте, що argv[0]для назви програми – це лише угода, тому той, хто викликає, може передати будь-які argvв системний виклик, і вони пройдуть без модерації?

Так вийшло, що binfmt_scriptце одне з тих місць, де передбачається, що argv[0]це назва програми. argv[0]завжди видаляється, а замість нього (на початок argv) додається таке:

  • шлях інтерпретатора;

  • аргументи йому;

  • назва файлу скрипта.

Погляньмо на приклад виклику execve:

// аргументы: filename, argv, envp
execve("./script", [ "A", "B", "C" ], []);

Гіпотетичний файл scriptмістить такий шебанг на першому рядку:

#!/usr/bin/node --experimental-module

Модифікований argv, що передається в інтерпретатор Node.js , виглядатиме так:

[ "/usr/bin/node", "--experimental-module", "./script", "B", "C" ]

Після оновлення argvобробник завершує підготовку файлу для виконання шляхом встановлення linux_binprm.interpзначення шляху інтерпретатора. Нарешті, він повертає 0як індикатор успішної підготовки програми до виконання.

Різні інтерпретатори

Іншим цікавим обробником є binfmt_misc. Він дозволяє додавати деякі обмежені формати через налаштування простору користувача шляхом монтування спеціальної файлової системи в /proc/sys/fs/binfmt_misc/. Програми можуть виконувати спеціально відформатовані записи у файли, що знаходяться в цій директорії, для додавання власних обробників. Кожна конфігурація містить інформацію про те:

  • Як визначити формат файлу. Може визначатися магічне число певному відступі (offset) чи розширення файлу для пошуку;

  • шлях до виконуваного файлу інтепретатора. Не існує способу визначення аргументів інтепретатора, для цього необхідний скрипт-обгортка;

  • деякі прапори конфігурації, включаючи один, що визначає, як binfmt_miscоновлює argv.

Ця система binfmt_miscчасто використовується установками Java , налаштованими на виявлення файлів класів за їхніми магічними байтами 0xCAFEBABEта файлами JAR з їхнього розширення. У моїй системі обробник налаштований на виявлення байткоду Python з розширення .pycта його передачу відповідному обробнику.

Це дозволяє установникам програм додавати підтримку своїх форматів без написання високопривілейованого коду ядра.

На завершення

Системний виклик завжди завершується однією із двох речей:

  • врешті-решт досягається виконуваний двійковий формат, який він розуміє, і код виконується. І тут відбувається заміна старого коду;

  • або ж після вичерпання всіх можливостей у програму, що викликається, повертається код помилки.

Якщо ви користувалися системою Unix, могли помітити, що скрипти оболонки виконуються навіть за відсутності рядка шебанга чи розширення .sh.

$ echo "echo hello" > ./file
$ chmod +x ./file
$ ./file
hello

chmod +xповідомляє ОС, що файл виконується. Інакше файл виконати не вийде.

То чому скрипт оболонки виконується як скрипт оболонки? Обробники ядра не повинні визначати скрипти оболонки без явних міток!

Насправді, така поведінка не є частиною ядра. Це типовий метод обробки помилок оболонкою.

Коли ми виконуємо файл за допомогою оболонки і системний виклик провалюється, більшість оболонок повторюють виконання файлу як скрипт оболонки шляхом виклику оболонки з назвою файлу як перший аргумент. Bash, як правило, використовує себе як інтерпретатор, а ZSH зазвичай використовує оболонку Борна .

Така поведінка є стандартною, оскільки вона визначена в POSIX – старому стандарті, розробленому для забезпечення можливості перенесення коду між різними системами Unix. Незважаючи на те, що POSIX не повною мірою дотримується більшістю інструментів та ОС, багато його угод є загальноприйнятими.

Якщо [системний виклик] провалюється через помилку, аналогічну помилці [ENOEXEC]оболонка повинна виконати аналогічну команду з назвою команди в якості першого операнда та іншими аргументами, що передаються в нову оболонку. Якщо файл, що виконується, не є текстовим, оболонка може пропустити виконання цієї команди. У цьому випадку оболонка повинна записати повідомлення про помилку та повернути статус виходу 126.

4. Elf

Тепер ми непогано розуміємось на execve. У більшості випадків ядро ​​досягає програми, що містить машинний код для запуску. Як правило, запуску коду передує деякий процес налаштування, наприклад різні частини програми повинні завантажуватися в правильні місця в пам’яті. Кожна програма потребує різного об’єму пам’яті для різних речей, тому ми маємо стандартні формати файлів для визначення налаштувань програми для виконання. Хоча Linux підтримує багато форматів, найпоширенішим є ELF (executable and linkable format – виконуваний та пов’язаний формат).

Коли ми запускаємо програму або програму командного рядка на Linux, ймовірно, що запускається двійковий файл ELF. Однак у macOS фактичним форматом є Mach-O . Mach-O робить те саме, що ELF, але має іншу структуру. У Windows файли .exeмають формат Portable Executable , який слідує тій же концепції.

У ядрі Linux “бінарники” ELF обробляються обробником binfmt_elf, який є складнішим, ніж більшість інших обробників, і містить тисячі рядків коду. Він відповідає за розбір (парсинг) певних деталей із файлу ELF та використання їх для завантаження процесу в пам’ять та його виконання.

$ wc -l binfmt_* | sort -nr | sed 1d
    2181 binfmt_elf.c
    1658 binfmt_elf_fdpic.c
     944 binfmt_flat.c
     836 binfmt_misc.c
     158 binfmt_script.c
      64 binfmt_elf_test.c

Структура файлу

Перед розглядом того, як binfmt_elfвиконує файли ELF, погляньмо на сам формат файлу. Файли ELF, як правило, складаються з 4 частин:

ELF Header

Кожен файл ELF має заголовок . У нього дуже важлива роль – передача такої базової інформації про бінарника, як:

  • для якого процесора він призначений. Файли ELF можуть містити машинний код різних типів процесора, таких як ARM і x86;

  • чи передбачається запуск бінарника як виконуваного файлу чи його завантаження іншою програмою як ” динамічно пов’язаної бібліотеки ” . Ми поговоримо про динамічне зв’язування пізніше;

  • вхідна точка виконуваного файлу. Визначається, куди у пам’яті завантажувати дані з файлу ELF. Вхідна точка – це адреса пам’яті, що вказує, де в пам’яті знаходиться перша інструкція машинного коду після завантаження всього процесу.

Заголовок ELF завжди знаходиться на початку файлу. Він визначає локації таблиці заголовків програми (program header table) та таблиці заголовків розділів (section header table), які можуть бути у будь-якому місці всередині файла. Ці таблиці, своєю чергою, вказують на дані, що зберігаються десь у файлі.

Таблиця заголовків програми

Таблиця заголовків програми – це серія записів (entries), що містять деталі завантаження та запуску бінарника під час виконання (runtime). Кожен запис має поле type(тип), повідомляє, які деталі вона визначає: наприклад, PT_LOADозначає, що запис містить дані, які мають бути завантажені в пам’ять, а PT_NOTEозначає сегмент, що містить інформаційний текст, який може нікуди не завантажуватися.

Кожен запис містить інформацію про те, де знаходяться дані у файлі, і іноді про те, як завантажити дані в пам’ять:

  • вона вказує на позицію даних у файлі ELF;

  • вона може визначати адресу пам’яті, куди мають бути завантажені дані. Якщо дані не повинні завантажуватись у пам’ять, цей сегмент залишається порожнім;

  • 2 поля визначають довжину даних: одне для довжини даних у пам’яті, інше для довжини області пам’яті для створення. Якщо довжина пам’яті перевищує довжину файлу, додаткова пам’ять заповнюється нулями. Це дозволяє програмам резервувати статичну пам’ять для використання під час виконання. Ці порожні сегменти пам’яті зазвичай називаються сегментами BSS ;

  • нарешті, поле flagsвизначає, які операції дозволяються при завантаженні в пам’ять: PF_Rробить дані доступними тільки для читання PF_W– для запису PF_X– для виконання в ЦП.

Таблиця заголовків розділів

Таблиця заголовків розділів – це серія записів, які містять інформацію про розділи . Інформація про розділ – це як картка, яка відображає дані всередині файлу ELF. Це полегшує розуміння гаданого використання елементів даних програмами на кшталт відладчиків .

Наприклад, таблиця заголовків програми може визначати великий набір даних для завантаження пам’ять цілком. Один блок PT_LOADможе містити як код, і глобальні змінні! Для запуску програми їхнє окреме визначення не потрібно. ЦП починає з вхідної точки і рухається вперед, виймаючи дані про запити програми. Однак, ПЗ типу відладчика для аналізу програми має точно знати, де починається і закінчується кожна частина даних, інакше, воно може спробувати декодувати рядок “hello” як код (і оскільки рядок не є валідним кодом, “вибухнути”). Така інформація зберігається у таблиці заголовків розділів.

Хоча таблиця заголовків розділів зазвичай включається, вона насправді опціональна. Файли ELF можуть чудово виконуватись при повному видаленні цієї таблиці. Розробники, які хочуть сховати свій код, іноді навмисно видаляють або спотворюють її в бінарниках ELF, щоб ускладнити їхнє декодування .

Кожен розділ містить назву (name), тип (type) та деякі прапори, які визначають, як дані повинні використовуватись та декодуватися. Стандартні назви зазвичай починаються з точки за згодою. Найбільш поширеними розділами є:

  • .text– машинний код для завантаження в пам’ять та виконання в ЦП. Тип SHT_PROGBITSіз прапором SHF_EXECINSTRпозначає його як виконуваний. Прапор SHF_ALLOCозначає завантаження у пам’ять для виконання;

  • .data– ініціалізовані дані, жорстко закодовані у файлі для завантаження в пам’ять. У цій секції може бути, наприклад, глобальна змінна, що містить деякий текст. Якщо ви пишете низькорівневий код, це розділ, де “живе” статика. Тип SHT_PROGBITSпросто означає, що розділ містить інформацію для програми. Прапори SHF_ALLOCта SHF_WRITEпозначають його як доступну для запису пам’ять;

  • .bss– Зазвичай частина виділеної пам’яті починається з нуля. Поміщати купу порожніх байтів у файл ELF нерозумно, для цього призначений розділ BSS. Про сегменти BSS корисно знати у процесі налагодження. У таблиці заголовків розділів є запис, що визначає довжину пам’яті виділення. Типом тут є SHT_NOBITS, а прапорами – SHF_ALLOCі SHF_WRITE;

  • .rodata– Це ті ж .data, але доступні тільки для читання. У найпростішій програмі мовою C, що запускає printf("Hello, world!"), рядок “Hello, world!” перебуватиме у розділі .rodata, а сам код відображення (printing code) – у розділі .text;

  • .shstrtab– це кумедний факт реалізації! Самі назви розділів (такі як .textі .shtrtab) не включаються до таблиці заголовків розділів. Кожен запис містить відступ (offset) до локації у файлі ELF, що містить її назву. Це полегшує парсинг записів таблиці завдяки їхньому фіксованому розміру (відступ – це число фіксованого розміру, а для включення до таблиці назви потрібен рядок змінного розміру). Всі ці дані про назви зберігаються в окремому розділі .shstrtabз типом SHT_STRTAB.

Дані

Програма і записи таблиці розділів заголовків вказують на блоки даних усередині файлу ELF, чи завантажити їх у пам’ять, чи визначити місцезнаходження коду, чи просто називати розділи. Всі ці різні частини даних містяться в розділі “Data” файлу ELF.

Коротке пояснення зв’язування

Повернемося до коду binfmt_elf: ядро ​​піклується про два типи записів із таблиці заголовків програми.

Сегменти PT_LOADвизначають, куди повинні завантажуватися всі ці програми, такі як розділи .textта .data. Ядро читає ці сутності з ELF файлу для завантаження даних в пам’ять, щоб програма могла бути виконана ЦП.

Іншим типом записів таблиці заголовків програми, про яке піклується ядро, є PT_INTERP, визначальним “час виконання динамічного компонування” (dynamic linking runtime).

Почнемо з того, що таке “зв’язування” чи “компонування” (linking). Програмісти, як правило, пишуть програми поверх бібліотек повторно використовуваного коду, наприклад, libcпро яку згадувалося раніше. При перетворенні вихідного коду в бінарник, що виконується, програма викликає компонувальник (linker), що дозволяє всі посилання, визначаючи код бібліотеки і копіюючи його в бінарник. Цей процес називається “статичним зв’язуванням” (static linking) – зовнішній код включається прямо в файл, що розповсюджується.

Деякі бібліотеки дуже популярні. Та ж libcвикористовується майже всіма програмами, оскільки вона є канонічний інтерфейс для взаємодії з ОС через системні виклики. Що робити? Включити окрему копію libcдо кожної програми на комп’ютері? Що якщо libcз’явився баг? Чи доведеться оновлювати всі програми? Ці проблеми вирішуються за рахунок динамічного компонування.

Якщо статично пов’язана програма потребує функції foo()з бібліотеки bar, копія цієї функції включається до програми. Однак, при динамічному зв’язуванні в програму включається тільки посилання, що ніби говорить потрібна foo()з bar“. При запуску програми (за умови, що barінстальовано на комп’ютері) машинний код функції foo()завантажується в пам’ять на вимогу (on-demand). При оновленні установки barновий код завантажується в програму при її наступному запуску без необхідності зміни самої програми.

Динамічна компоновка в дикій природі

У Linux динамічно пов’язані бібліотеки bar, як правило, упаковуються в файли з розширенням .so(shared object – загальний об’єкт). Ці файли .soє такими ж файлами ELF, як програми. Заголовок ELF містить поле, яке визначає, чим є файл, виконуваним файлом або бібліотекою. Крім того, загальні об’єкти містять розділ .dynsymу таблиці заголовків розділів, в якому міститься інформація про те, які символи експортуються з файлу і можуть бути динамічно пов’язані.

У Windows бібліотеки начебто barупаковуються у файли з розширенням .dll(dynamic link library – бібліотека, що динамічно підключається). У macOS використовується розширення .dylib(dynamically linked library – динамічно підключена бібліотека). Також як macOS та файли .exeWindows, ці формати трохи відрізняються від файлів ELF, але дотримуються тієї ж концепції та техніки.

Цікавою відмінністю двох типів зв’язування є те, що при статичному зв’язуванні виконуваний файл включається (завантажується в пам’ять) тільки використовується частина бібліотеки. У разі динамічного зв’язування на згадку завантажується вся бібліотека. На перший погляд це здається менш ефективним, але насправді це дозволяє сучасним ОС зберігати більше простору шляхом одноразового завантаження бібліотеки в пам’ять та розподілу коду між процесами. Спільно використовуватися може лише код, оскільки бібліотеці потрібен різний стан для різних програм, але економія все одно може становити близько десятків та сотень МБ ОЗУ.

Виконання

Повернемося до ядра, що виконує файли ELF: якщо бінарник, що виконується, є динамічно пов’язаним, ОС не може відразу перейти до його коду, оскільки в наявності є не весь код – динамічно пов’язані програми містять тільки посилання на функції необхідних їм бібліотек!

Для запуску бінарника ОС необхідно визначити необхідні бібліотеки, завантажити їх, замінити всі іменовані покажчики реальними інструкціями переходу і потім запустити повний код програми. Це дуже складний код, що глибоко взаємодіє з форматом ELF, тому він зазвичай є окремою програмою, а не частиною ядра. Файли ELF визначають шлях до програми, яку хочуть використовувати (щось типу /lib64/ld-linux-x86-64.so.2) у записи PT_INTERPтаблиці заголовків програми.

Після читання заголовка ELF та сканування таблиці заголовків програми ядро ​​налаштовує структуру пам’яті для програми. Все починається із завантаження в пам’ять всіх сегментів PT_LOAD, статичних даних, простору BSS та машинного коду програми. Якщо програма динамічно пов’язана, ядро ​​має виконати інтерпретатор ELF ( PT_INTERP), тому воно також завантажує в пам’ять дані, BSS і код інтерпретатора.

Тепер ядру необхідно встановити покажчик інструкції для ЦП для відновлення при поверненні в простір користувача. Якщо файл, що виконується, є динамічно пов’язаним, ядро ​​встановлює покажчик інструкції на початок коду інтерпретатора ELF в пам’яті. Інакше, ядро ​​встановлює його на початок виконуваного файлу.

Ядро майже готове до повернення із системного виклику (пам’ятайте, що ми, як і раніше, знаходимося в execve). Воно відправляє argcі argvзмінні середовища оточення в стек для читання програмою при запуску.

Після цього регістри очищаються. Перед обробкою системного виклику ядро ​​зберігає поточне значення регістрів у стеку для подальшого відновлення при перемиканні назад на простір користувача. Перед поверненням в простір користувача, ядро ​​заповнює цю частину стека нулями.

Нарешті, системний виклик завершується і ядро ​​повертається у простір користувача. Воно відновлює регістри, які тепер заповнені нулями, і переходить до збереженого покажчика інструкції. Цей покажчик є початковою точкою нової програми (або інтерпретатора ELF), і поточний процес замінюється новим!

5. Перетворювач

Досі всі наші розмови про читання та записи на згадку були трохи невиразними. Наприклад, файли ELF визначають конкретні адреси пам’яті для завантаження даних, то чому не виникає проблем із процесами, які намагаються використовувати однакові адреси? Чому здається, що кожен процес має доступ до різної пам’яті?

Ми розуміємо, що execveце системний виклик, який замінює поточний процес новою програмою, але це не пояснює, як кілька процесів можуть запускатися одночасно. Це також не пояснює, як запускається перша програма – процес, що породжує (spawn) інші процеси.

Пам’ять – це фікція

Виявляється, що коли ЦП читає або пише на адресу пам’яті, йдеться не про локацію у фізичній пам’яті (ОП). Це місце у просторі віртуальної пам’яті (virtual memory space).

ЦП “спілкується” з чіпом під назвою блок управління пам’яттю (memory management unit, MMU). MMU за допомогою словника перекладає локації у віртуальній пам’яті в локації в ОП. Коли ЦП отримує інструкцію з читання з адреси пам’яті 0xAD4DA83F, він звертається до MMU для перекладу адреси. MMU “дивиться” у словник, знаходить фізичну адресу, що збігається 0x70B7BD74, і повертає це число ЦП. Після цього ЦП читає із цієї адреси в ОП.

Після увімкнення комп’ютер працює з фізичною ВП. Відразу після цього ОС створює словник та вказує ЦП використовувати MMU.

Цей словник називається “таблицею сторінок” (page table), а система перекладу кожної адреси пам’яті називається “пейджингом” або “підкачуванням” (paging). Записи таблиці сторінок називаються сторінками (pages). Сторінка є зв’язком певної частини (chunk) віртуальної пам’яті з ОП. Ці частини завжди мають фіксований розмір, у кожної архітектури процесора він свій. Розмір сторінки x86-64 становить 4 КБ, тобто. кожна сторінка визначає зв’язок для блоку пам’яті довжиною 4096 байт (x86-64 дозволяє ОС мати великі сторінки розміром 2 МБ або 4 ГБ, що підвищує швидкість перетворення адрес, але збільшує фрагментацію і втрати пам’яті).

Сама таблиця сторінок просто лежить у ВП. Хоча вона може містити мільйони записів, розмір кожного запису складає всього пару байт, тому вона не займає багато місця.

Для того щоб увімкнути підкачку при завантаженні, ядро ​​спочатку створює таблицю сторінок в ОП. Потім воно зберігає фізичну адресу початку таблиці сторінок у регістрі під назвою “базовий регістр таблиці сторінок” (page table base register, PTBR). Нарешті, ядро ​​включає підкачування перетворення всіх адрес пам’яті з допомогою MMU. У x86-64 старші 20 біт регістра управління 3 (CR3) функціонують як PTBR. Біт 31 регістра CR0 (PG) встановлюється в 1 для дозволу підкачування.

Магія таблиці сторінок полягає у можливості її редагування у процесі роботи комп’ютера. Це дозволяє кожному процесу мати власний ізольований простір пам’яті, коли ОС перемикає контекст від одного процесу до іншого, важливо прив’язати простір віртуальної пам’яті до іншої сфери фізичної пам’яті. Припустимо, що ми маємо два процеси: код і дані (ймовірно, завантажені з файлу ELF) процесу А знаходяться в 0x0000000000400000, а процес Б отримує доступ до коду і даних за тією ж адресою. Ці два процеси навіть можуть бути екземплярами однієї програми, оскільки між ними не виникає конфліктів через пам’ять! У фізичній пам’яті дані процесу А лежать далеко від процесу Б, прив’язка до 0x0000000000400000виконується ядром при перемиканні процесів.

Ремарка: негативний факт щодо ELF.

У деяких ситуаціях binfmt_elfмає зіставити першу сторінку пам’яті із нулями. Деякі програми, написані для UNIX System V Release 4.0 (SVr4), ОС 1988, першою підтримує ELF, покладаються на читання нульових покажчиків. Якимось чудовим чином деякі програмісти все ще покладаються на таку поведінку.

Схоже, розробник ядра Linux, який реалізував це, був трохи незадоволений .

“Ви питаєте, навіщо??? Ну, SVr4 відображає сторінку як доступну тільки для читання і деякі додатки “залежать” від цієї поведінки. Оскільки у нас немає можливості їх перекомпілювати, ми змушені емулювати поведінку SVr4. Зітхання.”

Безпека підкачування

Ізоляція процесів, що забезпечується підкачуванням пам’яті, не тільки покращує ергономіку коду (процесам не потрібно турбуватися про використання пам’яті іншими процесами), але й створює рівень безпеки: одні процеси не мають доступу до інших процесів. Це половина відповіді на запитання:

Якщо програми виконуються прямо в ЦП, а ЦП має прямий доступ до ОП, чому код не може отримати доступ до пам’яті іншого процесу або, вибач, ядра?

Що щодо пам’яті ядра? Насправді ядру потрібно зберігати багато власних даних для відстеження всіх запущених процесів і тієї ж таблиці сторінок. При кожному перериванні або запуску системного виклику, коли ЦП входить у режим ядра, ядро ​​повинне якимось чином отримати доступ до пам’яті.

Рішення Linux у тому, щоб завжди виділяти верхню половину віртуальної пам’яті ядру, тому Linux називається ядром старшої половини (higher half kernel). Windows практикує схожу техніку, а macOS… трохи складніша (прим. пров.: зверніть увагу, що тут три різні посилання) .

З точки зору безпеки буде жахливим, якщо процеси користувача будуть мати доступ до пам’яті ядра, тому підкачування додає другий шар безпеки: кожна сторінка повинна визначати прапори дозволів (permission flags). Один прапор визначає, чи доступна область пам’яті тільки для читання або також для запису. Інший прапор повідомляє ЦП, що тільки ядро ​​має доступ до області. Цей прапор використовується для захисту всього простору старшої половини ядра – насправді програмам користувача є весь простір пам’яті ядра в карті віртуальної пам’яті, вони просто не мають відповідних дозволів.

Сама таблиця сторінок фактично міститься у просторі пам’яті ядра! Коли чіп таймера запускає апаратне переривання для перемикання процесів, ЦП перемикає рівень привілеїв та переходить до коду ядра Linux. Знаходження в режимі ядра (кільце 0 Intel) дозволяє ЦП звертатися до захищеної області пам’яті ядра. Потім ядро ​​може писати в таблицю сторінок (яка знаходиться десь у цій верхній половині пам’яті) для повторного прив’язування нижньої частини віртуальної пам’яті для нового процесу. Коли ядро ​​переключається до нового процесу і ЦП входить у режим користувача, його доступ до пам’яті ядра закривається.

Майже будь-який доступ до пам’яті відбувається через MMU. Що щодо покажчиків обробника таблиці дескрипторів переривань? Вони також звертаються до простору віртуальної пам’яті ядра.

Ієрархічна підкачка та інші оптимізації

64-бітові системи мають адреси пам’яті довжиною 64 біта, отже, 64-бітовий простір віртуальної пам’яті має розмір колосальних 16 ексбібайт . Це неймовірно багато, набагато більше, ніж може похвалитися будь-який сучасний комп’ютер чи комп’ютер найближчого майбутнього. Наскільки  відомо, найбільше ОП (більше 1,5 петабайт – менше 0,01% від 16 ЕБ) було у суперкомп’ютера Blue Waters .

Якщо запис у таблиці сторінок потрібен для кожного розділу віртуальної пам’яті розміром 4 КБ, потрібно 4503599627370496 таких записів. Із записами таблиці сторінок довжиною 8 байт потрібно 32 пебібайти ОП лише зберігання таблиці. Як бачите, це, як і раніше, перевищує світовий рекорд за обсягом ВП у комп’ютері.

Оскільки неможливо (або як мінімум дуже непрактично) мати послідовні записи таблиці сторінок для всього можливого простору пам’яті, архітектури ЦП реалізують ієрархічне розбиття по сторінках (ієрархічне підкачування – hierarchical paging). У таких системах існує кілька рівнів таблиць сторінок з дедалі дрібнішим ступенем деталізації. Записи верхнього рівня охоплюють великі блоки пам’яті і вказують таблиці сторінок менших блоків, створюючи деревоподібну структуру (tree). Окремі записи для блоків 4 КБ або іншого розміру є листям (leaves) дерева.

У x86-64 історично використовується чотирирівневе ієрархічне підкачування. У цій системі кожен запис у таблиці сторінок визначається шляхом зміщення початку таблиці, що містить її, на частину (portion) адреси. Ця частина починається зі старших бітів, які працюють як префікс, тому запис охоплює всі адреси, що починаються з цих бітів. Запис вказує початку наступного рівня таблиці, що містить піддерев’я цього блоку пам’яті, які знову індексуються наступним набором бітів.

Розробники чотирирівневої підкачки також вирішили ігнорувати старші 16 біт всіх віртуальних покажчиків з метою економії місця у таблиці сторінок. 48 біт дають нам віртуальний адресний простір розміром 128 ТБ, що вважається досить великим.

Оскільки перші 16 біт пропущені, “найзначніші біти” (most significant bits) для індексації першого рівня таблиці сторінок фактично починаються з 47-го, а не з 63-го біта. Це також означає, що наведена вище діаграма старшої половини ядра була технічно неточною: початкова адреса простору ядра повинна бути зображена як середина адресного простору розміром менше 64 біт.

Ієрархічна підкачка вирішує проблему з простором, оскільки на будь-якому рівні дерева покажчик на наступний запис може бути нульовим ( 0x0). Це дозволяє виключити цілі поддеревья таблиці сторінок – невідображувані (unmapped) області займають місце у ОП. Пошук по незв’язаних адресах пам’яті швидко завершується помилкою, оскільки ЦП повертає помилку, коли зустрічає порожній запис вище по дереву. За допомогою прапора присутності (presence flag) записи таблиці сторінок можуть бути позначені як непридатні для використання, навіть якщо адреса виглядає валідною.

Ще однією перевагою ієрархічного підкачування є можливість ефективного перемикання великих розділів віртуальної пам’яті. Велика ділянка віртуальної пам’яті може зіставлятися з однією областю фізичної пам’яті одного процесу і з іншою областю іншого процесу. Ядро може зберігати обидва відображення в пам’яті і просто оновлювати вказівники на верхньому рівні дерева під час перемикання процесів. Якби вся карта простору пам’яті зберігалася у вигляді плоского масиву записів, ядру довелося б оновлювати безліч записів, що було б повільним і вимагало незалежного відстеження відображень пам’яті для кожного процесу.

Я сказала, що x86-64 “історично” використовує чотирирівневе підкачування, тому що останні процесори реалізують п’ятирівневе підкачування . П’ятирівневе підкачування додає ще один рівень абстракції, а також 9 біт адресації для розширення адресного простору до 128 ПБ з 57-бітними адресами. Таке підкачування підтримується Linux з 2017 р. , а також останніми серверними версіями Windows 10 та 11.

Ремарка: обмеження адресного фізичного простору.

ОС не використовують усі 64 біти віртуальних адрес, а ЦП не використовують усі 64 біти фізичних адрес. Коли 4-рівневе підкачування було стандартом, процесори x86-64 не використовували більше 46 біт. Це означало, що фізичний адресний простір було обмежено лише 64 ТБ. Процесори з 5-рівневим підкачуванням використовують до 52 біт, підтримуючи фізичний адресний простір до 4 ПБ.

На рівні ОС вигідно, щоб віртуальний простір був більшим за фізичний. Як сказав Лінус Торвальдс: “[віртуальний простір має бути більшим за фізичний], принаймні в 2 рази, а краще в 10 і більше. Хто цього не розуміє, той дурень. Кінець дискусії”.

Обмін та підкачування на вимогу

Доступ до пам’яті може завершитися помилкою з кількох причин: адреса може знаходитися за межами допустимого діапазону, вона може не відображатися в таблиці сторінок або може містити запис, позначений як відсутній. У всіх цих випадках MMU ініціює апаратне переривання, яке називається “відмовою сторінки” (page fault), щоб передати керування ядру.

Якщо читання дійсно було невалідним або забороненим, ядро ​​завершує програму з помилкою сегментації (segmentation fault, segfault):

$ ./program
Segmentation fault (core dumped)

Ремарка: походження segfault.

“Помилка сегментації” означає різні речі, залежно від контексту. MMU запускає апаратне переривання, яке називається “помилкою сегментації”, коли відбувається читання пам’яті без дозволу, але “помилка сегментації” – це також назва сигналу, який ОС посилає працюючій програмі з метою її примусового завершення у зв’язку з нелегальним доступом до пам’яті.

В інших випадках доступ до пам’яті може бути “навмисно” невдалим, що дозволяє ОС заповнити пам’ять і передати управління ЦП для повторної спроби. Наприклад, ОС може зіставити файл на диску з віртуальною пам’яттю, не завантажуючи його в ОП, і зробити таке завантаження лише за запиті адреси та виникненні помилки сторінки (page fault). Це називається “підкачування на вимогу” (demand paging).

Це дозволяє існувати таким системним викликам, як mmap , які ліниво (відкладено – lazily) відображають цілі файли з диска у віртуальну пам’ять. Джастін Танні нещодавно значно оптимізував LLaMa.cpp, середовище виконання мовної моделі Facebook, змусивши всю логіку завантаження використовувати mmap .

При виконанні програми та її бібліотек ядро ​​фактично нічого не завантажує на згадку. Створюється лише файл mmap – коли ЦП намагається виконати код, негайно виникає помилка сторінки, і ядро ​​замінює сторінку на реальний блок пам’яті.

Підкачування на вимогу також дозволяє використовувати метод, відомий під назвою “обмін” (swapping) або просто “підкачування”. ОС можуть звільняти фізичну пам’ять, записуючи сторінки пам’яті на диск, а потім видаляючи їх з фізичної пам’яті, але зберігаючи їх у віртуальній пам’яті з прапором присутності, встановленим у 0. При читанні цієї віртуальної пам’яті ОС відновлює пам’ять з диска в ОП і встановлює прапор назад у 1. У цьому, ОС може змінювати місцями розділи ОЗУ, щоб звільнити місце пам’яті, завантажуваної з диска. Операції читання та запису на диск є повільними, тому ОС намагаються звести підкачування до мінімуму за допомогою ефективних алгоритмів заміни сторінок .

Цікавим прийомом є використання покажчиків на фізичну пам’ять таблиці сторінок зберігання локацій файлів у фізичної пам’яті. Оскільки MMU повертає помилку сторінки, як тільки побачить негативний прапор присутності, валідною є адреса чи ні, значення не має.

6. Виделки та корови

Прим. пер.: автор грає зі словами “fork” та “cow”.

Останнє питання: звідки береться перший процес?

Якщо execveзапускає нову програму, замінюючи поточний процес, то як запустити нову програму окремо у новому процесі? Це важливо, якщо ми хочемо робити кілька речей на комп’ютері одночасно: коли ми двічі клацаємо по іконці програми, вона запускається окремо, тоді як програма, в якій ми працювали раніше, продовжує працювати.

Відповіддю є інший системний виклик: fork, що лежить в основі багатозадачності. forkдуже простий: він клонує (копіює) поточний процес та його пам’ять, не чіпаючи збережений покажчик інструкції, а потім дозволяє обом процесам продовжуватися. Програми продовжують працювати незалежно одна від одної, а всі обчислення подвоюються.

Новий запущений процес називається “дочірнім” (child), а клонований – “батьківським” (parent). Процеси можуть викликати forkкілька разів, породжуючи багато дітей (нащадків). Кожен нащадок нумерується за допомогою ідентифікатора процесу (процес ID, PID), який починається з 1.

Бездумне дублювання коду досить марне, тому forkповертає різні значення для батьківського та дочірнього процесів. Для предка він повертає PID нового дочірнього процесу, а нащадка – 0. Це дозволяє виконувати іншу роботу у новому процесі, отже розгалуження (forking) справді корисно.

pid_t pid = fork();

// З цієї точки код продовжується як завжди, але через 
// два "однакових" процесу.
//
// Однакових... за винятком PID, що повертається з `fork()`!
//
// Це єдиний індикатор того, що ми маємо справу
// з різними програмами.

if (pid == 0) {
    // Ми перебуваємо у дочірньому процесі.
    // Проводимо деякі обчислення та передаємо результат предку!
} else {
    // Ми у батьківському процесі.
    // Продовжуємо робити те, що робили.
}

Розгалуження процесу може бути складним для розуміння. Вважатимемо, що ви знаєте, як воно працює. Якщо ні, то загляньте на цей непривабливий сайт із гарним поясненням.

Таким чином, програми Unix запускають нові програми, викликаючи forkта запускаючи execveу новому дочірньому процесі. Це називається “шаблоном fork-exec” (fork-exec pattern). При запуску програми наш комп’ютер виконує такий код:

pid_t pid = fork();

if (pid == 0) {
    // Замінюємо дочірній процес на нову програму.
    execve(...);
}

// Якщо ми потрапили сюди, отже, процес було замінено. Ми перебуваємо в батьківському процесі! 
// PID дочірнього процесу зберігається у змінній `pid` на випадок, 
// якщо ми захочемо його вбити.

// Тут триває код батьківської програми.

Ви могли помітити, що дублювання пам’яті процесу лише для того, щоб її відкинути під час завантаження іншої програми, звучить трохи неефективно. Дублювання даних у фізичній пам’яті – це повільний процес, але не дублювання таблиць сторінок, тому ми просто не дублюємо ОП: ми створюємо копію таблиці сторінок старого процесу для нового процесу – відображення вказує на ту саму нижче фізичну пам’ять.

Але дочірній процес має бути незалежним та ізольованим від батьківського! Недобре, якщо нащадок писатиме на згадку пращура, і навпаки!

Уявляю вам COW (copy on write – копіювання під час запису, cow – корова). COW дозволяє обом процесам читати з тих самих фізичних адрес до того часу, поки вони намагаються здійснити запис на згадку. Як тільки процес намагається це зробити, ця сторінка копіюється в ОП. Це дозволяє обом процесам мати ізольовану пам’ять без клонування всього простору пам’яті. Це зумовлює ефективність патерну fork-exec. Оскільки жоден із старих процесів не пише на згадку до завантаження нового бінарника, пам’ять не копіюється.

COW реалізована, як і багато інших кумедних речей, через хакі сторінок (page hacks) та обробку апаратного переривання. Після клонування сторінок за допомогою fork, він позначає (встановлює відповідні прапори) сторінки обох процесів як доступні лише читання. Це запускає segfault (різновид апаратного переривання), яке обробляється ядром. Ядро, що дублює пам’ять, оновлює сторінку для дозволу запису та повертається з переривання для повторної спроби запису.

На початку

Кожен процес на комп’ютері є результатом клонування-виконання (fork-execed) батьківської програми, крім одного: процесу ініціалізації (init process). Процес ініціалізації встановлюється вручну ядром. Це перша програма користувача при запуску і остання при завершенні.

Хочете побачити клювання миттєвий чорний екран? Якщо у вас є macOS або Linux, збережіть свою роботу, відкрийте термінал і вбийте процес ініціалізації (PID 1):

$ sudo kill 1

Ремарка: знання про процес ініціалізації, на жаль, застосовні лише до Unix-подібних систем, на зразок macOS або Linux. Більшість того, що ви вивчите далі, не застосовується до Windows, архітектура ядра якої дуже відрізняється.

Процес ініціалізації відповідає за створення всіх програм та служб, що становлять ОС. Багато з них, у свою чергу, створюють власні послуги та програми.

Завершення процесу ініціалізації завершує всіх його нащадків та їх нащадків, що призводить до завершення роботи ОС.

Повертаємося до ядра

Розглянемо, як ядро ​​запускає процес ініціалізації.

Комп’ютер виконує послідовність наступних речей:

  1. Материнська плата поставляється з невеликим програмним забезпеченням, що виконує пошук програми, яка називається “завантажувачем” (bootloader) у підключених дисках. Вона вибирає завантажувач, завантажує його машинний код у ВП та виконує цей код. Пам’ятайте, що ОС ще не запущено. До запуску процесу ініціалізації багатозадачності та системних викликів не існує. У контексті, що передує ініціалізації, “виконання” програми означає прямий перехід до машинного коду в ОП без очікування повернення.

  2. Завантажувач відповідає за виявлення ядра, його завантаження в ВП та виконання. Деякі завантажувачі, такі як GRUB , можна налаштовувати та/або вибирати між кількома ОС. Вбудованими завантажувачами macOS і Windows є BootX і Windows Boot Manager відповідно.

  3. Ядро запускається і приступає до виконання великої кількості завдань ініціалізації, включаючи встановлення обробників переривань, завантаження драйверів і створення початкового відображення пам’яті. Нарешті, ядро ​​переходить у режим користувача і запускає програму ініціалізації.

  4. Тепер ми знаходимося у просторі користувача ОС! Програма ініціалізації виконує скрипти ініціалізації, запускає сервіси та виконує програми на кшталт оболонки/UI (user interface – інтерфейс користувача).

Ініціалізація Linux

У Linux ініціалізація ядра (крок 3) відбувається у функції start_kernelinit /main.c . Ця функція викликає більше 200 інших функцій ініціалізації, тому не будемо включати до статті весь код , але рекомендуемо з ним ознайомитись. Наприкінці start_kernelвикликається функція arch_call_rest_init:

// init/main.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/init/main.c#L1087-L1088
/* Робимо інше без "__init", тепер ми живі */
    arch_call_rest_init();

Ремарка: що означає без __init?

Функція start_kernelвизначається як asmlinkage __visible void __init __no_sanitize_address start_kernel(void). Дивні слова __visibleі __initє __no_sanitize_addressмакросами препроцесора C і використовуються ядром Linux для додавання різного коду і поведінки в функцію.

В даному випадку, __initце макрос, що вказує ядру вивантажити функцію та її дані з пам’яті після завершення процесу запуску для збереження простору.

Як це працює? Якщо не занурюватися занадто глибоко в деталі, саме ядро ​​Linux упаковане як ELF. Макрос __initперетворюється на __section(".init.text")директиву компілятора для розміщення коду в розділ .init.textзамість звичайного розділу .text. Інші макроси також дозволяють даним і константам поміщатися в спеціальні розділи, наприклад, __initdataперетворюється на __section(".init.data").

arch_call_rest_init– це лише функція-

// init/main.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/init/main.c#L832-L835
void __init __weak arch_call_rest_init(void)
{
    rest_init();
}

У коментарі йдеться “робимо інше без __init”, оскільки rest_initне визначається за допомогою макросу __init. Це означає, що вона не вивантажується для очищення пам’яті:

// init/main.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/init/main.c#L689-L690
noinline void __ref rest_init(void)
{

Далі rest_initстворює потік (thread) для процесу ініціалізації:

/*
* Спочатку нам потрібно створити процес, щоб він отримав pid 1, проте 
* завдання ініціалізації, зрештою, захоче створити kthreads, які, 
* якщо ми заплануємо їх до kthreadd, будуть OOPS.
*/
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);

Параметр kernel_init, що передається в user_mode_thread– це функція, яка завершує деякі завдання ініціалізації та потім шукає валідну програму ініціалізації для виконання. Ця процедура починається з деяких базових завдань налаштування. Пропустимо більшу частину коду, де це відбувається, за винятком виклику free_initmem. Це місце, де ядро ​​звільняє розділи .init!

free_initmem();

Тепер ядро ​​може знайти відповідну програму для запуску:

/*
 * Ми пробуємо кожну, доки не досягнемо успіху.
 *
 * Замість init можна використовувати оболонку Борна, якщо ми
 * намагаємося відновити по-справжньому зламану машину.
 */
if (execute_command) {
    ret = run_init_process(execute_command);
    if (!ret)
        return 0;
    panic("Requested init %s failed (error %d).",
          execute_command, ret);
}

if (CONFIG_DEFAULT_INIT[0] != '
/*
* Ми пробуємо кожну, доки не досягнемо успіху.
*
* Замість init можна використовувати оболонку Борна, якщо ми
* намагаємося відновити по-справжньому зламану машину.
*/
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
if (CONFIG_DEFAULT_INIT[0] != '\0') {
ret = run_init_process(CONFIG_DEFAULT_INIT);
if (ret)
pr_err("Default init %s failed (error %d)\n",
CONFIG_DEFAULT_INIT, ret);
else
return 0;
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
') { ret = run_init_process(CONFIG_DEFAULT_INIT); if (ret) pr_err("Default init %s failed (error %d)\n", CONFIG_DEFAULT_INIT, ret); else return 0; } if (!try_to_run_init_process("/sbin/init") || !try_to_run_init_process("/etc/init") || !try_to_run_init_process("/bin/init") || !try_to_run_init_process("/bin/sh")) return 0; panic("No working init found. Try passing init= option to kernel. " "See Linux Documentation/admin-guide/init.rst for guidance.");

У Linux програма ініціалізації майже завжди знаходиться або символічно прив’язана до /sbin/init. Загальними програмами ініціалізації є systemd , OpenRC та runit . kernel_init()за замовчуванням звертається до /bin/sh, якщо не знайшла нічого іншого. Якщо вона не знаходить /bin/sh, значить щось жахливо неправильно.

MacOS також має програму ініціалізації! Вона називається launchd і знаходиться в /sbin/launchd. Спробуйте запустити її в терміналі та отримайте повідомлення про те, що ви не є ядром.

Тут ми переходимо до кроку 4 процесу запуску комп’ютера: процес ініціалізації виконується у просторі користувача та починає запускати різні програми за допомогою патерну fork-exec.

Відображення пам’яті при клонуванні

Як ядро ​​Linux повторно відображає нижню частину пам’яті під час клонування процесів. Схоже, більшість коду клонування процесів міститься в kernel/fork.c . Початок цього файлу підказав нам правильне місце для пошуку:

// kernel/fork.c
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/kernel/fork.c#L8-L13
/*
 * 'fork.c' містить підпрограми допомоги (help-routines) для системного виклику 'fork'
 * (см. также entry.S и др.).
 * Клонування є простим, коли ви його освоїте, але
 * управління пам'яттю може бути стервом. 'mm/memory.c': 'copy_page_range()'
 */

Схоже, функція copy_page_range()приймає деяку інформацію про відображення пам’яті та копіює таблиці сторінок. При побіжному перегляді функцій, які він викликає, стає зрозумілим, що сторінки стають доступними тільки для читання, щоб стати сторінками COW. Визначення такої необхідності виконується за допомогою функції is_cow_mapping().

is_cow_mapping()визначається в include/linux/mm.h і повертає true, якщо у відображенні пам’яті є прапори, що вказують на те, що пам’ять доступна для запису і не є розподіленою (тобто не використовується кількома процесами одночасно). Розподілена пам’ять не повинна бути COW, оскільки вона є спільною. Помилуйте трохи незрозумілим маскуванням бітів (bitmasking):

// include/linux/mm.h
// https://github.com/torvalds/linux/blob/22b8cc3e78f5448b4c5df00303817a9137cd663f/include/linux/mm.h#L1541-L1544
static inline bool is_cow_mapping(vm_flags_t flags)
{
    return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}

Повертаємося в kernel/fork.c , виконання простої команди копіювання для copy_page_range()призводить до одного виклику з функції dup_mmap()… яка, у свою чергу, викликається функцією dum_mm()… яка викликається функцією copy_mm()… яка, нарешті, викликається масивною функцією copy_process()copy_process()– це ядро ​​функції клонування, і, у певному сенсі, суть того, як системи Unix виконують програми – копіювання та редагування шаблону, створеного для першого процесу під час запуску.

На закінчення

Як виконуються програми?

На низькому рівні: процесори тупі. Вони містять покажчик на згадку і виконують інструкції послідовно, поки не досягнуть інструкції, що вказує їм перейти кудись.

Крім інструкцій переходу, послідовність виконання інструкцій може порушуватися апаратними і програмними перериваннями. Ядра процесора не можуть одночасно виконувати декілька програм, але багатозадачність може імітуватися за допомогою таймерів для запуску переривань і перемикання кодом ядра між різними покажчиками коду.

Здається, що програми виконуються як цілісні, ізольовані одиниці. Прямий доступ до системних ресурсів у режимі користувача закритий, простір пам’яті ізольований за допомогою підкачки, системні виклики спроектовані таким чином, що надають загальне введення/виведення без знання про реальний контекст виконання. Системні виклики – це інструкції, які просять ЦП запустити певний код ядра, локація якого налаштовується ядром під час запуску.

Але… як виконуються програми?

Після запуску комп’ютера ядро ​​запускає процес ініціалізації. Це перша програма, запущена на найвищому рівні абстракції, коли машинному коду не потрібно турбуватися про багато специфічних деталей системи. Програма ініціалізації запускає програми, що рендерують графічне оточення комп’ютера та відповідають за запуск іншого програмного забезпечення.

Для запуску програма клонується за допомогою системного виклику fork. Це клонування є ефективним, оскільки всі сторінки пам’яті є COW і пам’ять не копіюється у фізичну ОП. У Linux за це відповідає функція copy_process().

Обидва процеси перевіряються щодо клонированности. Якщо вони є клонованими, вони використовують системний виклик execveдля звернення до ядра для заміни поточного процесу на нову програму.

Швидше за все, нова програма є файлом ELF, який ширяє ядром для отримання інформації про те, як завантажувати програму і куди поміщати її код і дані в новому відображенні віртуальної пам’яті. Ядро також може виконувати підготовку інтерпретатора ELF у випадку, коли програма динамічно пов’язана.

Потім ядро ​​завантажує відображення віртуальної пам’яті програми та повертається в простір користувача із запущеною програмою, що означає встановлення покажчика інструкції ЦП на початок коду нової програми у віртуальній пам’яті.

Епілог

Вітаю! Тепер ви краще знаєте, як працює комп’ютер. Сподіваюся, вам було весело.

Дозвольте проводити вас, підкресливши, що знання, які ви отримали, реальні та актуальні. Наступного разу, коли ви замислитеся про те, як комп’ютер запускає кілька додатків, сподіваемось, ви уявите мікросхеми таймера та апаратні переривання. Коли ви будете писати програму якоюсь химерною мовою програмування і отримаєте помилку компонувальника (linker error), сподіваемось, ви згадаєте, що таке компонувальник і за що він відповідає.

Інші статті по темі
Знайшли помилку?
Якщо ви знайшли помилку, зробіть скріншот і надішліть його боту.