A modular GNUmakefile for C, C++, D, Fortran, Objective-C, Objective-C++, Pascal, Modula-2, and Assembly projects.
A single-file, drop-in build system for mixed-language projects. Supports multiple executables, static libraries, and shared libraries through a tree of lightweight module.mk descriptor files.
No configuration generators, no external dependencies -- just GNU Make.
| Extension | Language | Compiler | Dep tracking |
|---|---|---|---|
.c |
C | $(CC) |
yes |
.cc .cpp |
C++ | $(CXX) |
yes |
.d |
D | $(GDC) |
yes |
.m |
Objective-C | $(CC) |
yes |
.mm |
Objective-C++ | $(CXX) |
yes |
.f .f90 |
Fortran | $(FC) |
yes |
.S |
Assembly (preprocessed) | $(CC) |
yes |
.asm |
Assembly (NASM) | $(NASM) |
-- |
.pas |
Pascal | $(FPC) |
-- |
.mod |
Modula-2 | $(GM2) |
yes |
All languages produce standard .o files and can be freely mixed within a single target.
- Copy
GNUmakefileinto your project root. - Create
src/module.mkto declare your targets. - Run
make.
If you prefer a top-level module.mk instead of src/module.mk, the build system will use it automatically. In that case you are responsible for listing subdirectories in SUBDIRS (e.g. SUBDIRS = src).
project/
GNUmakefile
src/
module.mk
main.c
# src/module.mk
EXECUTABLES += myapp
myapp_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
myapp_SRCS = main.cmake # builds _out/<triplet>/bin/myappEach module.mk declares build targets by appending to one of three lists:
| List | Output |
|---|---|
EXECUTABLES |
Binary in _out/<triplet>/bin/ |
LIBRARIES |
Static archive (.a) in _build/<triplet>/ |
SHARED_LIBS |
Shared library (.so/.dylib/.dll) in _out/<triplet>/lib/ |
Every target needs at minimum:
<name>_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
<name>_SRCS = file1.c file2.cSource paths in _SRCS are relative to _DIR. Wildcards are supported (e.g. *.c). Sources may use any supported extension and can be mixed:
myapp_SRCS = main.c accel.S utils.cppAlways reference
<name>_DIR, never a shared variable, in any value that expands lazily (exported flags, generation recipes). A common convention isROOT := $(dir $(lastword $(MAKEFILE_LIST)))followed byfoo_DIR := $(ROOT). That is safe only becausefoo_DIRfreezesROOTimmediately with:=. The sharedROOTis reassigned by everymodule.mk, so a lazy (=) reference reads whichever one was included last:foo_EXPORTED_CPPFLAGS = -I$(ROOT) # WRONG -- resolves late, wrong dir foo_EXPORTED_CPPFLAGS = -I$(foo_DIR) # right -- frozen per targetA single-directory project never exposes this; it surfaces only once a second
module.mkreassignsROOT.
| Variable | Description |
|---|---|
<name>_CFLAGS |
C / Objective-C compiler flags (e.g. -Wall -O2) |
<name>_CXXFLAGS |
C++ / Objective-C++ compiler flags (e.g. -std=c++17) |
<name>_CPPFLAGS |
Preprocessor flags (e.g. -I, -D) |
<name>_DFLAGS |
D compiler flags |
<name>_FFLAGS |
Fortran compiler flags |
<name>_ASFLAGS |
Assembler flags (.S files) |
<name>_NASMFLAGS |
NASM flags (.asm files) |
<name>_FPCFLAGS |
Free Pascal flags (.pas files) |
<name>_GM2FLAGS |
GCC Modula-2 flags (.mod files) |
<name>_GENERATED_SRCS |
Source files produced by a code generator; compiled from _build/<triplet>/ instead of the source tree (see Generated Sources and Headers) |
<name>_GENERATED_HDRS |
Header files produced by a code generator; placed in _build/<triplet>/, ordered ahead of consumers and put on their include path automatically |
<name>_EXTRA_OBJS |
Additional pre-built .o files to link (not compiled or cleaned by this build system) |
<name>_LDFLAGS |
Linker flags (executables and shared libs) |
<name>_LDLIBS |
Link libraries (executables and shared libs, e.g. -lm) |
<name>_PKGS |
External packages, resolved to --cflags/--libs via the built-in table or pkg-config (e.g. sdl3 gl m) |
<name>_EXEC |
Set automatically -- full output path for executables |
<name>_LIBS |
Library dependencies (resolved transitively; works on libraries too) |
<name>_EXPORTED_CPPFLAGS |
Preprocessor flags exported to dependents (e.g. -I) |
<name>_EXPORTED_CFLAGS |
C flags exported to dependents |
<name>_EXPORTED_CXXFLAGS |
C++ flags exported to dependents |
<name>_EXPORTED_LDFLAGS |
Linker flags exported to dependents |
<name>_EXPORTED_LDLIBS |
Link libraries exported to dependents (e.g. -lstdc++) |
<name>_TESTCMD |
Test commands (use define/endef); run by make run-tests |
| Variable | Description |
|---|---|
TEST_TARGETS |
Targets with _TESTCMD to run during make run-tests |
SUBDIRS |
Subdirectories containing additional module.mk files (per-file, not per-target). When using a top-level module.mk, add src here to load src/module.mk. |
TOP |
Absolute path to the project root (with trailing slash), available to all module.mk files |
src/
module.mk
main.c
util/
module.mk
util.c
util.h
# src/module.mk
SUBDIRS = util
EXECUTABLES += myapp
myapp_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
myapp_SRCS = main.c
myapp_LIBS = myutil
# src/util/module.mk
LIBRARIES += myutil
myutil_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
myutil_SRCS = util.c
myutil_EXPORTED_CPPFLAGS = -I$(myutil_DIR)EXECUTABLES += myapp
myapp_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
myapp_SRCS = main.c engine.cpp
myapp_CFLAGS = -O2
myapp_CXXFLAGS = -O2 -std=c++17When any source file in the target or its transitive _LIBS dependencies is .cc, .cpp, or .mm, the linker is automatically set to $(CXX).
SHARED_LIBS += myplugin
myplugin_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
myplugin_SRCS = plugin.c hooks.c
myplugin_CFLAGS = -WallObjects for shared libraries are compiled with -fPIC automatically (or -Cg for Pascal).
Pascal functions must use the cdecl calling convention to be callable from C:
procedure greet(msg: PChar); cdecl; export;Compile to .o with Free Pascal, then link alongside C objects. You may need to add FPC runtime libraries to _LDLIBS.
Most per-target variables accept .<os>, .<arch>, and .<os>.<arch> suffixes. After all module.mk files are loaded, suffixed values are appended to the base variable automatically. <os> follows the uname -s spelling (e.g. Linux, Darwin, Windows_NT) and <arch> follows uname -m (e.g. x86_64, aarch64, riscv64).
When cross-compiling (e.g. CC=aarch64-linux-gnu-gcc), the target OS and architecture are derived from the compiler's triplet, so the correct suffixes are selected automatically.
# library with arch-specific SIMD and a portable C fallback
LIBRARIES += minmax
minmax_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
minmax_SRCS = minmax.c
minmax_SRCS.x86_64 = arch/minmax_x86_64.S
minmax_SRCS.aarch64 = arch/minmax_aarch64.S
minmax_LDLIBS.Linux = -lm -ldl
minmax_CFLAGS.Linux.x86_64 = -msse4.2The supported suffixed variables are: _SRCS, _LIBS, _PKGS, _EXTRA_OBJS, and all per-target compiler/linker flag variables (_CFLAGS, _CXXFLAGS, _CPPFLAGS, _LDFLAGS, _LDLIBS, _ASFLAGS, _DFLAGS, _FFLAGS, _NASMFLAGS, _FPCFLAGS, _GM2FLAGS, and the _EXPORTED_* variants).
A module.mk can check whether its compiler is available and conditionally register its target. This is useful for optional dependencies or compilers that may not be installed (e.g. cross-compiling without Free Pascal):
# library: "greet_pascal" -- only when fpc is available
ifneq ($(shell command -v $(FPC) 2>/dev/null),)
LIBRARIES += greet_pascal
greet_pascal_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
greet_pascal_SRCS = greetpascal.pas
greet_pascal_EXPORTED_CPPFLAGS = -I$(greet_pascal_DIR) -DHAVE_PASCAL
endifDependents can use $(filter) to conditionally link against the optional library:
world_LIBS = greet_c $(filter greet_pascal,$(LIBRARIES)) greet_m2And guard the corresponding C code with the exported define:
#ifdef HAVE_PASCAL
#include <greet_pascal.h>
#endifList external libraries a target needs in <name>_PKGS. Each token is
resolved once to compile and link flags, folded into the target's
CPPFLAGS (--cflags) and LDLIBS (--libs):
# executable linked against SDL3 and the math and OpenGL libraries
EXECUTABLES += game
game_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
game_SRCS = game.c
game_PKGS = sdl3 gl mA token is resolved one of two ways:
- If it is in
KNOWN_PKGS, its flags come from the built-in table (PKG_<token>_CFLAGS/PKG_<token>_LDLIBS, platform suffixes supported). Nopkg-configis invoked, so these work on systems without it (e.g. macOS, Windows). - Otherwise it is passed to
pkg-config --cflags/--libs <token>.
The built-in table is limited to stable, path-free system libraries:
| Token | Purpose | Linux | macOS | Windows |
|---|---|---|---|---|
m |
Math library | -lm |
(in libSystem) | (in CRT) |
gl |
OpenGL | -lGL |
-framework OpenGL |
-lopengl32 |
dl |
Dynamic loader | -ldl |
(in libSystem) | |
rt |
Realtime/clock | -lrt |
(in libSystem) | |
pthread |
POSIX threads | -lpthread |
(in libSystem) |
_PKGS accepts the same .<os>, .<arch>, and .CONFIG_* suffixes as
the other per-target variables, and package link flags declared on a
library propagate to any executable that links it.
To add a package to the table, or override an entry for one platform,
extend it from a module.mk:
KNOWN_PKGS += glfw3
PKG_glfw3_LDLIBS.Linux = -lglfw
PKG_glfw3_LDLIBS.Darwin = -lglfw -framework Cocoa -framework IOKit
PKG_glfw3_LDLIBS.Windows_NT = -lglfw3Code generators (an IDL compiler, bin2c, a shader packer) produce .c and
.h files. List them in <name>_GENERATED_SRCS and <name>_GENERATED_HDRS
and they are placed under _build/<triplet>/ instead of being dropped in the
source tree. Paths are relative to <name>_DIR. You still write the rule that
runs the generator.
LIBRARIES += proto
proto_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
proto_GENERATED_SRCS = proto.c
proto_GENERATED_HDRS = proto.h
# GNU Make 4.3+: one recipe, both outputs (a grouped target with &:).
$(BUILDDIR)/$(proto_DIR)proto.c $(BUILDDIR)/$(proto_DIR)proto.h &: $(proto_DIR)proto.idl
my-idl-codegen $< -o $(BUILDDIR)/$(proto_DIR)Declaring a header in _GENERATED_HDRS makes the build system, for that target
and every target that lists it in _LIBS (transitively):
- order the header ahead of any object that may
#includeit, so a clean parallel build never compiles a consumer before the header exists; - find it: the header's build directory goes on the include path
automatically, so consumers write
#include "proto.h"with no manual-I; - rebuild consumers when it changes (via the
.depfiles); and - clean it.
This replaces the hand-written -I$(BUILDDIR)/... flags and
obj : | generated.h order-only prerequisites these generators used to
require. Change detection needs make to notice the header's new timestamp, so
the generation rule must give the header a recipe: use a grouped target
(&:, GNU Make 4.3+) as above, or on GNU Make 4.0-4.2 give each output its own
recipe:
$(BUILDDIR)/$(proto_DIR)proto.c : $(proto_DIR)proto.idl
my-idl-codegen $< -o $(BUILDDIR)/$(proto_DIR)
$(BUILDDIR)/$(proto_DIR)proto.h : $(BUILDDIR)/$(proto_DIR)proto.c ; @touch -c $@A bare proto.h : proto.c with no recipe will not rebuild consumers when only
the header changes, because make never re-stats a target it did not run a
recipe for.
Define test commands with define/endef and register the target:
define myapp_TESTCMD
$(myapp_EXEC) --selftest
$(myapp_EXEC) < testdata/input.txt | diff - testdata/expected.txt
endef
TEST_TARGETS += myappThe <name>_EXEC variable is set automatically for each executable (expands to the full output path).
Run all tests with make run-tests. Each line is a separate shell command; if any fails, make stops.
_build/<triplet>/ object files (.o) and dependency files (.dep)
_out/<triplet>/bin/ executable binaries
_out/<triplet>/lib/ shared libraries
The triplet (e.g. x86_64-linux-gnu) comes from $(CC) -dumpmachine, so cross-compiled artifacts don't clobber native ones.
| Target | Description |
|---|---|
make |
Build all executables (default) |
make <name> |
Build a single target by name |
make clean |
Remove generated files |
make clean_<name> |
Remove files for a single target |
make clean-all |
clean plus remove empty build directories |
make run-tests |
Build all test targets, then run their _TESTCMD |
make run-test-<name> |
Build and test a single target |
make defconfig |
Reset config.mk from ./defconfig (auto-created on first build) |
make defconfig_<name> |
Generate config.mk from configs/<name>.mk |
Override on the command line, in the environment, or in a .env file (copy env.example to .env for local settings):
| Variable | Default | Description |
|---|---|---|
USE_CLANG |
(unset) | Set to use clang/clang++ instead of cc/c++ |
CC |
cc |
C compiler |
CXX |
c++ |
C++ compiler |
FC |
gfortran |
Fortran compiler |
GDC |
gdc |
D compiler |
NASM |
nasm |
Netwide Assembler |
FPC |
fpc |
Free Pascal compiler |
GM2 |
gm2 |
GCC Modula-2 frontend |
AR |
ar |
Archiver |
ARFLAGS |
rvD |
Archiver flags |
DEBUG |
(unset) | Enable debug build flags (-Og -g -fno-omit-frame-pointer) |
RELEASE |
(unset) | Enable release build flags (-O2, LTO, -DNDEBUG, section GC) |
RELEASE_MARCH |
native |
Target architecture for release builds (e.g. x86-64-v2) |
MKDIR_P |
mkdir -p |
Directory creation |
RMDIR |
rmdir |
Directory removal |
V |
(unset) | Verbose output. V=1 prints full command lines (recommended for CI/CD). Default shows short tags (CC, LD, AR, etc.) |
COLOR |
(auto) | Color for quiet-mode tags. Auto-detected by default. COLOR=0 disables, COLOR=1 forces on |
DEBUG and RELEASE are mutually exclusive. Build mode flags are injected into all GCC-based compile and link commands. LTO is auto-detected by probing the full toolchain (compile, archive, link). Uses -flto=thin with Clang and -flto=auto with GCC. Pascal (FPC) is not affected.
Use Clang instead of GCC:
make USE_CLANG=1Debug build:
make DEBUG=1Release build:
make RELEASE=1
make RELEASE=1 RELEASE_MARCH=x86-64-v3Cross-compile example:
make CC=aarch64-linux-gnu-gcc CXX=aarch64-linux-gnu-g++Verbose output (recommended for CI/CD):
make V=1
make V=1 RELEASE=1Optional per-triplet feature toggles. A config.mk file in the build directory controls which features are enabled.
If your project has a defconfig file, config.mk is auto-created from it on the first build. To reset or customize:
make defconfig # reset _build/<triplet>/config.mk from ./defconfig
# edit _build/<triplet>/config.mk
make # rebuild with new settingsWithout a defconfig or config.mk, the build works normally with all CONFIG options disabled.
After changing config options that add or remove source files, remove _build/ manually before rebuilding (make clean only removes files known to the current config).
For each CONFIG_FOO = y in config.mk:
-
Per-target variables with a
.CONFIG_FOOsuffix are merged into their base variable:myapp_SRCS.CONFIG_SSL = ssl.c myapp_LDLIBS.CONFIG_SSL = -lssl -lcrypto
-
-DCONFIG_FOO=1is added to all compile commands, so C/C++ code can use#ifdef CONFIG_FOO. -
A
config.hheader is auto-generated in_build/<triplet>/(on the include path). EveryCONFIG_*variable is emitted:ybecomes#define CONFIG_FOO 1, and non-y/non-nvalues are written verbatim. Source files can#include "config.h"to access all config values without-Descaping.
Non-boolean values use the CONFIG_ prefix and are written verbatim into config.h:
# config.mk
CONFIG_CUSTOM_GREETING = y
CONFIG_GREETING_STR = "What's up"/* greet.c */
#include "config.h"
#ifndef CONFIG_GREETING_STR
#define CONFIG_GREETING_STR "Hello"
#endifStrings with spaces, quotes, and special characters work naturally because config.h is generated directly -- no shell escaping is involved.
Entire modules can be gated on a CONFIG option:
ifeq ($(CONFIG_LUA_SCRIPTING),y)
LIBRARIES += lua_bridge
lua_bridge_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
lua_bridge_SRCS = lua_bridge.c
endifPlace named config templates in configs/:
make defconfig_minimal # copies configs/minimal.mk to config.mkConfig options control features, not toolchains. Compiler selection (CC, USE_CLANG) and build modes (DEBUG, RELEASE) belong in .env or on the command line.
- GNU Make 4.0 or later (uses the
$(file)function introduced in 4.0) - A C compiler (GCC, Clang, etc.)
- Optional language compilers as needed by your project
Core build tools:
sudo apt-get install build-essentialAdditional language compilers:
sudo apt-get install g++ # C++ (.cc, .cpp)
sudo apt-get install gdc # D (.d)
sudo apt-get install gfortran # Fortran (.f, .f90)
sudo apt-get install gobjc # Objective-C (.m)
sudo apt-get install gobjc++ # Objective-C++ (.mm)
sudo apt-get install fpc # Pascal (.pas)
sudo apt-get install gm2 # Modula-2 (.mod)
sudo apt-get install nasm # Assembly (.asm)Or install everything at once:
sudo apt-get install build-essential g++ gdc gfortran gobjc gobjc++ fpc gm2 nasmA self-contained test suite under tests/ exercises build system features using only C and C++ (no exotic compilers needed):
tests/run-tests.sh # test with default compiler
tests/run-tests.sh USE_CLANG=1 # test with clangPublic domain. Use however you like.