Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 12 мин чтения
Открыть в интерактивном справочнике

135. Процесс: запуск, окружение, память, лимиты, завершение

Прошивка на микроконтроллере — одна программа, которая владеет всем. На компьютере с Linux программа живёт внутри процесса: ядро выдаёт ей память, время процессора и файлы, следит за пределами и убирает за ней после выхода. Эта часть справочника — о том, как программа на C живёт под ядром: процессы, потоки, обмен между ними, библиотеки, ядро изнутри. Начало — с самого процесса: что он получает при запуске, где лежат его данные и что происходит, когда он завершается.

Все числа — из прогонов в контейнере stm-sys:1 (Debian 12, gcc 12, ядро 6.6): программа _verify/sys135/proc_layout.c печатает адреса своих же сегментов, считает страничные промахи и читает собственную карту памяти.

Как это называется в документации

Оригинал Перевод
process процесс: программа, запущенная ядром, со своей памятью
exit status код завершения
environment окружение: переменные вида ИМЯ=значение
text, data, bss сегменты кода, инициализированных и нулевых данных
heap, stack куча, стек
program break граница кучи, которую двигает brk
memory mapping отображение памяти (mmap)
page fault страничный промах: обращение к странице, которой ещё нет
resident set size (RSS) сколько физической памяти занято
resource limit предел ресурса (ulimit)
nice value «уступчивость»: чем больше, тем ниже приоритет
OOM killer убийца процессов при нехватке памяти

135.1 Завершение: три функции и код возврата

Процесс заканчивается тремя способами, и они не равнозначны:

Вызов Что делает
return из main или exit(n) вызывает функции atexit, сбрасывает буферы stdio, закрывает файлы, отдаёт ядру код n
_exit(n), _Exit(n) сразу к ядру: без atexit и без сброса буферов
сигнал процесс убит; кода возврата нет, есть номер сигнала

Код возврата — младшие 8 бит: оболочка показывает его как $?. Гибель от сигнала она показывает как $? = 128 + номер: SIGKILL — 137, SIGSEGV — 139. EXIT_SUCCESS — 0, EXIT_FAILURE — 1. Программа из этого раздела возвращает первый аргумент:

./proc_layout 7 >/dev/null || echo "rc=$?"
rc=7

Разница между exit и _exit видна, когда вывод идёт не в терминал, а в канал: stdout тогда буферизован целиком, и _exit теряет всё, что программа успела «напечатать». Прогон ./proc_layout _exit | cat не выводит ни одного байта, хотя обычный запуск печатает 14 строк. Функция, зарегистрированная через atexit, тоже не вызывается. Поэтому _exit — для потомка после неудачного exec и для обработчиков сигналов, а в обычном пути — exit.

Три пути завершения процесса: return из main или exit проходит функции atexit, сброс буферов stdio и закрытие файлов и отдаёт ядру код, который оболочка показывает как 7; _exit идёт к ядру сразу мимо atexit и буферов, и в канале теряются все 14 строк; сигнал убивает процесс без кода возврата, остаётся номер сигнала

static void at_exit_hook(void) { puts("atexit: вызвано при exit()"); }

Память процесса снизу вверх: код, константы, данные, нули, куча; выше — отображения mmap; стек у вершины


135.2 Что процесс получает при запуске

main принимает три аргумента: число слов командной строки, сами слова и окружение. Третий аргумент не в стандарте C, но есть везде в Unix; переносимо окружение читают через extern char **environ и getenv:

int main(int argc, char **argv, char **envp)

Окружение наследуется: потомок получает копию окружения родителя. Команда env -i запускает программу с пустым окружением, и getenv("PATH") возвращает NULL:

env -i FOO=bar ./proc_layout
argv0 ./proc_layout argc 1 envp[0] FOO=bar...
PATH через getenv: нет

Переменные, на которые опирается почти всё: PATH (где искать программы), HOME, LANG и LC_* (язык и кодировка), TERM, PWD. Две особые — для динамического компоновщика: LD_LIBRARY_PATH добавляет каталоги с библиотеками, LD_PRELOAD подставляет свою библиотеку раньше всех (раздел 143). Свои настройки программы принято класть по правилам XDG: $HOME/.config, $HOME/.cache, $HOME/.local/share.


135.3 Память процесса: сегменты

Программа proc_layout объявляет по переменной в каждом сегменте и печатает их адреса:

char bss_buf[1 << 20];                 /* 1 МБ нулей — в .bss, в файле места не занимает */
char data_buf[1 << 10] = {1};          /* 1 КБ — в .data, лежит в файле */
const char ro_str[] = "только чтение"; /* .rodata */

Команда size считает три сегмента исполняемого файла:

   text    data     bss     dec     hex filename
   4110    1752 1048608 1054470  101706 proc_layout

bss — 1 048 608 байт, а файл proc_layout при этом маленький: нули не хранятся, ядро выделяет под них страницы при запуске. Это тот же принцип, что в прошивке (раздел 11): .data копируется из Flash, .bss обнуляется.

Адреса в запущенном процессе (один прогон; при следующем они будут другими — адреса случайны):

text   main      0x5ce1f313820f
rodata ro_str    0x5ce1f3139010
data   data_buf  0x5ce1f313b0a0
bss    bss_buf   0x5ce1f313b4c0
heap   malloc100 0x5ce1f78482a0
mmap   malloc256K 0x7339f5beb010
stack  local     0x7fff44c88dcc
brk    sbrk(0)   0x5ce1f7869000

Порядок снизу вверх по адресам: код, константы, данные, нули, куча — все рядом; стек — далеко вверху, растёт вниз; а 256 КБ из malloc лежат между ними, в отдельной области. Почему — в следующем подразделе.

Карту памяти процесс может прочитать сам из /proc/self/maps:

631277405000-631277406000 r--p /v/sys135/proc_layout
631277406000-631277407000 r-xp /v/sys135/proc_layout
631277407000-631277408000 r--p /v/sys135/proc_layout
631277408000-631277409000 r--p /v/sys135/proc_layout
631277409000-63127740a000 rw-p /v/sys135/proc_layout
6312aecb6000-6312aecd7000 rw-p [heap]
7fff76efb000-7fff76f1c000 rw-p [stack]
7fff76f52000-7fff76f56000 r--p [vvar]
7fff76f56000-7fff76f58000 r-xp [vdso]

Пять отображений одного файла с разными правами: только код r-xp исполняемый, только данные rw-p записываемые. Попытка записать в ro_str — SIGSEGV, и это работа ядра, а не компилятора. [vdso] — кусок кода ядра, отображённый в процесс: через него gettimeofday и clock_gettime обходятся без системного вызова.

Карта сокращена: опыт печатает только строки своего файла, кучи, стека и ядра, строк libc и динамического компоновщика в ней нет. Не видно и мегабайта bss_buf: в файловое отображение rw-p помещается одна страница с data_buf, а нули лежат в безымянном отображении rw-p сразу за ним.


135.4 Куча: brk и mmap

Куча в Linux — не один механизм, а два. malloc из glibc берёт маленькие блоки из области за концом данных, двигая её границу системным вызовом brk; sbrk(0) возвращает текущую границу — на прогоне она стоит сразу за кучей. Большие запросы — по умолчанию от 128 КБ (M_MMAP_THRESHOLD) — malloc выполняет отдельным mmap: поэтому блок 256 КБ оказался по адресу 0x7339…, далеко от кучи, а free такого блока возвращает память ядру сразу. Маленькие блоки после free остаются у процесса: glibc сдвигает границу brk вниз, только когда свободен конец кучи больше M_TRIM_THRESHOLD (по умолчанию 128 КБ), а дыры в середине кучи отдаёт лишь malloc_trim(0). В опыте 4000 блоков по 1000 байт заняли 3960 КБ кучи; free всех, кроме последнего, не сдвинул границу ни на байт, malloc_trim(0) вернул страницы из середины (RSS упал почти на 4 МБ, граница — лишь на 20 КБ), а free последнего блока опустил границу до 136 КБ. Порог mmap при этом плавающий: после free большого блока он поднимается, и следующий такой же блок в 256 КБ пришёл уже из кучи.

Порог и поведение меняются mallopt, а mallinfo показывает статистику. Стандартный malloc — не единственный: jemalloc, tcmalloc и mimalloc подменяют его целиком через LD_PRELOAD ради многопоточности и меньшей фрагментации, а dmalloc — ради поиска ошибок.


135.5 Страницы и страничные промахи

Память выдаётся страницами по 4096 байт (getconf PAGESIZE), и выдаётся лениво: bss_buf объявлен на 1 МБ, но физической памяти под ним нет, пока к ней не обратились. Первое обращение к каждой странице — страничный промах: ядро находит страницу, обнуляет её и подставляет. Программа считает промахи через getrusage до и после memset на 1 МБ:

minor faults за memset 1 МБ: 256 (страниц по 4 КБ: 256)

Ровно по промаху на страницу. Это minor — страница просто ещё не выделена. Major промах — когда страницу надо прочитать с диска: файл или своп. Их числа — столбцы minflt и majflt в getrusage, а RSS в top — сколько страниц процесса сейчас физически в памяти. VIRT там же — всё, что процесс зарезервировал, включая нетронутые нули и отображённые файлы; пугаться VIRT в гигабайты не стоит, смотреть надо на RES.


135.6 Пределы

У процесса есть мягкий и жёсткий предел на каждый ресурс: getrlimit читает, setrlimit меняет, мягкий можно поднять только до жёсткого. В контейнере:

RLIMIT_STACK мягкий 8192 КБ
RLIMIT_NOFILE 1048576

Стек 8 МБ — отсюда «падает на большом массиве в main»: массив в 10 МБ на стеке превышает предел и даёт SIGSEGV. Такой массив кладут в static или берут из кучи. Остальные пределы:

Предел Что ограничивает При превышении
RLIMIT_CPU секунды процессора SIGXCPU
RLIMIT_FSIZE размер создаваемого файла SIGXFSZ
RLIMIT_DATA, RLIMIT_AS данные, всё адресное пространство malloc возвращает NULL
RLIMIT_STACK стек SIGSEGV
RLIMIT_CORE размер файла core 0 — файл не создаётся
RLIMIT_NOFILE открытые файлы open — EMFILE
RLIMIT_NPROC процессов у пользователя fork — EAGAIN

Из оболочки то же самое — ulimit -a; ulimit -c unlimited включает файлы core для посмертной отладки (раздел 138).


135.7 Приоритет, время, /proc

Приоритет. nice от −20 до 19; обычный пользователь может только повышать число, то есть уступать. Из программы — nice(incr) или setpriority. Проверка: nice -n 10 sh -c 'cat /proc/$$/stat' показывает в 19-м поле nice=10.

Время. times и getrusage делят время на пользовательское и системное — сколько процесс считал сам и сколько за него работало ядро. Команда time печатает то же самое; /usr/bin/time -v — ещё и максимальный RSS, промахи, переключения контекста.

/proc. Всё, что ядро знает о процессе, лежит в /proc/<pid>/: status — состояние и память, maps — карта, fd/ — открытые файлы, limits — пределы, stat — одна строка с полями для программ. /proc/self — ссылка на самого себя.

OOM. Когда память кончается, ядро убивает процесс с наибольшим oom_score; строка в журнале — Out of Memory: Killed process …. Файл /proc/<pid>/oom_score_adj от −1000 до 1000 говорит ядру, кого убивать неохотно, а кого первым.

Отсюда неожиданность для программиста на C: malloc в Linux при нехватке памяти почти никогда не возвращает NULL. Ядро по умолчанию обещает больше, чем есть (vm.overcommit_memory = 0 отказывает только в явно невозможном запросе), а страницы выдаёт при первом касании (135.5). В опыте в контейнере с пределом 128 МБ malloc на 512 МБ вернул адрес, а процесс убили на записи страниц — код возврата 137, то есть SIGKILL; проверка на NULL тут не спасает. Строгий учёт — vm.overcommit_memory = 2; для одной программы — предел RLIMIT_AS (135.6): с ним на 256 МБ malloc на 1 ГБ честно вернул NULL.


135.8 Грабли

Симптом Причина
вывод программы пропал, когда её запустили через канал _exit или сигнал: буфер stdout не сброшен — exit или fflush
atexit не сработал выход через _exit или убит сигналом
код возврата 256 «стал нулём» оболочка видит только младшие 8 бит
getenv("PATH") вернул NULL окружение пустое — запуск из env -i, cron или службы
SIGSEGV на большом локальном массиве стек 8 МБ (RLIMIT_STACK) — массив в static или malloc
VIRT в гигабайты, а памяти хватает резерв, а не занято; смотреть RES
free не вернул память системе дыры в середине кучи остаются у процесса; границу кучи glibc опускает, только когда свободен её конец больше 128 КБ (M_TRIM_THRESHOLD); отдать явно — malloc_trim(0)
программа убита без причины, в dmesg — Out of Memory OOM killer; снизить потребление или oom_score_adj

135.9 Итоги

Полные исходники

proc_layout.c — адреса сегментов, кучи и стека процесса и порог mmap.

/* Раздел 135: где что лежит в памяти процесса — адреса сегментов, куча, стек, порог mmap. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <malloc.h>
#include <sys/resource.h>

char bss_buf[1 << 20];                 /* 1 МБ нулей — в .bss, в файле места не занимает */
char data_buf[1 << 10] = {1};          /* 1 КБ — в .data, лежит в файле */
const char ro_str[] = "только чтение"; /* .rodata */

static void at_exit_hook(void) { puts("atexit: вызвано при exit()"); }

int main(int argc, char **argv, char **envp)
{
    int local = 0;
    char *small = malloc(100);              /* из кучи (brk) */
    char *big = malloc(256 * 1024);         /* больше M_MMAP_THRESHOLD (128 К) — отдельное mmap */
    printf("text   main      %p\n", (void *)main);
    printf("rodata ro_str    %p\n", (void *)ro_str);
    printf("data   data_buf  %p\n", (void *)data_buf);
    printf("bss    bss_buf   %p\n", (void *)bss_buf);
    printf("heap   malloc100 %p\n", (void *)small);
    printf("mmap   malloc256K %p\n", (void *)big);
    printf("stack  local     %p\n", (void *)&local);
    printf("brk    sbrk(0)   %p\n", sbrk(0));
    printf("argv0 %s argc %d envp[0] %.20s...\n", argv[0], argc, envp[0]);
    printf("PATH через getenv: %s\n", getenv("PATH") ? "есть" : "нет");
    struct rlimit rl;
    getrlimit(RLIMIT_STACK, &rl);
    printf("RLIMIT_STACK мягкий %ld КБ\n", (long)(rl.rlim_cur / 1024));
    getrlimit(RLIMIT_NOFILE, &rl);
    printf("RLIMIT_NOFILE %ld\n", (long)rl.rlim_cur);
    atexit(at_exit_hook);
    /* page faults: тронуть 1 МБ bss — каждая страница даёт minor fault */
    struct rusage ru0, ru1;
    getrusage(RUSAGE_SELF, &ru0);
    memset(bss_buf, 1, sizeof bss_buf);
    getrusage(RUSAGE_SELF, &ru1);
    printf("minor faults за memset 1 МБ: %ld (страниц по 4 КБ: %d)\n",
           ru1.ru_minflt - ru0.ru_minflt, (int)(sizeof bss_buf / 4096));
    if (argc > 1 && strcmp(argv[1], "maps") == 0) {     /* карта памяти процесса от ядра */
        FILE *f = fopen("/proc/self/maps", "r"); char line[256];
        while (fgets(line, sizeof line, f))
            if (strstr(line, "proc_layout") || strstr(line, "[heap]") || strstr(line, "[stack]") || strstr(line, "[v"))
                printf("maps %s", line);
        fclose(f);
    }
    free(big); free(small);
    if (argc > 1 && strcmp(argv[1], "_exit") == 0) _exit(3);   /* без atexit и без сброса буферов */
    return argc > 1 ? atoi(argv[1]) : 0;
}

Дальше: раздел 136 — fork, exec, wait и сигналы.

Открыть в интерактивном справочнике