Розуміння C Runtime: crt0, crt1, crti та crtn

Вступ

Коли ви пишете просту програму на C, наприклад:

#include <stdio.h>
int main(void) {
  printf("Hello, world!\n");
  return 0;
}

і компілюєте її (наприклад, gcc hello.c -o hello), ви можете припустити, що ваша функція main() є першою частиною коду, що виконується, коли програма запускається. Однак насправді існує кілька спеціальних частин коду, які виконуються до і після main(), готуючи середовище для вашої програми. Ці частини коду є частиною об'єктів запуску C Runtime (CRT). Серед них ви могли зустрічати імена файлів, такі як crt0.o, crt1.o, crti.o та crtn.o. У цьому пості ми розглянемо, що кожен з них робить, чому вони існують і як вони працюють разом, щоб забезпечити безперебійну роботу ваших програм на C (і C++).

Що таке C Runtime?

C Runtime (CRT) — це набір процедур запуску, ініціалізаційного коду, підтримки стандартної бібліотеки та інколи оболонок системних викликів, які формують середовище, в якому виконується програма на C. Більшість цього коду знаходиться поза вашим власним вихідним кодом, але автоматично підключається компілятором (наприклад, gcc або clang).

Коли ви компілюєте програму за допомогою команди:

gcc main.c -o main

або

clang main.c -o main

компілятор і лінкер неявно включають об'єктні файли запуску і бібліотеки, включаючи один або кілька об'єктних файлів CRT. Ці файли містять вхідні точки на рівні асемблера та процедури, які:

  1. Ініціалізують регістри та стек.
  2. Налаштовують аргументи програми (argc, argv, envp).
  3. Викликають глобальні конструктори (у програмах на C++).
  4. Викликають вашу функцію main().
  5. Обробляють повернення з main() і передають код завершення операційній системі.

Роль crt0.o (або crt1.o в сучасних інструментальних ланцюгах)

Історично crt0.o (C runtime zero) є невеликим об'єктним файлом, який містить реальну процедуру входу, зазвичай звану _start. Його відповідальності включають:

  1. Ініціалізація програми
  • Ініціалізація стека (на деяких архітектурах і ОС, хоча зазвичай це налаштовує вказівник стека ядро).
  • Налаштування сегментів пам'яті, якщо це необхідно (наприклад, дані, BSS).
  • Підготовка argc, argv та вказівників на середовище з даних, наданих ядром.
  • Виклик конструкторів для глобальних і статичних об'єктів (особливо в C++).
  • Можливо, виклик функцій ініціалізації бібліотек (для стандартної бібліотеки вводу/виводу тощо).
  1. Перехід керування до main()
  • Після налаштування середовища, crt0.o викликає main(argc, argv, envp).
  1. Очищення
  • Коли main() завершується, crt0.o (або остання процедура виходу) викликає операційно-системний виклик виходу (наприклад, _exit або подібний) для завершення процесу з кодом повернення з main().

Оскільки crt0.o часто був великим, монолітним файлом, багато сучасних інструментальних ланцюгів зараз розділяють його на більш модульні компоненти. Можливо, ви побачите використання crt1.o замість crt0.o. Назва crt1.o зазвичай вказує на те, що це "перше" (або основне) об'єктне файл для запуску. Незважаючи на різницю в іменах, вони виконують ту ж саму основну функцію: містять символ _start, який є стандартною точкою входу, використовуваною лінкером.

Типовий вміст crt0.o / crt1.o

  • Низькорівневий асемблерний код, відповідальний за налаштування середовища виконання.
  • Символ, званий _start (або інколи __start), що є точкою входу.
  • Виклик до main() (або _main, залежно від конвенції).

Фаза лінкування

Коли ви лінкуєте вашу програму, лінкер автоматично підключає crt0.o (або crt1.o) з реалізації C бібліотеки (наприклад, glibc або musl) або з компіляторського інструментального ланцюга.
Це відбувається за лаштунками, якщо ви явно не вимкнете це (наприклад, за допомогою певних флагів компілятора, таких як -nostartfiles).

Додаткові файли для запуску: crti.o, crtn.o та інші

У сучасних інструментальних ланцюгах C Runtime часто розділений на кілька об'єктних файлів:

  • crti.o (C runtime ініціалізація)
  • crtn.o (C runtime завершення)
  • crt1.o (C runtime точка входу)

crti.o: Ініціалізація C Runtime

crti.o зазвичай містить пролог для ініціалізаційної процедури запуску. Його основні завдання включають:

  • Ініціалізація, специфічна для платформи
    Наприклад, ініціалізація спеціальних регістрів, можливостей CPU або інших ресурсів, специфічних для архітектури.
  • Підготовка середовища
    Готує все необхідне для виклику конструкторів (.ctors секція для C++).
  • Підключення для раннього налаштування
    Це можуть бути ініціалізаційні процедури, необхідні для ОС або платформи, такі як налаштування локального сховища потоків (TLS) на деяких системах.

Концептуально, ви можете вважати crti.o місцем, де середовище запуску програми ініціалізується: «Я починаю налаштування середовища, ось деякий початковий код». Коли все готово, керування переходить до main() або інших початкових процедур.

crtn.o: Завершення C Runtime

crtn.o містить епілог процесу ініціалізації та обробляє фінальні процедури. Він:

  • Завершує послідовність ініціалізації
    Завершує те, що почав crti.o, забезпечуючи виклик усіх глобальних конструкторів.
  • Керує деструкторами
    Для програм на C++ глобальні деструктори (.dtors) повинні бути викликані наприкінці програми. Огортаючи початкову та кінцеву частини навколо цих секцій, crti.o і crtn.o правильно керують цією логікою.

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

Як все це працює разом

Щоб уявити, як ці файли вписуються в потік запуску програми, ось спрощена діаграма:

┌─────────────────────┐  
 │ Точка входу в програму │ (Визначено в crt1.o або crt0.o)  
 │ _start() │  
 └──────────┬──────────┘  
 │  
 │ (1) Ініціалізація середовища, пам'яті тощо  
 │  
 ┌──────────┴──────────┐  
 │ crti.o (Пролог) │   
 │ Викликає конструктори │  
 └──────────┬──────────┘  
 │  
 │ (2) Переходимо до main()  
 │  
 ┌──────────┴──────────┐  
 │ main() │  
 └──────────┬──────────┘  
 │  
 │ (3) main повертається  
 │  
 ┌──────────┴──────────┐  
 │ crtn.o (Епілог) │  
 │ Викликає деструктори │  
 └──────────┬──────────┘  
 │  
 │ (4) системний виклик виходу  
 │  
 ┌─────┴──────┐  
 │ Вихід ОС │  
 └────────────┘

Ключові етапи:

  1. _startcrt1.o або crt0.o) виконує низькорівневе налаштування, після чого викликає початковий код з crti.o.
  2. Ініціалізаційний код з crti.o завершується, і ми переходимо до main().
  3. Коли main() повертається, виконується епілог з crtn.o, що викликає фіналізатори та деструктори.
  4. Останній системний виклик виходу завершує процес з кодом повернення з main().

Приклад фрагмента асемблера

Нижче наведено спрощений фрагмент асемблерного коду для Linux x86–64, що ілюструє мінімальну процедуру _start (реальний код у crt1.o або crt0.o може бути складнішим). Зверніть увагу, що в реальних реалізаціях будуть додаткові інструкції для управління середовищем, локальним сховищем потоків тощо.

.global _start  
_start:  
 ; Вказівник стека вже налаштований ОС.  
 ; Регістри RDI, RSI та RDX можуть містити вказівники на argc, argv та envp.  

 ; Зберігаємо argc, argv та envp у стеку або  
 ; передаємо їх безпосередньо в main() (залежно від конвенції виклику).  
 mov rdi, [rsp] ; argc знаходиться на верху стека  
 lea rsi, [rsp+8] ; вказівник на argv після argc  
 ; envp буде після argv тощо.
call main ; Виклик main(argc, argv, envp неявно)  

 ; Збереження коду повернення в eax  
 mov rax, rax  

 ; Виконання системного виклику виходу  
 mov rax, 60 ; sys_exit на Linux x86-64  
 syscall

Дуже спрощена версія crti.o може виглядати ось так (псевдокод C++ / асемблер):

.section .init  
 _init:  
 ; Тут ви ініціалізуєте глобальні конструктори або  
 ; налаштовуєте код, необхідний для запуску перед викликом main.  
 ; Наприклад, викликайте __libc_init_array (в деяких інструментальних ланцюгах)  
 ret

А crtn.o може мати відповідний код:

.section .fini  
 _fini:  
 ; Процедури очищення та виклик глобальних деструкторів.  
 ; Наприклад, викликайте __libc_fini_array  
 ret

У реальних інструментальних ланцюгах ці секції (.init і .fini) автоматично виконуються до та після main(), відповідно, завдяки скриптам GNU linker і механізмам .init_array / .fini_array або .ctors / .dtors.

Практичні зауваження щодо сучасного використання

  • Статичне проти динамічного лінкування
    Якщо ви створюєте статично лінкований виконуваний файл (-static), то файли C runtime повністю включаються в кінцевий бінарний файл. Для динамічно лінкованих виконуваних файлів динамічна версія цих об'єктів CRT часто обробляє взаємодію з динамічним завантажувачем перед викликом main().
  • Різні ОС, різні реалізації
    Назви та точні деталі можуть змінюватися. На Linux з glibc ви можете побачити crt1.o, crti.o, crtn.o і так далі. На інших системах (наприклад, BSD або macOS) назви можуть відрізнятися, або процес може бути реалізований по-іншому.
  • Конструктори та деструктори C++
    Секції .ctors і .dtors (або .init_array і .fini_array) є необхідними для автоматичного виклику конструкторів і деструкторів глобальних об'єктів. Окремі файли crti.o та crtn.o обгортають ці виклики, щоб вони відбувалися до виклику main() і після того, як main() повертається (або викликається exit()).
  • Користувацькі точки входу
    Досвідчені розробники іноді заміняють стандартні об'єкти CRT на свої власні мінімалістичні версії (використовуючи -nostdlib або -nostartfiles) для автономних або вбудованих середовищ.

Висновок

C runtime (CRT) — це важлива, часто недооцінена частина будь-якої програми на C або C++. Файли на кшталт crt0.o (або crt1.o, crti.o та crtn.o) гарантують, що ваш код має все необхідне перед виконанням main(), включаючи налаштування стека, виклик глобальних конструкторів і ініціалізацію бібліотек. Вони також займаються очищенням (наприклад, глобальні деструктори), коли main() повертається. Хоча ці об'єкти зазвичай включаються автоматично компілятором, знання про них допомагає вам зрозуміти, як ваша програма на C/C++ переходить від сирого процесу до повноцінної програми — і врешті-решт завершиться організовано.

Незалежно від того, чи розробляєте ви компілятори, працюєте з вбудованими системами, або просто цікаво дізнатися, як насправді починається програма на C, ці відомості про C runtime можуть допомогти розвіяти міфи про "невидимий" код за вашою функцією main().

Посилання

Перекладено з: Understanding the C Runtime: crt0, crt1, crti, and crtn

Leave a Reply

Your email address will not be published. Required fields are marked *