lucavallin
Published on

Crafting a Clean, Maintainable, and Understandable Makefile for a C Project.

avatar
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 to BIN_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 in SRC_DIR and LIB_DIR.
  • Transforming each .c filename to its corresponding .o filename using patsubst.

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.