Embedded HandbookApps
All sections › XVII. Systems programming in C
· level intermediate · 12 min read
Open in the interactive handbook

135. The process: startup, environment, memory, limits, termination

Firmware on a microcontroller is a single program that owns everything. On a computer running Linux, a program lives inside a process: the kernel gives it memory, processor time and files, watches its limits and cleans up after it when it exits. This part of the handbook is about how a C program lives under the kernel: processes, threads, communication between them, libraries, the kernel from the inside. It starts with the process itself: what it gets at startup, where its data lives and what happens when it terminates.

All the numbers come from runs in the stm-sys:1 container (Debian 12, gcc 12, kernel 6.6): the program _verify/sys135/proc_layout.c prints the addresses of its own segments, counts page faults and reads its own memory map.

Terms used in the documentation

Term Meaning
process a program started by the kernel, with its own memory
exit status the code the process returns on termination
environment variables of the form NAME=value
text, data, bss the segments of code, initialized data and zeroed data
heap, stack dynamic memory, the call stack
program break the end of the heap, moved by brk
memory mapping a region mapped into the address space (mmap)
page fault an access to a page that is not there yet
resident set size (RSS) how much physical memory is in use
resource limit a cap on a resource (ulimit)
nice value "niceness": the higher it is, the lower the priority
OOM killer the kernel mechanism that kills processes when memory runs out

135.1 Termination: three functions and the exit code

A process ends in three ways, and they are not equivalent:

Call What it does
return from main or exit(n) calls the atexit functions, flushes the stdio buffers, closes files, hands code n to the kernel
_exit(n), _Exit(n) straight to the kernel: no atexit and no buffer flush
signal the process is killed; there is no exit code, only a signal number

The exit code is the low 8 bits: the shell shows it as $?. Death by a signal is shown as $? = 128 + the number: SIGKILL gives 137, SIGSEGV gives 139. EXIT_SUCCESS is 0, EXIT_FAILURE is 1. The program in this section returns its first argument:

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

The difference between exit and _exit shows when the output goes not to a terminal but to a pipe: stdout is then fully buffered, and _exit loses everything the program has "printed" so far. The run ./proc_layout _exit | cat does not output a single byte, although a normal run prints 14 lines. A function registered with atexit is not called either. So _exit is for a child after a failed exec and for signal handlers, and on the normal path you use exit.

Three ways a process terminates: return from main or exit goes through the atexit functions, the stdio buffer flush and closing files, and hands the kernel a code the shell shows as 7; _exit goes straight to the kernel past atexit and the buffers, and in a pipe all 14 lines are lost; a signal kills the process with no exit code, only the signal number remains

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

Process memory from the bottom up: code, constants, data, zeros, heap; above them the mmap mappings; the stack near the top


135.2 What a process gets at startup

main takes three arguments: the number of command-line words, the words themselves and the environment. The third argument is not in the C standard, but every Unix has it; the portable way to read the environment is extern char **environ and getenv:

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

The environment is inherited: a child gets a copy of its parent's environment. The command env -i starts a program with an empty environment, and getenv("PATH") returns NULL:

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

Variables that almost everything relies on: PATH (where to look for programs), HOME, LANG and LC_* (language and encoding), TERM, PWD. Two are special and belong to the dynamic linker: LD_LIBRARY_PATH adds directories with libraries, LD_PRELOAD loads your own library before all the others (section 143). A program's own settings conventionally go where the XDG rules say: $HOME/.config, $HOME/.cache, $HOME/.local/share.


135.3 Process memory: segments

The program proc_layout declares one variable in each segment and prints their addresses:

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

The size command counts the three segments of the executable:

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

bss is 1048608 bytes, yet the proc_layout file is small: the zeros are not stored, the kernel allocates pages for them at startup. This is the same principle as in firmware (section 11): .data is copied from Flash, .bss is zeroed.

Addresses in the running process (one run; on the next one they will be different — the addresses are randomized):

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

The order from the bottom up by address: code, constants, data, zeros, heap — all close together; the stack is far up and grows down; and the 256 KB from malloc lies between them, in a separate region. Why — in the next subsection.

A process can read its memory map itself from /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]

Five mappings of one file with different permissions: only the code r-xp is executable, only the data rw-p is writable. An attempt to write to ro_str gives SIGSEGV, and that is the kernel's doing, not the compiler's. [vdso] is a piece of kernel code mapped into the process: through it gettimeofday and clock_gettime work without a system call.

The map is shortened: the experiment prints only the lines of its own file, the heap, the stack and the kernel; the lines for libc and the dynamic linker are not in it. The megabyte of bss_buf is not visible either: the file mapping rw-p holds one page with data_buf, and the zeros lie in an anonymous rw-p mapping right after it.


135.4 The heap: brk and mmap

The heap in Linux is not one mechanism but two. malloc from glibc takes small blocks from the region past the end of the data, moving its boundary with the system call brk; sbrk(0) returns the current boundary — in the run it sits right after the heap. Large requests — by default from 128 KB (M_MMAP_THRESHOLD) — malloc serves with a separate mmap: that is why the 256 KB block ended up at address 0x7339…, far from the heap, and free of such a block returns the memory to the kernel at once. Small blocks stay with the process after free: glibc moves the brk boundary down only when the free end of the heap is larger than M_TRIM_THRESHOLD (128 KB by default), and holes in the middle of the heap are released only by malloc_trim(0). In the experiment, 4000 blocks of 1000 bytes took 3960 KB of heap; free of all but the last did not move the boundary by a single byte, malloc_trim(0) returned the pages from the middle (RSS fell by almost 4 MB, the boundary by only 20 KB), and free of the last block lowered the boundary to 136 KB. The mmap threshold, meanwhile, floats: after free of a large block it goes up, and the next block of the same 256 KB came from the heap.

The threshold and the behavior are changed with mallopt, and mallinfo shows statistics. The standard malloc is not the only one: jemalloc, tcmalloc and mimalloc replace it entirely through LD_PRELOAD for the sake of multithreading and lower fragmentation, and dmalloc for finding bugs.


135.5 Pages and page faults

Memory is handed out in pages of 4096 bytes (getconf PAGESIZE), and it is handed out lazily: bss_buf is declared as 1 MB, but there is no physical memory behind it until it is accessed. The first access to each page is a page fault: the kernel finds a page, zeroes it and maps it in. The program counts the faults with getrusage before and after a memset over 1 MB:

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

Exactly one fault per page. This is a minor fault — the page simply has not been allocated yet. A major fault is when the page has to be read from disk: a file or swap. Their counts are the minflt and majflt columns in getrusage, and RSS in top is how many of the process's pages are physically in memory right now. VIRT there is everything the process has reserved, including untouched zeros and mapped files; a VIRT of gigabytes is nothing to worry about, look at RES.


135.6 Limits

A process has a soft and a hard limit for each resource: getrlimit reads them, setrlimit changes them, and the soft one can be raised only up to the hard one. In the container:

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

An 8 MB stack — this is where "it crashes on a big array in main" comes from: a 10 MB array on the stack exceeds the limit and gives SIGSEGV. Such an array is made static or taken from the heap. The other limits:

Limit What it caps When exceeded
RLIMIT_CPU seconds of processor time SIGXCPU
RLIMIT_FSIZE size of a file being created SIGXFSZ
RLIMIT_DATA, RLIMIT_AS data, the whole address space malloc returns NULL
RLIMIT_STACK stack SIGSEGV
RLIMIT_CORE size of the core file 0 — no file is created
RLIMIT_NOFILE open files open gives EMFILE
RLIMIT_NPROC processes per user fork gives EAGAIN

From the shell, the same thing is ulimit -a; ulimit -c unlimited enables core files for post-mortem debugging (section 138).


135.7 Priority, time, /proc

Priority. nice runs from −20 to 19; an ordinary user can only raise the number, that is, yield. From a program — nice(incr) or setpriority. A check: nice -n 10 sh -c 'cat /proc/$$/stat' shows nice=10 in field 19.

Time. times and getrusage split time into user and system time — how much the process computed itself and how much the kernel worked on its behalf. The time command prints the same; /usr/bin/time -v also prints the maximum RSS, page faults and context switches.

/proc. Everything the kernel knows about a process lives in /proc/<pid>/: status — state and memory, maps — the map, fd/ — open files, limits — the limits, stat — one line of fields for programs. /proc/self is a link to the process itself.

OOM. When memory runs out, the kernel kills the process with the highest oom_score; the log line is Out of Memory: Killed process …. The file /proc/<pid>/oom_score_adj, from −1000 to 1000, tells the kernel whom to kill reluctantly and whom first.

Hence a surprise for a C programmer: malloc in Linux almost never returns NULL when memory runs short. By default the kernel promises more than it has (vm.overcommit_memory = 0 refuses only a clearly impossible request), and hands out pages on first touch (135.5). In an experiment in a container with a 128 MB limit, malloc of 512 MB returned an address, and the process was killed while writing the pages — exit code 137, that is, SIGKILL; a check for NULL does not help here. Strict accounting is vm.overcommit_memory = 2; for a single program it is the RLIMIT_AS limit (135.6): with it set to 256 MB, malloc of 1 GB honestly returned NULL.


135.8 Pitfalls

Symptom Cause
the program's output disappeared when it was run through a pipe _exit or a signal: the stdout buffer was not flushed — use exit or fflush
atexit did not run the exit went through _exit, or the process was killed by a signal
exit code 256 "became zero" the shell sees only the low 8 bits
getenv("PATH") returned NULL the environment is empty — started from env -i, cron or a service
SIGSEGV on a large local array the stack is 8 MB (RLIMIT_STACK) — put the array in static or malloc
VIRT in gigabytes, yet there is enough memory it is reserved, not used; look at RES
free did not return memory to the system holes in the middle of the heap stay with the process; glibc lowers the heap boundary only when its free end is larger than 128 KB (M_TRIM_THRESHOLD); to release explicitly — malloc_trim(0)
the program was killed for no reason, dmesg says Out of Memory the OOM killer; reduce consumption or use oom_score_adj

135.9 Summary

Full sources

proc_layout.c — addresses of the process's segments, heap and stack, and the mmap threshold.

/* Раздел 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;
}

Next: section 136 — fork, exec, wait and signals.

Open in the interactive handbook