Procedural Programming with C · Advanced · Week 29

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.

By the end of this week you can
  • 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 nm and objdump.

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>command

The 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)
SymbolMeans
$@The target
$<The first prerequisite
$^All prerequisites
:=Immediate assignment (prefer over =)
.PHONYThese 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 them

Warning 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 += -Werror

A 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
MakeCMake
Direct, no extra toolGenerates for several build systems
Unix-centricCross-platform, including Visual Studio
You write dependency handlingHandled for you
Fine for a small projectStandard 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 livesIn the executableIn a separate file
Executable sizeLargerSmaller
DeploymentOne fileLibrary must be present
Fixing a library bugRelink every programReplace one file
Memory with many usersOne copy eachShared between processes
StartupImmediateDynamic 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     # works

In 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.o
0000000000000000 T intarray_create
0000000000000120 T intarray_destroy
0000000000000094 t grow
                 U malloc
                 U realloc
LetterMeaning
TDefined here, external (in .text)
tDefined here, static — lowercase means local
UUndefined: needed from elsewhere
D / BInitialized / zero-initialized data
RRead-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 needs

6Worked 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 asan
ls -l app_manual app_static app_shared
LD_LIBRARY_PATH=. ./app_shared

Compare 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 job

Now 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 status

Identical inputs, different order, different outcome. Confirm the library does contain the symbol:

nm libintarray.a | grep intarray_create

Inspect 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 app

The 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

MistakeWhat happensFix
Spaces instead of a tab in a recipemissing separatorUse a real tab.
No header dependency trackingStale objects; inconsistent binary-MMD -MP plus -include $(DEPS).
Library before the objects that use itundefined referenceLibraries last.
Shared library without -fPICRelocation error at link timeCompile position-independent.
Running a shared build without the pathcannot open shared object fileLD_LIBRARY_PATH, or install it — week 45.
No .PHONYA file named clean stops the target workingDeclare phony targets.
Recursive = where := was meantRe-evaluated on each use; slow and surprisingPrefer :=.
Sanitizer flags only at compile timeLink errors about missing runtimePass 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.