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 the debian/rules Makefile format. This tutorial covers the advanced Make patterns you will encounter in real-world projects.

KeyValue
OSUbuntu 24.04 LTS
Make versionGNU Make 4.3
GCC version13
G++ version13
Python version3.12
Working directory~/projects/makefile-tutorial

0. Prerequisites


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/src
cd ~/projects/makefile-tutorial/static-lib

Create a small math library with two source files.

src/mathlib.h – the header.

#ifndef MATHLIB_H
#define MATHLIB_H
int 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 = gcc
CFLAGS = -Wall -Wextra -g
AR = ar
TARGET = calculator
LIB = libmath.a
LIB_SRCS = src/add.c src/multiply.c
LIB_OBJS = $(LIB_SRCS:.c=.o)
.PHONY: all clean
all: $(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.o
gcc -Wall -Wextra -g -c src/multiply.c -o src/multiply.o
ar rcs libmath.a src/add.o src/multiply.o
gcc -Wall -Wextra -g -c main.c -o main.o
gcc main.o -L. -lmath -o calculator
3 + 4 = 7
3 * 4 = 12
CommandPurpose
ar rcs libmath.a obj1.o obj2.oCreate a static library archive from object files
-L.Tell the linker to look for libraries in the current directory
-lmathLink against libmath.a (the linker adds the lib prefix and .a suffix)

Note: The ar flags 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/src
cd ~/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 = gcc
CFLAGS = -Wall -Wextra -g -fPIC
LDFLAGS = -L.
LDLIBS = -lmath
TARGET = calculator
LIB = libmath.so
LIB_SRCS = src/add.c src/multiply.c
LIB_OBJS = $(LIB_SRCS:.c=.o)
.PHONY: all clean
all: $(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.

make
LD_LIBRARY_PATH=. ./calculator
3 + 4 = 7
3 * 4 = 12
FlagPurpose
-fPICGenerate Position Independent Code, required for shared libraries
-sharedProduce 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 like error while loading shared libraries: libmath.so: cannot open shared object file. For system-wide installation, shared libraries go in /usr/lib or /usr/local/lib and you run ldconfig to update the cache.

Static vs shared – when to use each.

AspectStatic (.a)Shared (.so)
Binary sizeLarger – library code is embeddedSmaller – library is external
Runtime dependencyNone – self-containedRequires the .so file at runtime
UpdatesRecompile to pick up library changesReplace the .so file, no recompile needed
Typical useSingle-binary tools, embedded systemsSystem 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 = gcc
CFLAGS = -Wall -Wextra -g $(shell pkg-config --cflags libcurl)
LDLIBS = $(shell pkg-config --libs libcurl)
TARGET = fetcher
.PHONY: all clean
all: $(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-config support with pkg-config --list-all. If a library does not provide a .pc file, 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 = gcc
CFLAGS = -Wall -Wextra -g
# Math library (-lm) and pthreads (-lpthread)
LDLIBS = -lm -lpthread
TARGET = compute
.PHONY: all clean
all: $(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 = gcc
CXX = g++
CFLAGS = -Wall -Wextra -g
CXXFLAGS = -Wall -Wextra -std=c++17 -g
TARGET = mixed
C_SRCS = mathlib.c
CPP_SRCS = main.cpp
C_OBJS = $(C_SRCS:.c=.o)
CPP_OBJS = $(CPP_SRCS:.cpp=.o)
OBJS = $(C_OBJS) $(CPP_OBJS)
.PHONY: all clean
all: $(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 = python3
PYTHON_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 = gcc
CFLAGS = -Wall -fPIC -I$(PYTHON_INC)
.PHONY: all clean
all: mymodule.so
mymodule.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.mk
include app/module.mk

Each module.mk defines its own sources and objects using directory-prefixed paths.

ApproachProsCons
RecursiveSimple, each directory is self-containedCannot optimize dependencies across directories
Non-recursiveFull dependency graph, optimal rebuildMore 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=/usr
override_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.

ElementMeaning
#!/usr/bin/make -fShebang – run this file with Make
%:Match any target
dh $@Call debhelper with the current target name
override_dh_auto_configureReplace the default configure step
override_dh_auto_installReplace 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 with ar – the code is embedded in the final executable
  • Shared libraries (.so) are built with -shared -fPIC – loaded at runtime, shared across programs
  • pkg-config automates 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 subdir for each subdirectory; non-recursive Make includes all rules from one top-level Makefile
  • debian/rules is just a Makefile – a catch-all %: pattern rule that delegates to debhelper, with override_* 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.

Leave a Reply