- Published on
Crafting a Clean, Maintainable, and Understandable Makefile for a C Project.
- Name
- Luca Cavallin
In the world of software development, especially in C projects, a Makefile is your blueprint for how your project is built and organized. It's a simple way to manage the build process, dictating how your source code transforms into an executable program. Alternatives include build systems like CMake or Meson, but Makefiles remain a popular choice due to their straightforward nature. In a Makefile, you can print to the screen using the echo command, utilize wildcards to specify groups of files, and employ 'phony' targets to run commands regardless of file dependencies.
Now, diving into my project, I aimed to create a Makefile that is clean, maintainable, and easy to comprehend. I took a systematic approach to achieve this, and the result can be found within the gnaro repository on GitHub.
Variables
debug ?= 0
NAME := gnaro
SRC_DIR := src
BUILD_DIR := build
INCLUDE_DIR := include
LIB_DIR := lib
TESTS_DIR := tests
BIN_DIR := bin
This section defines general project settings:
debug
: A conditional flag for building in debug mode. This is used to determine whether to enable debugging flags in the build process.NAME
: The name of the project.SRC_DIR
toBIN_DIR
: Directories for the source files, build output, includes, libraries, tests, and binaries.
Object File Paths
OBJS := $(patsubst %.c,%.o, $(wildcard $(SRC_DIR)/*.c) $(wildcard $(LIB_DIR)/**/*.c))
This line is responsible for generating the paths for all object files by:
- Using
wildcard
to find all.c
files inSRC_DIR
andLIB_DIR
. - Transforming each
.c
filename to its corresponding.o
filename usingpatsubst
.
Object files are intermediate files generated during the build process. They are used to store the compiled code of each source file, and they are linked together to create the final executable. The Makefile assumes that the directory structure inside the build
directory mirrors that of root directory (only for files relevant to the build process).
Compiler Settings
CC := clang-18
LINTER := clang-tidy-18
FORMATTER := clang-format-18
Here, we set the compiler to clang-18
and define tools for linting (clang-tidy-18
) and formatting (clang-format-18
).
Compiler and Linker Flags Settings
CFLAGS := -std=gnu17 -D _GNU_SOURCE -D __STDC_WANT_LIB_EXT1__ -Wall -Wextra -pedantic
LDFLAGS := -lm
This section defines:
CFLAGS
: Compilation flags, which specify:- The C standard to use (gnu17).
- Definitions for enabling GNU and C11 extensions.
- Various warning flags.
LDFLAGS
: Linker flags, like linking to the math library (libm
).
ifeq ($(debug), 1)
CFLAGS := $(CFLAGS) -g -O0
else
CFLAGS := $(CFLAGS) -Oz
endif
This conditional block adds debugging flags (-g -O0
) to CFLAGS
if debug
is set to 1
. Otherwise, it sets the optimization level to -Oz
.
Targets
$(NAME): format lint dir $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $(BIN_DIR)/$@ $(patsubst %, build/%, $(OBJS))
This target is the one generating the final executable for gnaro
. It also ensures that the source code is formatted and linted before building. The $(OBJS)
variable is used to specify the object files to link together.
Object files are built using the following target:
$(OBJS): dir
@mkdir -p $(BUILD_DIR)/$(@D)
@$(CC) $(CFLAGS) -o $(BUILD_DIR)/$@ -c $*.c
Each object file depends on its corresponding source file. This target compiles each source file into its object file.
Run tests
test: dir
@$(CC) $(CFLAGS) -lcunit -o $(BIN_DIR)/$(NAME)_test $(TESTS_DIR)/*.c
@$(BIN_DIR)/$(NAME)_test
This target compiles and runs the tests using the CUnit testing framework.
Linting, Formatting, and Memory Checking
The lint
, format
, and check
targets handle code quality checks using the specified tools.
# Run CUnit tests
test: dir
@$(CC) $(CFLAGS) -lcunit -o $(BIN_DIR)/$(NAME)_test $(TESTS_DIR)/*.c
@$(BIN_DIR)/$(NAME)_test
# Run linter on source directories
lint:
@$(LINTER) --config-file=.clang-tidy $(SRC_DIR)/* $(INCLUDE_DIR)/* $(TESTS_DIR)/* -- $(CFLAGS)
# Run formatter on source directories
format:
@$(FORMATTER) -style=file -i $(SRC_DIR)/* $(INCLUDE_DIR)/* $(TESTS_DIR)/*
# Run valgrind memory checker on executable
check: $(NAME)
@sudo valgrind -s --leak-check=full --show-leak-kinds=all $(BIN_DIR)/$< --help
@sudo valgrind -s --leak-check=full --show-leak-kinds=all $(BIN_DIR)/$< --version
@sudo valgrind -s --leak-check=full --show-leak-kinds=all $(BIN_DIR)/$< -v
Setup Dependencies
setup:
# ... OS and tool installation commands ...
I included a target for setting up the project and installing required OS packages and development tools on a new system. Initially, I used scripts to automate this process, but I found that it was cleaner to include it in the Makefile.
Directory Setup and Cleanup
The dir
target ensures the build and bin directories exist, while the clean
target removes them.
# Setup build and bin directories
dir:
@mkdir -p $(BUILD_DIR) $(BIN_DIR)
# Clean build and bin directories
clean:
@rm -rf $(BUILD_DIR) $(BIN_DIR)
Phony Targets
.PHONY: lint format check setup dir clean bear
.PHONY
tells make
that the listed targets don't correspond to actual files. This ensures they always execute, even if a file with the same name exists.
Bear
Bear is a tool that generates a compilation database for clang tooling. It is used to generate a compile_commands.json
file, which is used by tools like clang-tidy
and clang-format
to determine how to handle the source files.
bear --exclude $(LIB_DIR) make $(NAME)
Full Makefile
I am including the full Makefile below for reference:
# Project Settings
debug ?= 0
NAME := gnaro
SRC_DIR := src
BUILD_DIR := build
INCLUDE_DIR := include
LIB_DIR := lib
TESTS_DIR := tests
BIN_DIR := bin
# Generate paths for all object files
OBJS := $(patsubst %.c,%.o, $(wildcard $(SRC_DIR)/*.c) $(wildcard $(LIB_DIR)/**/*.c))
# Compiler settings
CC := clang-18
LINTER := clang-tidy-18
FORMATTER := clang-format-18
# Compiler and Linker flags Settings:
# -std=gnu17: Use the GNU17 standard
# -D _GNU_SOURCE: Use GNU extensions
# -D __STDC_WANT_LIB_EXT1__: Use C11 extensions
# -Wall: Enable all warnings
# -Wextra: Enable extra warnings
# -pedantic: Enable pedantic warnings
# -lm: Link to libm
CFLAGS := -std=gnu17 -D _GNU_SOURCE -D __STDC_WANT_LIB_EXT1__ -Wall -Wextra -pedantic
LDFLAGS := -lm
ifeq ($(debug), 1)
CFLAGS := $(CFLAGS) -g -O0
else
CFLAGS := $(CFLAGS) -Oz
endif
# Targets
# Build executable
$(NAME): format lint dir $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $(BIN_DIR)/$@ $(patsubst %, build/%, $(OBJS))
# Build object files and third-party libraries
$(OBJS): dir
@mkdir -p $(BUILD_DIR)/$(@D)
@$(CC) $(CFLAGS) -o $(BUILD_DIR)/$@ -c $*.c
# Run CUnit tests
test: dir
@$(CC) $(CFLAGS) -lcunit -o $(BIN_DIR)/$(NAME)_test $(TESTS_DIR)/*.c
@$(BIN_DIR)/$(NAME)_test
# Run linter on source directories
lint:
@$(LINTER) --config-file=.clang-tidy $(SRC_DIR)/* $(INCLUDE_DIR)/* $(TESTS_DIR)/* -- $(CFLAGS)
# Run formatter on source directories
format:
@$(FORMATTER) -style=file -i $(SRC_DIR)/* $(INCLUDE_DIR)/* $(TESTS_DIR)/*
# Run valgrind memory checker on executable
check: $(NAME)
@sudo valgrind -s --leak-check=full --show-leak-kinds=all $(BIN_DIR)/$< --help
@sudo valgrind -s --leak-check=full --show-leak-kinds=all $(BIN_DIR)/$< --version
@sudo valgrind -s --leak-check=full --show-leak-kinds=all $(BIN_DIR)/$< -v
# Setup dependencies for build and development
setup:
# Update apt and upgrade packages
@sudo apt update
@sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y
# Install OS dependencies
@sudo apt install -y bash libarchive-tools lsb-release wget software-properties-common gnupg
# Install LLVM tools required for building the project
@wget https://apt.llvm.org/llvm.sh
@chmod +x llvm.sh
@sudo ./llvm.sh 18
@rm llvm.sh
# Install Clang development tools
@sudo apt install -y clang-tools-18 clang-format-18 clang-tidy-18 valgrind bear
# Install CUnit testing framework
@sudo apt install -y libcunit1 libcunit1-doc libcunit1-dev
# Cleanup
@sudo apt autoremove -y
# Setup build and bin directories
dir:
@mkdir -p $(BUILD_DIR) $(BIN_DIR)
# Clean build and bin directories
clean:
@rm -rf $(BUILD_DIR) $(BIN_DIR)
# Run bear to generate compile_commands.json
bear:
bear --exclude $(LIB_DIR) make $(NAME)
.PHONY: lint format check setup dir clean bear
Limitations
While the gnaro
project Makefile offers a structured and comprehensive build process, it does have some limitations. For instance, it's tailored specifically to environments with the apt package manager, making it less portable to non-Debian-based systems. The Makefile also assumes the availability of specific versions of tools like clang-18
, which could pose challenges if the project is moved to environments with different tool versions or if these tools are deprecated in the future. Additionally, the heavy reliance on implicit rules and predefined directories might make the Makefile less flexible to changes in the project's structure or build requirements. However, these limitations are outweighed by the benefits of having a clean and maintainable Makefile.
Conclusion
Crafting a clean and maintainable Makefile doesn't have to be a complex ordeal. By leveraging Makefile features like variables, wildcards, automatic variables, and phony targets, and by commenting liberally, I was able to create a Makefile that is not only functional but also easy to understand and modify. This straightforward approach has streamlined the build process of my C project, demonstrating the utility and simplicity of Makefiles in managing build processes.