Building Programs and Libraries
Typing compiler commands by hand stops working at about three files. This week replaces them with a build system, turns week 28's module into a real library, and opens the linker up far enough that undefined reference stops being mysterious.
- Write a Makefile with automatic dependency tracking and multiple configurations.
- Write an equivalent CMake configuration and say when each tool is the better choice.
- Build and link both a static and a shared library.
- Explain why linking order matters and diagnose an unresolved symbol.
- Inspect an object file with
nmandobjdump.
1Make
A Makefile is a set of rules: a target, the prerequisites it depends on, and the commands to rebuild it. Make rebuilds a target only when a prerequisite is newer.
target: prerequisites
<TAB>commandThe recipe line must begin with a literal tab. Spaces produce Makefile:4: *** missing separator. Stop. — the single most common Make error, and invisible in most editors. Configure your editor to keep real tabs in Makefiles.
A usable Makefile
CC := gcc
CFLAGS := -std=c17 -Wall -Wextra -g -MMD -MP
LDFLAGS :=
LDLIBS :=
SRCS := main.c intarray.c
OBJS := $(SRCS:.c=.o)
DEPS := $(OBJS:.o=.d)
TARGET := app
.PHONY: all clean debug release asan test
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
debug: CFLAGS += -O0 -DDEBUG
debug: $(TARGET)
release: CFLAGS += -O2 -DNDEBUG
release: $(TARGET)
asan: CFLAGS += -fsanitize=address,undefined -O1
asan: LDFLAGS += -fsanitize=address,undefined
asan: $(TARGET)
test: asan
./$(TARGET)
clean:
rm -f $(OBJS) $(DEPS) $(TARGET)
-include $(DEPS)| Symbol | Means |
|---|---|
$@ | The target |
$< | The first prerequisite |
$^ | All prerequisites |
:= | Immediate assignment (prefer over =) |
.PHONY | These targets are not files |
The two lines that matter most
-MMD -MP makes the compiler emit a .d file listing every header each source includes. -include $(DEPS) pulls those in as extra rules. The result: editing intarray.h rebuilds every file that includes it. Without this, Make tracks only .c files and a header change silently produces a stale, inconsistent binary — the most confusing class of build bug there is.
.PHONY tells Make that clean is not a file. Without it, a file named clean in the directory would make make clean do nothing.
make # build
make -j8 # build with eight parallel jobs
make asan # build instrumented
make clean # remove products
make -n # show the commands without running themWarning profiles
-Wall -Wextra is a floor, not a ceiling. Several valuable warnings are in neither, and a project decides once which set it holds itself to:
# beyond -Wall -Wextra, each of these is worth its noise
-Wpedantic strict conformance to the chosen standard
-Wshadow a local hiding an outer name
-Wconversion implicit narrowing conversions
-Wsign-conversion signed and unsigned conversions
-Wcast-qual casting away const
-Wstrict-prototypes () where (void) was meant
-Wwrite-strings string literals typed as char *
-Wmissing-prototypes a public function with no declaration-Wshadow caught a real bug in week 17. -Wconversion catches the silent narrowing of week 7, at the cost of being noisy on existing code. -Wstrict-prototypes enforces week 11's (void) rule. Turn them on early on a new project; on an existing one, add them one at a time and fix the fallout before adding the next.
The final step is to make warnings fatal:
release: CFLAGS += -WerrorA warning that nobody fixes is noise, and noise hides the next real warning. Making the build fail is the only mechanism that keeps the count at zero. Apply it in the continuous integration configuration of week 35 rather than in the everyday developer build, so that work in progress is not blocked by an unused variable.
2CMake
CMake generates build files rather than building directly, which is how a single configuration produces Makefiles on Linux, Ninja builds, and Visual Studio projects on Windows.
cmake_minimum_required(VERSION 3.16)
project(intarray_demo C)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
add_compile_options(-Wall -Wextra)
add_library(intarray STATIC intarray.c)
target_include_directories(intarray PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(app main.c)
target_link_libraries(app PRIVATE intarray)
option(ENABLE_ASAN "Build with AddressSanitizer" OFF)
if(ENABLE_ASAN)
target_compile_options(app PRIVATE -fsanitize=address,undefined)
target_link_options(app PRIVATE -fsanitize=address,undefined)
endif()cmake -S . -B build
cmake --build build -j8
cmake -S . -B build-asan -DENABLE_ASAN=ON
cmake --build build-asan| Make | CMake |
|---|---|
| Direct, no extra tool | Generates for several build systems |
| Unix-centric | Cross-platform, including Visual Studio |
| You write dependency handling | Handled for you |
| Fine for a small project | Standard for anything with dependencies |
Learn Make first: it exposes what a build actually is, and CMake ultimately emits one. Use CMake for anything that must build on more than one platform or consume third-party libraries.
3Static libraries
A static library is an archive of object files. The linker copies the ones you actually use into your executable.
gcc -std=c17 -Wall -Wextra -c intarray.c -o intarray.o
ar rcs libintarray.a intarray.o # r=insert c=create s=index
gcc main.c -L. -lintarray -o app # -L where, -l which
./app # no runtime dependency-lintarray means "find libintarray.a or libintarray.so" — the lib prefix and the extension are added for you. -L. adds the current directory to the search path.
The resulting executable is self-contained: the library's code is inside it, and the .a file is no longer needed to run.
4Shared libraries
gcc -std=c17 -Wall -Wextra -fPIC -c intarray.c -o intarray.o
gcc -shared intarray.o -o libintarray.so
gcc main.c -L. -lintarray -o app
LD_LIBRARY_PATH=. ./app # must be findable at run time-fPIC — position-independent code — is required because a shared library can be loaded at any address, so it must not contain absolute addresses. Omit it and the link fails with a relocation error.
The LD_LIBRARY_PATH is needed because the loader searches standard directories, not the one you built in. That is a development convenience; week 45 covers installing properly and embedding a search path.
Static (.a) | Shared (.so, .dll, .dylib) | |
|---|---|---|
| Code lives | In the executable | In a separate file |
| Executable size | Larger | Smaller |
| Deployment | One file | Library must be present |
| Fixing a library bug | Relink every program | Replace one file |
| Memory with many users | One copy each | Shared between processes |
| Startup | Immediate | Dynamic linking cost |
5Linking order and symbol resolution
The traditional Unix linker processes its arguments left to right, keeping a list of symbols still undefined. When it reaches a library it takes only the members that resolve something currently outstanding — and then moves on.
gcc -lintarray main.c -o app # FAILS
gcc main.c -lintarray -o app # worksIn the first form the library is examined before main.o has introduced any undefined symbols, so nothing is taken from it; by the time main.o needs intarray_create, the library has been passed. Libraries go after the objects that use them, and for mutually dependent libraries you may need to list one twice.
This is also why -lm belongs at the end of the command line, as week 24 noted.
Inspecting symbols
nm intarray.o0000000000000000 T intarray_create
0000000000000120 T intarray_destroy
0000000000000094 t grow
U malloc
U realloc| Letter | Meaning |
|---|---|
T | Defined here, external (in .text) |
t | Defined here, static — lowercase means local |
U | Undefined: needed from elsewhere |
D / B | Initialized / zero-initialized data |
R | Read-only data |
Note grow appearing as lowercase t: week 28's static keyword, visible in the object file. Other translation units cannot link against it, which is exactly what was intended.
nm -D libintarray.so | grep ' T ' # the exported interface
nm -u main.o # what main.o still needs
objdump -d intarray.o | head -30 # disassemble
ldd app # shared libraries an executable needs6Worked example: one module, four builds
Using intarray.h, intarray.c, and main.c from week 28.
Build it four ways
# 1. by hand, to remember what the tools automate
gcc -std=c17 -Wall -Wextra -g -c intarray.c -o intarray.o
gcc -std=c17 -Wall -Wextra -g -c main.c -o main.o
gcc intarray.o main.o -o app_manual
# 2. as a static library
ar rcs libintarray.a intarray.o
gcc -std=c17 -Wall -Wextra -g main.c -L. -lintarray -o app_static
# 3. as a shared library
gcc -std=c17 -Wall -Wextra -g -fPIC -c intarray.c -o intarray_pic.o
gcc -shared intarray_pic.o -o libintarray.so
gcc -std=c17 -Wall -Wextra -g main.c -L. -lintarray -o app_shared
# 4. with the Makefile
make clean && make asanls -l app_manual app_static app_shared
LD_LIBRARY_PATH=. ./app_sharedCompare the sizes. The statically linked executable is larger by roughly the size of the library's code; the shared one is smaller but will not run without libintarray.so on the loader's path.
Watch incremental rebuilding work — and then fail
make clean && make
touch main.c && make # rebuilds main.o only, then relinks
touch intarray.h && make # rebuilds BOTH: the .d files did their jobNow remove -MMD -MP from CFLAGS and the -include $(DEPS) line, and repeat:
make clean && make
touch intarray.h && make
make: 'app' is up to date.Make does not know the header exists. Change a struct in it and you get an executable whose two halves disagree about the layout — which manifests as memory corruption, not a build error. This is why dependency generation is not optional.
Reproduce the linking-order failure
gcc -std=c17 main.c -L. -lintarray -o ok
gcc -std=c17 -L. -lintarray main.c -o broken/usr/bin/ld: /tmp/ccXXXX.o: in function `main':
main.c:(.text+0x1f): undefined reference to `intarray_create'
collect2: error: ld returned 1 exit statusIdentical inputs, different order, different outcome. Confirm the library does contain the symbol:
nm libintarray.a | grep intarray_createInspect the symbol table
nm intarray.o | sort -k3
nm -u main.o
nm -D libintarray.so | grep ' T 'Check three things. Every intarray_* function is T — exported. grow is t — local, because it is static. And main.o lists exactly the intarray_* functions it calls as U, which is the precise set the linker must satisfy.
Then remove static from grow, rebuild, and run nm again: it becomes T, exported from the library, and now collides with any other translation unit that happens to define a function called grow.
Compare configurations
make clean && make release && ls -l app && mv app app_release
make clean && make debug && ls -l app && mv app app_debug
make clean && make asan && ls -l appThe release build is smallest and has assertions compiled out; the debug build carries symbols; the sanitizer build is several times larger because of the instrumentation. Having all three as named targets means switching costs one word, which is what makes running the instrumented build routine rather than exceptional.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Spaces instead of a tab in a recipe | missing separator | Use a real tab. |
| No header dependency tracking | Stale objects; inconsistent binary | -MMD -MP plus -include $(DEPS). |
| Library before the objects that use it | undefined reference | Libraries last. |
Shared library without -fPIC | Relocation error at link time | Compile position-independent. |
| Running a shared build without the path | cannot open shared object file | LD_LIBRARY_PATH, or install it — week 45. |
No .PHONY | A file named clean stops the target working | Declare phony targets. |
Recursive = where := was meant | Re-evaluated on each use; slow and surprising | Prefer :=. |
| Sanitizer flags only at compile time | Link errors about missing runtime | Pass them to the link step too. |
8Check yourself
Why does gcc -lfoo main.c fail where gcc main.c -lfoo works?
Because the linker processes arguments left to right and takes from a library only the members that resolve symbols already known to be undefined. Listed first, the library is examined before main.o has introduced any requirements, so nothing is extracted — and by the time the requirement appears, the library is behind it. Libraries go after the objects that use them.
What do -MMD -MP do, and why does a build break without them?
They make the compiler write a dependency file listing every header a source includes, which the Makefile then includes as extra rules. Without them Make sees only .c files, so changing a header rebuilds nothing — leaving object files compiled against different versions of a structure. The result is memory corruption at runtime rather than an error at build time.
Why is -fPIC required for a shared library?
Because a shared library may be loaded at a different address in every process, so its code cannot contain absolute addresses. Position-independent code addresses everything relative to the program counter or through an indirection table. Without it, the link fails with a relocation error.
In nm output, what is the difference between T and t?
Both mean the symbol is defined in the text section of this object file. Uppercase means external linkage — other translation units can link against it. Lowercase means internal linkage, produced by static, so the name is invisible outside this file. Seeing your helpers as t confirms the encapsulation actually took effect.
When would you choose a static library over a shared one?
When single-file deployment matters more than size — a command-line tool distributed on its own, or a program that must run on systems where you cannot install libraries. Shared libraries win when many programs use the same code, when a bug fix should reach them all by replacing one file, or when startup memory across many processes matters.
9Where this leads
Week 30 returns to the code itself, with the abstraction mechanisms C does offer: function pointers, callbacks, generic containers built on void *, variadic functions, and _Generic. Those are what let the IntArray you just packaged become an array of anything — and what make qsort possible at all.