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

145. UNIX as a development environment: POSIX, getopt, make, CMake, Meson, diff, gdb, valgrind, strace

In Keil (section 4а) the whole environment is one window. In Unix the environment is the shell and a dozen programs, each of which does one thing and combines with the others in a pipeline: the editor writes, make builds, gdb catches, valgrind finds leaks, diff and patch carry changes over, strace shows what the program asks of the kernel. This section walks through this set on one small utility — a counter of lines, words and bytes — which is itself written by the Unix rules. Everything runs in stm-sys:1 (_verify/sys145/).

What it is called in the documentation

Term Meaning
POSIX, Single UNIX Specification the standard for OS interfaces
feature test macro a macro that enables parts of the standard (_POSIX_C_SOURCE)
filter a program that reads stdin and writes stdout
exit status the code a program returns on exit
option, argument a command-line switch, a value for it or an operand
build system the tool that drives compilation
rule, target, prerequisite, recipe the parts of a make rule
generator (CMake) what CMake turns the description into
unified diff the diff format with context lines
breakpoint, backtrace a stop point, the call stack
memory leak, invalid read memory never freed, a read of memory you do not own
system call trace a log of the system calls a program makes
code formatter, coding style a tool that lays out code, the layout rules

145.1 POSIX: one description for all Unix systems

POSIX describes what a system must provide: files, processes, signals, threads, sockets — and which utilities with which options. Linux, BSD and macOS follow it almost entirely, Windows only through compatibility layers (Cygwin, MSYS2, WSL). In code the standard is enabled with a macro before the first #include:

#define _POSIX_C_SOURCE 200809L
#define _XOPEN_SOURCE 700
_POSIX_VERSION 200809, _XOPEN_VERSION 700, sysconf(_SC_VERSION) 200809

Version 2008 (the 2017 edition has the same interfaces) is what you can rely on everywhere. Without the macros, gcc -std=c11 hides everything that is not in C itself: fileno, strdup, getline from <stdio.h> and <string.h> become "implicitly declared". getopt_long is a GNU extension (also available in BSD) from <getopt.h>, and this header always declares it; plain POSIX has only getopt. With -std=gnu11 (the default) glibc enables both POSIX and its own extensions. Firmware does not have this: newlib on a microcontroller provides standard C, and POSIX calls such as open, read, write exist only on top of the stubs _read, _write, _sbrk, which the firmware writes itself (section 4в).

Unix tools around a program: build, debugger, memory checker, tracing, diff and patch


145.2 A utility by the Unix rules

There are four rules: read stdin if no files are given; write the result to stdout and errors to stderr; return 0 on success; accept options in the conventional form. getopt_long parses the options:

    static const struct option longopts[] = {
        { "lines", no_argument, 0, 'l' }, { "words", no_argument, 0, 'w' }, { "bytes", no_argument, 0, 'c' },
        { "help", no_argument, 0, 'h' }, { "version", no_argument, 0, 'V' }, { 0, 0, 0, 0 } };
    while ((c = getopt_long(argc, argv, "lwchV", longopts, NULL)) != -1) {

The string "lwchV" lists the short options (a colon after a letter would mean an argument), the array lists the long ones. After the loop optind points to the first non-option. A run:

printf 'a b\nc\n' | ./tool
      2       3       6
./tool -l count.c tool.c
     25 count.c
     47 tool.c
./tool --bogus            → ./tool: unrecognized option '--bogus'   rc=2
./tool nofile.txt         → nofile.txt: No such file or directory   rc=1

An unknown option gives code 2 and the help text on stderr; a missing file gives a message via perror, the remaining files are still processed, code 1. This way the utility fits into a pipeline: cat *.c | ./tool -l, ./tool -c *.h | sort -n.


145.3 make: rules and dependencies

make knows which file is made from which, and rebuilds only what is out of date. The utility's Makefile:

CC      = gcc
CFLAGS  ?= -Wall -Wextra -Wpedantic -std=c11 -O2 -MMD -MP
VERSION := 0.2
SRC     := tool.c count.c
OBJ     := $(SRC:.c=.o)

tool: $(OBJ)
    $(CC) $(CFLAGS) -o $@ $^

%.o: %.c
    $(CC) $(CFLAGS) -DTOOL_VERSION='"$(VERSION)"' -c $< -o $@

-include $(OBJ:.o=.d)

A rule is "target: prerequisites" plus a recipe indented with a tab (spaces are an error). $@ is the target, $< the first prerequisite, $^ all of them. The pattern rule %.o: %.c covers any .c. The -MMD -MP options make the compiler write a .d file next to the object with the list of headers, and -include pulls them in — this is how make learns that count.o depends on count.h:

count.o: count.c count.h

The check: after touch count.h both objects and the program are rebuilt — three compiler commands; a repeated make says "'tool' is up to date". A variable from the command line (make CC=clang) overrides any assignment in the Makefile. ?= sets a value only if the variable did not come from the environment: this way CFLAGS=-Og make replaces the flags. For CC ?= does not work: make has a built-in CC = cc, and CC ?= gcc would change nothing, so the compiler is set simply with CC = gcc. Targets without a file (clean, test) are marked .PHONY: clean test: otherwise a file with the same name in the directory makes make treat the target as done and answer 'clean' is up to date. On a Windows volume inside a container the timestamps are coarse and make does not see the change — build on a native file system.

The make dependency graph: the links to count.h came from the .d files; after touch count.h both objects and the program are out of date — three compiler commands


145.4 CMake, Ninja, Meson

make describes the build for one system; CMake and Meson describe the project and generate the build — for make, Ninja, Visual Studio, Xcode:

cmake_minimum_required(VERSION 3.10)
project(tool LANGUAGES C VERSION 0.2)
set(CMAKE_C_STANDARD 11)
add_library(count STATIC count.c)
add_executable(tool tool.c)
target_compile_definitions(tool PRIVATE TOOL_VERSION="${PROJECT_VERSION}")
target_link_libraries(tool PRIVATE count)
enable_testing()
add_test(NAME version COMMAND tool --version)
cmake -S . -B build-cmake -G Ninja -DCMAKE_BUILD_TYPE=Release
ninja -C build-cmake          → [4/4] Linking C executable tool
cd build-cmake && ctest       → 100% tests passed, 0 tests failed out of 1

Ninja is not a language but an executor: it reads the generated rules and builds faster than make thanks to exact dependencies and parallelism; its files are not normally written by hand. Meson is the same description, shorter, also through Ninja:

project('tool', 'c', version: '0.2', default_options: ['c_std=c11', 'warning_level=2'])
count = static_library('count', 'count.c')
exe = executable('tool', 'tool.c', link_with: count, c_args: ['-DTOOL_VERSION="' + meson.project_version() + '"'])
test('version', exe, args: ['--version'])
meson setup build-meson; ninja -C build-meson   → [4/4] Linking target tool
meson test -C build-meson                        → Ok: 1

All three build the same program with version 0.2 and one test. The choice: your own small project — make; a library for others — CMake (everyone supports it); a new project with no legacy — Meson. For firmware, CMake and west are covered in section 115.


145.5 diff and patch

A change as a file: diff -u old new prints the differences with three lines of context, and patch applies them to another copy:

--- /tmp/count.h.orig   2026-09-13 16:27:21.298406759 +0000
+++ /tmp/count.h.new    2026-09-13 16:27:21.298406759 +0000
@@ -2,7 +2,7 @@
 #define COUNT_H
 #include <stdio.h>
 #ifndef TOOL_VERSION
-#define TOOL_VERSION "0.1"
+#define TOOL_VERSION "0.3"
 #endif

@@ -2,7 +2,7 @@ means from which line and how many lines in each version; - and + mean removed and added. The same format is used by git diff, by patches sent to the kernel mailing lists and by code review. For directories use diff -ruN a b, and to apply, patch -p1 < file.


145.6 gdb: stop and look

The debugger for programs on a computer is the same gdb used for a microcontroller through ST-Link (section 4), only without the server. Build with -g -O0, then break, run, bt, print, next, step, finish. A batch run that stops in count_stream and shows what it returned:

gdb -q -batch -ex 'break count_stream' -ex run -ex 'bt 1' -ex finish ./tool_dbg
Breakpoint 1, count_stream (f=0x75357ab8aa80 <_IO_2_1_stdin_>) at count.c:8
#0  count_stream (f=0x75357ab8aa80 <_IO_2_1_stdin_>) at count.c:8
Value returned is $1 = {lines = 1, words = 3, bytes = 6}

gdb showed the argument (f is stdin) and the result structure field by field. Post-mortem debugging from a core file is in section 138; -O2 hides variables in registers, so for debugging use -Og.


145.7 valgrind: memory errors without rebuilding

Three classic errors in one program, selected by an option:

    if (which == 2) printf("за границей: %c\n", p[16]);        /* Invalid read of size 1 */
    if (which == 3) { free(p); printf("после free: %c\n", p[0]); return 0; }   /* Invalid read */
    printf("строка: %s\n", p);
    if (which != 1) free(p);                                    /* -q1: утечка 16 байт */
       строка: 0123456789abcde
-q1    16 bytes in 1 blocks are definitely lost in loss record 1 of 1
-q2    Invalid read of size 1
-q3    Invalid read of size 1

Without options the run is clean. valgrind --leak-check=full reports the leak with its size and the allocation stack; the out-of-bounds read and the read after free — with the address and the line. The program was not rebuilt for this: valgrind runs it on its own virtual machine, 20–50 times slower. A fast alternative is -fsanitize=address at build time (section 123): 2–3 times slower, but it needs a rebuild. Other tools from the same package: helgrind — races (section 139), cachegrind — cache misses, callgrind — a call profile.

A 16-byte block: a leak, a read past the end and a read after free — and what valgrind says about each


145.8 strace and ltrace: what the program asks of the kernel

strace intercepts system calls, ltrace intercepts calls to library functions. The -c summary for ./tool count.c:

strace -c:  всего вызовов: 40 — mmap 8, newfstatat 4, read 3, openat 3, mprotect 3
ltrace -c:  fgetc 741, __ctype_b_loc 740, fopen 1, printf 4

Forty system calls — almost all of them from the dynamic linker (mmap, mprotect while loading libc, two openat out of three and one read out of three); the actual work is one openat and two read: the first returned all 740 bytes of the file (stdio asks for a 4 KB block), the second returned 0, end of file. The 741 fgetc calls — 740 bytes and one EOF — take data from the buffer without touching the kernel. strace -p PID attaches to a running process, -e trace=network filters network calls, -f follows child processes. It is the first tool to reach for when a program "hangs": strace shows in which call.


145.9 Code layout: indent, astyle, clang-format

Braces and indentation are not argued about in code review — a program puts them in order. You write the style down once, and the formatter rewrites any source to match it. The input for the experiment is a function squeezed into one line (messy.c):

static int sum(const int*a,int n){int s=0;for(int i=0;i<n;i++){if(a[i]>0)s+=a[i];}return s;}

Three formatters from Debian 12 — GNU indent, Artistic Style (astyle) and clang-format — expanded it the same way in meaning and differently in the details. indent -linux is the Linux kernel style: tabs, the function brace on its own line, the if body on the next line:

static int sum(const int *a, int n)
{
    int s = 0;
    for (int i = 0; i < n; i++) {
        if (a[i] > 0)
            s += a[i];
    }
    return s;
}

astyle with the kr style by default leaves if (a[i] > 0)s += a[i]; on one line and const int*a as it is. The options --break-one-line-headers (-xb), --align-pointer=name (-k3) and --add-braces (-j) break the line, attach * to the name and add the curly braces — the very ones that MISRA rule 15.6 from section 129 requires:

static int sum(const int *a, int n)
{
    int s = 0;
    for (int i = 0; i < n; i++) {
        if (a[i] > 0) {
            s += a[i];
        }
    }
    return s;
}

clang-format takes the style from a .clang-format file in the source directory or above — one file at the project root for everyone:

BasedOnStyle: LLVM
IndentWidth: 4
BreakBeforeBraces: Linux
AllowShortFunctionsOnASingleLine: None
ColumnLimit: 100

The result is like indent's, only with a four-space indent. For a build pipeline clang-format has a check mode: --dry-run --Werror writes nothing, and on an unformatted file it reports an error and a non-zero code:

messy.c:2:25: error: code should be clang-formatted [-Wclang-format-violations]
неоформленный: rc=1, оформленный: rc=0

Formatting does not change the program: messy.c and its formatted copy under the same name produced object files of 1464 bytes each that matched byte for byte. astyle prints Formatted for a file and Unchanged for one already formatted; the -n option leaves no .orig copies. Which formatter you choose does not matter — what matters is that there is one per project and it runs by itself: from the editor on save or as a check before the build. Reformatting the whole project is done in a separate commit so that it does not hide meaningful changes.


145.10 Pitfalls

Symptom Cause
strdup/fileno "implicitly declared" -std=c11 without _POSIX_C_SOURCE
make: *** missing separator spaces instead of a tab in the recipe
make does not rebuild after a header change no dependencies on .h — -MMD -MP and -include
make clean does nothing there is a file named clean in the directory, the target is not declared .PHONY
make always rebuilds everything file timestamps are in the future
ninja: error: loading 'build.ninja' the build was not generated: run cmake/meson setup first
patch asks "File to patch" wrong -p: the directory level does not match
"optimized out" in gdb -O2; build with -Og or -O0
valgrind is silent, but the program crashes the error is on the stack — valgrind sees the heap; -fsanitize=address
strace: Operation not permitted no ptrace permission (container without SYS_PTRACE)
the utility "hangs" without files it reads stdin — this is intended; Ctrl-D ends it
clang-format formatted the code not the way the project does it did not find .clang-format in the file's directory or above — it used the LLVM style
astyle left if (x)y; on one line it does not break it by default: --break-one-line-headers, braces — --add-braces
the change is lost behind reformatting reformat the whole project in a separate commit

145.11 Summary

Full sources

tool.c — a Unix-style utility: getopt_long, stdin or files, exit code.

/* Раздел 145: утилита в духе Unix — getopt_long, чтение stdin или файлов, код возврата,
   ошибки в stderr. Считает строки, слова и байты; -l/-w/-c выбирают, --help и --version. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <getopt.h>
#include "count.h"

static void usage(FILE *f, const char *prog)
{
    fprintf(f, "использование: %s [-l] [-w] [-c] [файл...]\n"
               "  -l, --lines    строки\n  -w, --words    слова\n  -c, --bytes    байты\n"
               "  -h, --help     эта справка\n  -V, --version  версия\n", prog);
}

int main(int argc, char **argv)
{
    int show_l = 0, show_w = 0, show_c = 0, c;
    static const struct option longopts[] = {
        { "lines", no_argument, 0, 'l' }, { "words", no_argument, 0, 'w' }, { "bytes", no_argument, 0, 'c' },
        { "help", no_argument, 0, 'h' }, { "version", no_argument, 0, 'V' }, { 0, 0, 0, 0 } };
    while ((c = getopt_long(argc, argv, "lwchV", longopts, NULL)) != -1) {
        switch (c) {
        case 'l': show_l = 1; break;
        case 'w': show_w = 1; break;
        case 'c': show_c = 1; break;
        case 'h': usage(stdout, argv[0]); return 0;
        case 'V': puts("tool " TOOL_VERSION); return 0;
        default: usage(stderr, argv[0]); return 2;              /* неизвестный ключ: 2, как у GNU */
        }
    }
    if (!show_l && !show_w && !show_c) show_l = show_w = show_c = 1;
    int status = 0;
    if (optind == argc) {                                       /* без файлов — stdin: фильтр в конвейере */
        struct counts k = count_stream(stdin);
        print_counts(k, show_l, show_w, show_c, NULL);
    }
    for (int i = optind; i < argc; i++) {
        FILE *f = fopen(argv[i], "r");
        if (!f) { perror(argv[i]); status = 1; continue; }     /* ошибка — в stderr, продолжаем */
        struct counts k = count_stream(f);
        fclose(f);
        print_counts(k, show_l, show_w, show_c, argv[i]);
    }
    return status;
}

count.h — the header of the line, word and byte counter.

#ifndef COUNT_H
#define COUNT_H
#include <stdio.h>
#ifndef TOOL_VERSION
#define TOOL_VERSION "0.1"
#endif
struct counts { long lines, words, bytes; };
struct counts count_stream(FILE *f);
void print_counts(struct counts k, int l, int w, int c, const char *name);
#endif

leaky.c — three memory errors for valgrind.

/* Раздел 145: три ошибки памяти для valgrind — утечка, чтение за границей, использование после free;
   ключ -q1/-q2/-q3 включает одну, без ключа программа честная. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv)
{
    int which = argc > 1 ? argv[1][2] - '0' : 0;
    char *p = malloc(16);
    strcpy(p, "0123456789abcde");
    if (which == 2) printf("за границей: %c\n", p[16]);        /* Invalid read of size 1 */
    if (which == 3) { free(p); printf("после free: %c\n", p[0]); return 0; }   /* Invalid read */
    printf("строка: %s\n", p);
    if (which != 1) free(p);                                    /* -q1: утечка 16 байт */
    return 0;
}

posix_ver.c — which POSIX version the system declares.

/* Раздел 145: какую версию POSIX и X/Open объявляет система при _POSIX_C_SOURCE / _XOPEN_SOURCE. */
#define _POSIX_C_SOURCE 200809L
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("_POSIX_VERSION %ld, _XOPEN_VERSION %d, sysconf(_SC_VERSION) %ld\n",
           (long)_POSIX_VERSION, _XOPEN_VERSION, sysconf(_SC_VERSION));
    return 0;
}

Next: section 146 — object-oriented C.

Open in the interactive handbook