Build and Tooling

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C has no official build system, package manager or formatter — which means the toolchain is assembled from independent parts. This page is the practical catalogue of those parts and the flags worth standardizing on.

Compilers and Their Invocation

# GCC and Clang share almost all of their command-line interface.
$ gcc   -std=c23 -Wall -Wextra -o app main.c util.c
$ clang -std=c23 -Wall -Wextra -o app main.c util.c

# Separate compilation, which is what a real build does:
$ clang -std=c23 -Wall -Wextra -c main.c -o main.o
$ clang -std=c23 -Wall -Wextra -c util.c -o util.o
$ clang main.o util.o -o app -lm

# Useful stage-by-stage flags:
$ clang -E main.c            # preprocess only
$ clang -S main.c            # emit assembly
$ clang -c main.c            # compile to an object file
$ clang -MMD -MP -c main.c   # also write main.d, header dependencies for make

# Include paths, defines, libraries:
$ clang -std=c23 -Iinclude -DVERSION='"1.2.3"' -DNDEBUG \
        -Llib -lmylib -lm -o app main.c

# MSVC (Developer Command Prompt):
> cl /std:clatest /W4 /permissive- main.c util.c /Fe:app.exe

-std= is worth pinning explicitly in every project:

Edition GCC / Clang Notes

C23

-std=c23

GCC 14+, Clang 18+. Earlier versions spell it -std=c2x.

C17

-std=c17

The safe conservative default; universally supported.

C11

-std=c11

Needed if MSVC or an old toolchain is in scope.

C99

-std=c99

Only for legacy targets.

GNU dialects

-std=gnu23, -std=gnu17

The standard plus GNU extensions — often the compiler’s default, which is exactly why you should pin the strict one.

Add -pedantic (or -Wpedantic) to be told when you use an extension, and -pedantic-errors to refuse to.

Warning Flags

The single highest-value configuration decision in a C project. -Wall is not "all warnings":

Flag Catches

-Wall -Wextra

The baseline. Unused values, sign comparisons, uninitialized reads, missing braces, fall-through.

-Werror

Turns every warning into an error. Essential in CI; -Wno-error=…​ for specific exemptions during a migration.

-Wpedantic

Non-standard constructs.

-Wshadow

A local that hides an outer name.

-Wconversion -Wsign-conversion

Implicit narrowing and signedness changes. Noisy, and finds real bugs.

-Wcast-qual -Wcast-align

Casting away const, or to a stricter alignment.

-Wstrict-prototypes -Wmissing-prototypes

A function without a prototype, or a public function with no declaration.

-Wwrite-strings

Gives string literals const char[] type, so writing to one is an error.

-Wvla

Any variable-length array (banned in most embedded/safety codebases).

-Wdouble-promotion

Accidental floatdouble promotion, which matters on an MCU.

-Wformat=2

Stricter printf checking, including -Wformat-security.

-Wnull-dereference -Wduplicated-cond -Wlogical-op (GCC)

Logic mistakes -Wall misses.

-fanalyzer (GCC 10+)

Full static analysis: double frees, leaks, null dereferences across functions.

A configuration worth copying into a new project:

# Development / CI
CFLAGS = -std=c23 -Wall -Wextra -Werror -Wpedantic -Wshadow -Wconversion \
         -Wstrict-prototypes -Wmissing-prototypes -Wwrite-strings -Wvla \
         -Wcast-qual -Wformat=2 -Og -g -fsanitize=address,undefined

# Release
CFLAGS = -std=c23 -Wall -Wextra -O2 -DNDEBUG -D_FORTIFY_SOURCE=3 \
         -fstack-protector-strong -flto

-D_FORTIFY_SOURCE=3 and -fstack-protector-strong add run-time checks to the standard library calls and to stack frames for very little cost — keep them in release builds.

Make

make is universal, and a correct C Makefile is short. The essential trick is -MMD -MP, which makes the compiler generate the header dependencies so editing a header rebuilds what includes it:

CC      := clang
CFLAGS  := -std=c23 -Wall -Wextra -Werror -Og -g -MMD -MP -Iinclude
LDFLAGS :=
LDLIBS  := -lm

SRC := $(wildcard src/*.c)
OBJ := $(SRC:src/%.c=build/%.o)
DEP := $(OBJ:.o=.d)
BIN := build/app

.PHONY: all clean test asan
all: $(BIN)

$(BIN): $(OBJ)
	@mkdir -p $(dir $@)
	$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS)

build/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -c $< -o $@

asan: CFLAGS += -fsanitize=address,undefined
asan: LDFLAGS += -fsanitize=address,undefined
asan: clean all

test: $(BIN)
	./$(BIN) --self-test

clean:
	rm -rf build

-include $(DEP)

Note the tab-indented recipes (make requires tabs), the -include $(DEP) at the end, and the @mkdir -p so the build directory need not be committed.

CMake

For anything with dependencies or multiple platforms, CMake is the de-facto standard:

cmake_minimum_required(VERSION 3.21)   # CMAKE_C_STANDARD 23 needs 3.21
project(myapp VERSION 1.0.0 LANGUAGES C)

set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)          # -std=c23, not -std=gnu23
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # writes compile_commands.json for clangd/clang-tidy

# A library, with its public headers declared for consumers.
add_library(mylib src/util.c src/parse.c)
target_include_directories(mylib PUBLIC include)
target_compile_options(mylib PRIVATE
    $<$<C_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic;-Wshadow;-Wconversion>
    $<$<C_COMPILER_ID:MSVC>:/W4>
)

# The executable.
add_executable(myapp src/main.c)
target_link_libraries(myapp PRIVATE mylib m)

# Sanitizers behind an option.
option(ENABLE_SANITIZERS "Build with ASan and UBSan" OFF)
if(ENABLE_SANITIZERS)
    target_compile_options(myapp PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
    target_link_options(myapp PRIVATE -fsanitize=address,undefined)
endif()

# Tests through CTest.
include(CTest)
add_executable(test_parse tests/test_parse.c)
target_link_libraries(test_parse PRIVATE mylib)
add_test(NAME parse COMMAND test_parse)
$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON
$ cmake --build build -j
$ ctest --test-dir build --output-on-failure

CMAKE_EXPORT_COMPILE_COMMANDS is the one line that makes every other tool on this page work: clangd, clang-tidy and cppcheck all read compile_commands.json to learn your include paths and defines.

Other build systems in real use: Meson (fast, clean syntax), Ninja (a backend for CMake/Meson rather than a front end), Bazel for large monorepos, and plain shell scripts for small projects. pkg-config remains the standard way to discover a system library’s flags: clang $(pkg-config --cflags --libs libcurl) main.c.

Sanitizers and Valgrind

# AddressSanitizer + UndefinedBehaviorSanitizer: the default pairing.
$ clang -std=c23 -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer -o app *.c
$ ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1 ./app

# ThreadSanitizer -- races and lock-order inversions. Mutually exclusive with ASan.
$ clang -std=c23 -g -fsanitize=thread -o app *.c && ./app

# MemorySanitizer -- uninitialized reads (Clang only; needs instrumented deps).
$ clang -std=c23 -g -fsanitize=memory -fPIE -pie -o app *.c

# Fail the build in CI instead of just printing:
$ clang -std=c23 -g -fsanitize=undefined -fno-sanitize-recover=all -o app *.c

# Valgrind: no rebuild, much slower, catches leaks and invalid accesses.
$ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./app
$ valgrind --tool=helgrind ./app        # races
$ valgrind --tool=cachegrind ./app      # cache behavior

Installing Valgrind

# Debian / Ubuntu
$ sudo apt-get install valgrind

# Fedora / RHEL
$ sudo dnf install valgrind

# macOS (Homebrew) -- the mainline formula lags behind the newest macOS/Xcode releases;
# on a recent macOS, use the community tap instead:
$ brew install valgrind                                    # older macOS
$ brew tap LouisBrunner/valgrind && brew install --HEAD LouisBrunner/valgrind/valgrind

# Windows -- no native build; run it inside WSL (Ubuntu), same commands as above.

Valgrind supports Linux, FreeBSD, Solaris and Android natively; macOS support trails new OS releases; there is no native Windows build, so a Windows-only team needs WSL or a Linux CI runner — this is why the CI example below targets ubuntu-latest.

Interpreting Leak-Check Output

Memcheck’s leak report groups every unfreed block into one of four categories, from worst to most benign:

  • definitely lost — no pointer chain to the block exists anywhere; this is a real leak.

  • indirectly lost — the block itself is still pointed to, but the block(s) pointing to it are lost, so it becomes unreachable transitively (freeing the parent leak also fixes this one).

  • possibly lost — only an interior pointer (not a pointer to the block’s start) chains to it; ambiguous, and needs human judgment rather than an automatic fix.

  • still reachable — a pointer to the block still exists at exit; often a static or global that was never meant to be freed, and frequently not a bug.

$ valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./app
==12346== HEAP SUMMARY:
==12346==     definitely lost: 40 bytes in 1 blocks
==12346==     indirectly lost: 16 bytes in 1 blocks
==12346==       possibly lost: 0 bytes in 0 blocks
==12346==     still reachable: 72 bytes in 3 blocks

--show-leak-kinds=all is required to see anything beyond the default (definite,possible) — indirectly lost and still reachable are hidden otherwise. --track-origins=yes is a separate flag: it identifies where an uninitialized value originally came from, at the cost of roughly a further 2x slowdown.

Suppressions, Exit Codes and Machine-Readable Output

--error-exitcode=<n> is the flag most naive CI setups omit — without it, Valgrind always exits 0 regardless of what it finds, so a real leak never fails the build. Pair it with --suppressions= to silence known/ third-party noise (a libc or driver leak you cannot fix) rather than letting it drown out real findings: --gen-suppressions=all prints a suppression entry for each current finding, which can be reviewed and appended to a checked-in .valgrind.supp file:

{
   <libc_known_leak>
   Memcheck:Leak
   ...
   fun:malloc
   obj:*/libc.so*
}

Apply it with --suppressions=.valgrind.supp. For a CI system that wants to parse results rather than just check the exit code, --xml=yes --xml-file=report.xml produces machine-readable output instead of the plain console report.

Static Analysis

Finds bugs without running the program, including on paths your tests never take:

# GCC's built-in analyzer -- no extra tooling required.
$ gcc -std=c23 -fanalyzer -Wanalyzer-too-complex -c src/*.c

# Clang's analyzer.
$ clang --analyze -Xanalyzer -analyzer-output=text src/*.c
$ scan-build make                       # wraps a whole build, produces an HTML report

# clang-tidy: analysis plus modernization and naming checks.
$ clang-tidy -p build src/*.c \
    -checks='clang-analyzer-*,bugprone-*,cert-*,performance-*,readability-*'

# cppcheck: independent implementation, different findings. Worth running as well.
$ cppcheck --enable=all --std=c23 --inline-suppr --error-exitcode=1 -Iinclude src/

# Include-what-you-use: reports headers you include but do not need, and vice versa.
$ include-what-you-use -Iinclude src/main.c

Configure clang-tidy per project with a .clang-tidy file, and treat its output as a to-do list rather than a gate on day one — it is opinionated.

Formatting

clang-format ends every discussion about brace placement mechanically:

$ clang-format --style=LLVM -i src/*.c include/*.h
$ clang-format --dump-config --style=GNU > .clang-format     # start from a named style
$ git diff -U0 --no-color | clang-format-diff -p1 -i         # format only what changed
$ clang-format --dry-run --Werror src/*.c                    # CI check

A .clang-format worth starting from:

BasedOnStyle: LLVM
Language: Cpp
IndentWidth: 4
ColumnLimit: 100
PointerAlignment: Right
AlignAfterOpenBracket: Align
AllowShortIfStatementsOnASingleLine: false
BreakBeforeBraces: Linux
SortIncludes: CaseSensitive

Commit the file, run the formatter in a pre-commit hook, and never review whitespace again. (Language: Cpp is correct for C files — clang-format uses that name for the whole C family.)

Debugging

# Build with debug info and no (or light) optimization.
$ clang -std=c23 -g3 -Og -o app *.c

# GDB
$ gdb ./app
(gdb) break main                # or  break util.c:42  /  break parse if depth > 3
(gdb) run --input data.txt
(gdb) next / step / finish / continue
(gdb) print *node               # print a struct through a pointer
(gdb) print array[0]@10         # print 10 elements
(gdb) ptype struct Node         # show a type's layout
(gdb) backtrace full            # frames with locals
(gdb) watch counter             # break when a value changes
(gdb) info locals / info registers
(gdb) x/16xb buffer             # examine 16 bytes in hex

# Post-mortem: enable cores, then load one.
$ ulimit -c unlimited && ./app          # crashes, writes a core
$ gdb ./app core

# LLDB (the same session, different syntax)
$ lldb ./app
(lldb) breakpoint set --name main
(lldb) run
(lldb) frame variable
(lldb) memory read --size 1 --format x --count 16 buffer

# rr: record once, then replay and step BACKWARDS. Invaluable for rare bugs.
$ rr record ./app && rr replay

Debug with -Og -g3 rather than -O0: -g3 includes macro definitions, and -Og keeps the code recognizable while remaining fast enough to reproduce timing-dependent bugs.

Documentation with Doxygen

/**
 * @file util.h
 * @brief Small utilities shared across the project.
 */

#ifndef PROJECT_UTIL_H
#define PROJECT_UTIL_H

#include <stddef.h>

/**
 * @brief Copies at most @p size bytes of @p source into @p destination.
 *
 * The result is always NUL-terminated, unlike strncpy.
 *
 * @param destination Buffer to write to; must not be null.
 * @param size        Size of @p destination in bytes, including the terminator.
 * @param source      NUL-terminated string to copy; must not be null.
 * @return The length of @p source, so a value >= @p size means the copy was
 *         truncated.
 * @warning Passing overlapping buffers is undefined behavior.
 * @see util_append
 */
size_t util_copy(char *destination, size_t size, const char *source);

#endif /* PROJECT_UTIL_H */
$ doxygen -g Doxyfile           # generate a default configuration
$ doxygen Doxyfile              # build the documentation into html/

The settings worth changing in Doxyfile: OPTIMIZE_OUTPUT_FOR_C = YES, EXTRACT_ALL = NO (so undocumented declarations are reported rather than silently listed), WARN_AS_ERROR = YES in CI, and GENERATE_TREEVIEW = YES.

A CI Pipeline

Putting the above together, the checks worth running on every commit:

$ clang-format --dry-run --Werror src/*.c include/*.h        # formatting
$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON
$ cmake --build build -j                                     # -Werror build
$ ctest --test-dir build --output-on-failure                 # tests under sanitizers
$ for f in src/*.c; do                                       # static analysis
>   gcc -std=c23 -fanalyzer -Iinclude -c "$f" -o /dev/null || exit 1
> done
$ cppcheck --enable=all --std=c23 --error-exitcode=1 -Iinclude src/
$ clang-tidy -p build src/*.c

Build with both GCC and Clang: each finds warnings the other misses, and the difference is free coverage.

Adding a Valgrind Job

name: memcheck

on:
  push:
    branches: [ main ]
  pull_request:
  schedule:
    - cron: '0 3 * * *'   # nightly -- Valgrind is far slower than the per-commit checks above

jobs:
  valgrind:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install Valgrind
        run: sudo apt-get update && sudo apt-get install -y valgrind

      - name: Build (debug, unmodified binary -- Valgrind needs no instrumentation)
        run: |
          clang -std=c23 -g -O0 -o app *.c

      - name: Run under Memcheck
        run: |
          valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
                   --error-exitcode=1 --suppressions=.valgrind.supp \
                   ./app --self-test

Valgrind needs no rebuild or instrumentation and catches what ASan/UBSan miss, but it is roughly 20-30x slower, so it belongs in a separate/scheduled job (as above) rather than the fast per-commit pipeline shown earlier in this section — the two are complementary, not a replacement for one another.

See Also