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 |
|
GCC 14+, Clang 18+. Earlier versions spell it |
C17 |
|
The safe conservative default; universally supported. |
C11 |
|
Needed if MSVC or an old toolchain is in scope. |
C99 |
|
Only for legacy targets. |
GNU dialects |
|
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 |
|---|---|
|
The baseline. Unused values, sign comparisons, uninitialized reads, missing braces, fall-through. |
|
Turns every warning into an error. Essential in CI; |
|
Non-standard constructs. |
|
A local that hides an outer name. |
|
Implicit narrowing and signedness changes. Noisy, and finds real bugs. |
|
Casting away |
|
A function without a prototype, or a public function with no declaration. |
|
Gives string literals |
|
Any variable-length array (banned in most embedded/safety codebases). |
|
Accidental |
|
Stricter |
|
Logic mistakes |
|
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 |
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
staticor 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
-
Getting Started — the compilation pipeline and the minimum flags.
-
Testing — test frameworks and running them through CTest.
-
Performance — optimization levels and profilers.
-
Error Handling and Program Failure — what each sanitizer catches.
-
C++: Build and Tooling — the same toolchain, plus vcpkg/Conan and the C++-specific analyzer configuration.
References
-
The GCC manual — and specifically Warning Options and Static Analyzer Options.
-
clang-format and clang-tidy.
-
GDB manual and LLDB tutorial.