or, How I Like To Use Makefiles Unless Something Else Is Making My Life Miserable.
In the spirit of teaching grandmothers how to suck eggs, I thought I’d share the habits I’ve developed in using the venerable build tool “make” over the years. In these days when Eclipse or Visual Studio will do all the work for you, it may seem odd to espouse rolling your own makefiles. However, I consider that understanding what’s going on “under the hood” is an essential part of the engineering mindset. Also I personally find it easier to manage all my build information in one place rather than split across a dozen configuration dialogs, and a makefile is the obvious place to do that.
Please bear in mind that this is my personal take on the ideal makefile, and that reality (in particular clients’ preferences and style guides) will often change the details. The underlying principles remain the same across projects we undertake, however.
This is not a tutorial on make; if any of the syntax I use here looks unfamiliar, you should be able to find out about it in the extensive GNU make documentation that is available online.
The first thing you should put in any makefile is a comment explaining what it’s supposed to be building, a copyright message and/or license, and anything else you think is going to be globally relevant information for the reader. You know, the sorts of things you would normally put at the top of any source file. A makefile is a source file, and you should treat it as such.
The next thing I usually do is create a bunch of variables to define the toolchain I am using. These days selecting the toolchain is more often a matter for the docker container you use as your build environment, but sometimes you will want to try out a different version of the compiler or cross-compile for a different processor. I usually end up with some variation on:
PREFIX ?= arm-none-eabi-
CC := $(PREFIX)gcc
LD := $(PREFIX)gcc
SIZE := $(PREFIX)size
CP := $(PREFIX)objcopy
OD := $(PREFIX)objdump
GDB := $(PREFIX)gdb
The intention is to use $(CC), $(LD) etc in my recipes, so that if I do use a compiler with a different executable name (clang, for example), I only have to fix my makefile in one place. I could write this more generically, but it starts to get messy very quickly and it isn’t usually worth the effort. The number of projects I’ve worked on that use different compilers on the same source base is quite small. If necessary I can override PREFIX on the command line or in the environment to use a different toolchain.
I often follow this up with some variable definitions for common OS operations such as creating directories, writing encouraging messages to the console and so on. This is honestly a bit more optimistic than realistic — the differences between Windows and Linux are often deeper than a simple name change can paper over — but it can be useful for debugging purposes or if you don’t want to have to bother to remember all the flags you normally pile on a command.
One common makefile pattern that I don’t usually use is the “verbose mode” trick. The idea is to limit the visible output of make itself as distinct from the tools it calls, to keep the amount of information in your console window under control:
VERBOSE ?= 0
ifeq ($(VERBOSE),0)
V := @
else
V :=
endif
and then prefix every command in every recipe with $(V) (another good reason for creating variables for every command). If V contains “@” (the default) this will cause make to not echo each command to the console before executing it. Invoking make with “make VERBOSE=1” will put all the echoing back. I don’t usually use this because I always prefer to have more information rather than less, but that’s just me.
The next part of my makefile usually consists of variables for controlling the compilation and linking process, most especially which configuration I want to build for. Almost always you will want different compiler and linker flags for debug builds than you will for builds you intend will make it to the outside world, and you should control that from a single variable as much as possible. I will also create variables to control optional aspects of the build, particularly using or omitting debug code that I have already written into the project. Again, having the “fuseboard” of switches for such things in one place is almost always the right thing to do.
# Default to a debug build
CONFIG ?= debug
# Do use debug serial I/O as long as I can get away with it
ENABLE_SERIAL_DEBUG=1
# Turn on (or off) debug from the Widget chip
ENABLE_WIDGET_DEBUG=0
For everyday development I will want to build my image with full debugging information, because I’m likely to want to run a debugger. Using the “?=” assignment operator means that I can override this setting from an environment variable or the command line, so building a release image is just a matter of typing “make CONFIG=release“.
Notice that I’ve started commenting more here. The previous makefile entries are all familiar stuff I will recognise, but here things get a lot more specific. In six months’ time I will not remember what ENABLE_WIDGET_DEBUG was supposed to do without the comment to nudge my memory.
I usually also create a bunch of variables for the directories that the various different sorts of files in the build system will be separated into. This is mostly a convenience for writing recipes later on, but it does mean that I can build out-of-tree with very little modification.
# Target directories
BUILD_DIR := ../build/$(CONFIG)
DEP_DIR := $(BUILD_DIR)/dep
OBJ_DIR := $(BUILD_DIR)/obj
BIN_DIR := $(BUILD_DIR)/bin
# Source directories
SRC_DIR := src
INC_DIR := include
LINK_DIR := ldscript
Notice that I use a different build directory depending on the configuration being built. This prevents me from accidentally using an object file built for one configuration to create the binary for a different configuration, something make would otherwise not be able to detect. If you have other compile-time flags that you often change and affect the build of multiple source files, it might be worth adding that flag (or something derived from it) in the build directory name too. Inconsistent builds can make you waste hours tracking down non-existent bugs.
The advantage of a structured approach like this is that you can easily extend it to control building separate libraries or the like. FreeRTOS would be a common example, but let’s use a simple fictitious library to demonstrate here
# Fred library directories
FRED_BASE_DIR := libfred
FRED_SRC_DIR := $(FRED_BASE_DIR)/src
FRED_OBJ_DIR := $(OBJ_DIR)/libfred
FRED_DEP_DIR := $(DEP_DIR)/libfred
FRED_INC_DIRS := $(FRED_BASE_DIR)/include \
$(FRED_BASE_DIR)/secret/include
Next I normally deal with the list of files I want to compile. You might expect that this would be a matter of listing the source files, but it’s actually better to list the object files you want created. It makes the compilation rules easier to write, and a lot easier to reason about. Don’t forget to list the library files you will want to build too.
OBJS := $(OBJ_DIR)/main.o \
$(OBJ_DIR)/basic_stuff.o \
$(OBJ_DIR)/widget_stuff.o \
$(OBJ_DIR)/all_the_rest.o
ifeq ($(ENABLE_SERIAL_DEBUG),1)
OBJS += $(OBJ_DIR)/serial_debug.o
endif
FRED_OBJS := $(FRED_OBJ_DIR)/fred.o
DEPS := $(OBJS:$(OBJ_DIR)/%.o=$(DEP_DIR)/%.d)
DEPS += $(FRED_OBJS:$(FRED_OBJ_DIR)/%.o=$(FRED_DEP_DIR)/%.d)
That last bit is generating the names of all the dependency files automatically from the names of the object files. This gives us a convenient way to reference them and include them in the makefile later on. These dependency files are just makefile fragments that connect object files to the include files that they include.
Finally we get to the compiler and linker flags, which I accumulate into variables in much the sort of way you would expect.
CFLAGS += -Wall -Wextra -Werror
# ...
# ...plus all the rest of your usual boilerplate
# ...
CFLAGS += -DSERIAL_DEBUG=$(ENABLE_SERIAL_DEBUG)
CFLAGS += -DWIDGET_DEBUG=$(ENABLE_WIDGET_DEBUG)
LDFLAGS += -Wl,-Map=$(BIN_DIR)/example.map
# ... more boilerplate
LDFLAGS += -L $(LINK_DIR) -T example.ld
ifeq ($(CONFIG),release)
CFLAGS += -Os # or whatever optimisation you prefer
LDFLAGS += -Os
else ifeq ($(CONFIG),debug)
CFLAGS += -ggdb -Og -D__DEBUG # or whatever you prefer
LDFLAGS += -Og
else
$(error "CONFIG must be set to 'debug' or 'release'")
endif
DEPFLAGS := -MMD -MP -MF $(@:$(OBJ_DIR)/%.o=$(DEP_DIR)/%.d)
FRED_DEPFLAGS := -MMD -MP -MF
FRED_DEPFLAGS += $(@:$(FRED_OBJ_DIR)/%.o=$(FRED_DEP_DIR)/%.d)
This is all fairly standard stuff, but notice that I always appended to the compile and link flag variables, even the first time they appear. This allows you to add more compiler flags from the command line without having to edit the makefile. I also defined compile-time constants for the C code to match the variables I defined earlier in the makefile, again to help make sure that the code is built consistently.
There’s one more bit of makefile sneakiness to do to the flags:
INCLUDES = $(INC_DIR) $(FRED_INC_DIRS)
CFLAGS += $(addprefix -I, $(INCLUDES))
This creates “-Iinclude_directory_name” flags for each of the include directories. You can do it a different way using the same sort of substitution we did in generating the dependency filenames, but this feels neater to me.
Finally, the actual compilation rules!
.PHONY: all
all: $(BIN_DIR)/example.elf
$(BIN_DIR)/example.elf: $(OBJS) $(FRED_OBJS)
mkdir -p $(BIN_DIR)
$(LD) $(LDFLAGS) -o $@ $^
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
mkdir -p $(OBJ_DIR)
mkdir -p $(DEP_DIR)
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
$(FRED_OBJ_DIR)/%.o: $(FRED_SRC_DIR)/%.c
mkdir -p $(FRED_OBJ_DIR)
mkdir -p $(FRED_DEP_DIR)
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
There are fancier ways to ensure that your directories exist, but I went for simplicity for this example. There is also a much better way of ensuring that your dependencies are complete, accurate and relevant that Scott McPeak documented many years ago (see https://scottmcpeak.com/autodepend/autodepend.html for the gory details), but again it’s more complicated than I wanted to deal with in an example.
I always have an explicit “all” rule, in part because I’ve had a number of projects where what I want to build by default has changed over time. Particularly if you are building multiple binaries in the same invocation of make, it helps to have a single rule that obviously goes before all the others as a placemarker if nothing else. Ease of navigation through code is often underestimated in importance.
There is one final line that should be in your makefile, and I generally put it at the end:
-include $(DEPS)
This tries to fetch all the dependency files, including their rules in the processing. The “-” at the start of the line means that it doesn’t raise an error if one or more of the files doesn’t exist, which will quite often be the case.
Your makefile can also be a useful place to put recipes that would normally be two or three line scripts instead. Everyone is familiar with the “clean” target, and these days the “distclean” target too, which are just little scripts to delete files from directories the makefile conveniently has in hand. I often add a “tidy” target to delete the backup files editors litter your source tree with, because I don’t like the mess. These days I often also use my makefile to do things like write my binary image file to the flash memory of the embedded device I’m testing with. I don’t recommend using this technique for anything longer than four or five lines of shell commands; for anything complicated you should resort to a proper shell script or small application. I just find it convenient to have all the little things in one place, where there is a much better chance that I will remember what I wrote them for!
I was extremely pleased to discover that Claude seems to have much the same opinion of makefiles that I do. As a test for this article, I asked it to create a makefile for a new project building for a TI MSP430 using a toolchain I happened to have lying around. The results were quite a lot like what I’ve outlined above, though I did have to explicitly ask for both debug and release builds. It took about the same amount of time that I would have taken snaffling a makefile from another project and changing the details, but that was partly my fault for mistyping the name of the directory the toolchain was in. Claude found it anyway and checked it was the toolchain I meant. It did include several make targets I don’t normally bother with and naturally missed the uncommon targets I do use, but those are mere quibbles. I’m rather chuffed with both myself and Claude over that.
So that’s how I have learned to use makefiles over the years. I hope you will find this useful, and think of new convenient things to add to your makefiles.
