Advanced Make – Libraries, Linking, and Mixed Builds
Summary: Build static and shared libraries with Make, link against external libraries using
pkg-config, create mixed-language projects, and understand thedebian/rulesMakefile format. This tutorial covers the advanced Make patterns you will encounter in real-world projects.
| Key | Value |
|---|---|
| OS | Ubuntu 24.04 LTS |
| Make version | GNU Make 4.3 |
| GCC version | 13 |
| G++ version | 13 |
| Python version | 3.12 |
| Working directory | ~/projects/makefile-tutorial |
0. Prerequisites
- Completion of Tutorial 01 – Make Fundamentals and Tutorial 02 – Make for C and C++
- GCC, G++, and Make installed (
sudo apt install -y build-essential) pkg-configinstalled (sudo apt install -y pkg-config)libcurldevelopment headers installed for the linking example (sudo apt install -y libcurl4-openssl-dev)
1. Static Libraries
A static library is an archive of object files. When you link against it, the linker copies the needed code directly into your executable. The result is a standalone binary with no external dependencies on the library at runtime.
Create a project.
mkdir -p ~/projects/makefile-tutorial/static-lib/srccd ~/projects/makefile-tutorial/static-lib
Create a small math library with two source files.
src/mathlib.h – the header.
#ifndef MATHLIB_H#define MATHLIB_Hint add(int a, int b);int multiply(int a, int b);#endif
src/add.c – addition.
#include "mathlib.h"int add(int a, int b) { return a + b;}
src/multiply.c – multiplication.
#include "mathlib.h"int multiply(int a, int b) { return a * b;}
main.c – a program that uses the library.
#include <stdio.h>#include "src/mathlib.h"int main(void) { printf("3 + 4 = %d\n", add(3, 4)); printf("3 * 4 = %d\n", multiply(3, 4)); return 0;}
Create a Makefile that builds the static library and links it into the executable.
CC = gccCFLAGS = -Wall -Wextra -gAR = arTARGET = calculatorLIB = libmath.aLIB_SRCS = src/add.c src/multiply.cLIB_OBJS = $(LIB_SRCS:.c=.o).PHONY: all cleanall: $(TARGET)# Build the static library$(LIB): $(LIB_OBJS) $(AR) rcs $@ $^# Link the executable against the library$(TARGET): main.o $(LIB) $(CC) main.o -L. -lmath -o $@%.o: %.c $(CC) $(CFLAGS) -c $< -o $@clean: rm -f $(TARGET) $(LIB) main.o $(LIB_OBJS)
Build and run.
make./calculator
gcc -Wall -Wextra -g -c src/add.c -o src/add.ogcc -Wall -Wextra -g -c src/multiply.c -o src/multiply.oar rcs libmath.a src/add.o src/multiply.ogcc -Wall -Wextra -g -c main.c -o main.ogcc main.o -L. -lmath -o calculator3 + 4 = 73 * 4 = 12
| Command | Purpose |
|---|---|
ar rcs libmath.a obj1.o obj2.o | Create a static library archive from object files |
-L. | Tell the linker to look for libraries in the current directory |
-lmath | Link against libmath.a (the linker adds the lib prefix and .a suffix) |
Note: The
arflags mean:r= insert/replace files,c= create the archive if it does not exist,s= write an index for faster linking.
2. Shared (Dynamic) Libraries
A shared library is loaded at runtime rather than being copied into the executable. Multiple programs can share the same library in memory, and you can update the library without recompiling the programs that use it.
Create a project.
mkdir -p ~/projects/makefile-tutorial/shared-lib/srccd ~/projects/makefile-tutorial/shared-lib
Use the same source files from section 1 (src/mathlib.h, src/add.c, src/multiply.c, and main.c). Copy them or recreate them.
Create a Makefile for the shared library.
CC = gccCFLAGS = -Wall -Wextra -g -fPICLDFLAGS = -L.LDLIBS = -lmathTARGET = calculatorLIB = libmath.soLIB_SRCS = src/add.c src/multiply.cLIB_OBJS = $(LIB_SRCS:.c=.o).PHONY: all cleanall: $(TARGET)# Build the shared library$(LIB): $(LIB_OBJS) $(CC) -shared $^ -o $@# Link the executable against the shared library$(TARGET): main.o $(LIB) $(CC) $(LDFLAGS) main.o $(LDLIBS) -o $@%.o: %.c $(CC) $(CFLAGS) -c $< -o $@clean: rm -f $(TARGET) $(LIB) main.o $(LIB_OBJS)
Build and run.
makeLD_LIBRARY_PATH=. ./calculator
3 + 4 = 73 * 4 = 12
| Flag | Purpose |
|---|---|
-fPIC | Generate Position Independent Code, required for shared libraries |
-shared | Produce a shared library instead of an executable |
LD_LIBRARY_PATH=. | Tell the runtime linker to look for shared libraries in the current directory |
Warning: Without setting
LD_LIBRARY_PATH, the program will fail with an error likeerror while loading shared libraries: libmath.so: cannot open shared object file. For system-wide installation, shared libraries go in/usr/libor/usr/local/liband you runldconfigto update the cache.
Static vs shared – when to use each.
| Aspect | Static (.a) | Shared (.so) |
|---|---|---|
| Binary size | Larger – library code is embedded | Smaller – library is external |
| Runtime dependency | None – self-contained | Requires the .so file at runtime |
| Updates | Recompile to pick up library changes | Replace the .so file, no recompile needed |
| Typical use | Single-binary tools, embedded systems | System libraries, plugins |
3. Using pkg-config
Manually specifying -I, -L, and -l flags for every external library is error-prone. pkg-config reads .pc metadata files installed alongside libraries and outputs the correct flags.
pkg-config --cflags libcurl
-I/usr/include/x86_64-linux-gnu
pkg-config --libs libcurl
-lcurl
Use $(shell pkg-config ...) inside a Makefile to capture these flags automatically.
CC = gccCFLAGS = -Wall -Wextra -g $(shell pkg-config --cflags libcurl)LDLIBS = $(shell pkg-config --libs libcurl)TARGET = fetcher.PHONY: all cleanall: $(TARGET)$(TARGET): main.o $(CC) $^ $(LDLIBS) -o $@%.o: %.c $(CC) $(CFLAGS) -c $< -o $@clean: rm -f $(TARGET) *.o
Create a main.c that uses libcurl.
#include <stdio.h>#include <curl/curl.h>int main(void) { CURL *curl = curl_easy_init(); if (curl) { curl_easy_setopt(curl, CURLOPT_URL, "https://httpbin.org/get"); curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); CURLcode res = curl_easy_perform(curl); if (res == CURLE_OK) { long code; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); printf("HTTP status: %ld\n", code); } curl_easy_cleanup(curl); } return 0;}
Note: This example requires internet access to reach
httpbin.org. If you are in an offline or restricted environment, the build will succeed but the program will print a curl error instead of the HTTP status.
make./fetcher
HTTP status: 200
Tip: Check what libraries have
pkg-configsupport withpkg-config --list-all. If a library does not provide a.pcfile, you must specify the flags manually.
4. Linking Multiple External Libraries
Real projects often depend on several libraries. Add each one to the LDLIBS variable.
CC = gccCFLAGS = -Wall -Wextra -g# Math library (-lm) and pthreads (-lpthread)LDLIBS = -lm -lpthreadTARGET = compute.PHONY: all cleanall: $(TARGET)$(TARGET): main.o worker.o $(CC) $^ $(LDLIBS) -o $@%.o: %.c $(CC) $(CFLAGS) -c $< -o $@clean: rm -f $(TARGET) *.o
When combining pkg-config libraries with standard libraries, concatenate them.
LDLIBS = $(shell pkg-config --libs libcurl) -lm -lpthread
Note: Link order matters. If library A depends on library B, list A before B:
-lA -lB. The linker resolves symbols left to right.
5. Mixed-Language Builds
Some projects combine C and C++ in the same build, or wrap C libraries for use in Python. Make handles this by having different pattern rules for different file types.
Mixing C and C++ – compile each language with its own compiler, then link with the C++ compiler (because it knows how to link the C++ standard library).
CC = gccCXX = g++CFLAGS = -Wall -Wextra -gCXXFLAGS = -Wall -Wextra -std=c++17 -gTARGET = mixedC_SRCS = mathlib.cCPP_SRCS = main.cppC_OBJS = $(C_SRCS:.c=.o)CPP_OBJS = $(CPP_SRCS:.cpp=.o)OBJS = $(C_OBJS) $(CPP_OBJS).PHONY: all cleanall: $(TARGET)$(TARGET): $(OBJS) $(CXX) $^ -o $@%.o: %.c $(CC) $(CFLAGS) -c $< -o $@%.o: %.cpp $(CXX) $(CXXFLAGS) -c $< -o $@clean: rm -f $(TARGET) $(OBJS)
Note: When C++ calls C functions, the C header must use
extern "C"to prevent name mangling. This is a C++ requirement, not a Make requirement.
Building a Python C extension – use Python’s build configuration to get the correct compiler flags.
PYTHON = python3PYTHON_INC = $(shell $(PYTHON) -c "import sysconfig; print(sysconfig.get_path('include'))")PYTHON_LIB = $(shell $(PYTHON) -c "import sysconfig; print(sysconfig.get_config_var('LDLIBRARY'))")CC = gccCFLAGS = -Wall -fPIC -I$(PYTHON_INC).PHONY: all cleanall: mymodule.somymodule.so: mymodule.o $(CC) -shared $^ -o $@mymodule.o: mymodule.c $(CC) $(CFLAGS) -c $< -o $@clean: rm -f mymodule.so mymodule.o
This produces a .so file that Python can import with import mymodule.
6. Recursive vs Non-Recursive Make
Large projects with subdirectories often organize their build in one of two ways.
Recursive Make – each subdirectory has its own Makefile, and the top-level Makefile calls make in each one.
SUBDIRS = lib app.PHONY: all clean $(SUBDIRS)all: $(SUBDIRS)$(SUBDIRS): $(MAKE) -C $@clean: $(foreach dir,$(SUBDIRS),$(MAKE) -C $(dir) clean;)
$(MAKE) -C lib means “run Make in the lib/ directory.” The -C flag changes to that directory before reading its Makefile.
Non-recursive Make – a single top-level Makefile includes fragments from subdirectories.
include lib/module.mkinclude app/module.mk
Each module.mk defines its own sources and objects using directory-prefixed paths.
| Approach | Pros | Cons |
|---|---|---|
| Recursive | Simple, each directory is self-contained | Cannot optimize dependencies across directories |
| Non-recursive | Full dependency graph, optimal rebuild | More complex to set up |
Tip: For small-to-medium projects, a single flat Makefile (as shown in Tutorial 02) is simplest. Use recursive Make only when subdirectories are genuinely independent.
7. Understanding debian/rules
If you have encountered a debian/rules file while packaging software, you were looking at a Makefile. The file debian/rules is an executable Makefile that Debian’s packaging tools invoke to build and install a package.
Here is a typical debian/rules file.
#!/usr/bin/make -f%: dh $@override_dh_auto_configure: autoreconf -i ./configure --prefix=/usroverride_dh_auto_install: make DESTDIR=$(CURDIR)/debian/cmatrix install
The first line (#!/usr/bin/make -f) makes the file executable as a script that runs under Make.
The %: rule is a catch-all pattern rule. The % matches any target name. When Debian’s build system calls make build, make install, or make clean, this rule passes the target name to dh (the debhelper tool) via $@.
The override_dh_auto_configure and override_dh_auto_install targets replace the default behavior of specific debhelper steps with custom commands.
| Element | Meaning |
|---|---|
#!/usr/bin/make -f | Shebang – run this file with Make |
%: | Match any target |
dh $@ | Call debhelper with the current target name |
override_dh_auto_configure | Replace the default configure step |
override_dh_auto_install | Replace the default install step |
$(CURDIR) | Built-in Make variable – the current working directory |
DESTDIR=... | Install into a staging directory, not the real filesystem |
Everything in this file uses Make concepts you already know: targets, prerequisites (none in this case), recipes, automatic variables ($@), and pattern rules (%:). The only new element is dh, which is a Debian-specific tool – not a Make concept.
Summary
You learned the advanced Make patterns used in real-world projects.
- Static libraries (
.a) are archives built withar– the code is embedded in the final executable - Shared libraries (
.so) are built with-shared -fPIC– loaded at runtime, shared across programs pkg-configautomates the discovery of compiler and linker flags for external libraries- Link order matters – if library A depends on B, use
-lA -lB - Mixed-language builds use separate pattern rules for each language and link with the C++ compiler when C++ is involved
- Recursive Make calls
$(MAKE) -C subdirfor each subdirectory; non-recursive Make includes all rules from one top-level Makefile debian/rulesis just a Makefile – a catch-all%:pattern rule that delegates to debhelper, withoverride_*targets for customization
This completes the Makefile tutorial series. You now have enough knowledge to read, write, and maintain Makefiles for projects of any size and in any language.
