Makefiles | The power to build

FreeBSD gearing-up

Index


Working with languages like C/C++ requires running a process to compile our project. That process can look like a black box where magic happens, but it's not that complicated. Let's see how Makefiles work and how to write practical ones for your everyday hacks and projects.

You've maybe heard of Makefile generators like cmake. We're not using them here, neither a heavy IDE. The article is explained using a plain text editor and the command-line.

make is a tool used mostly to compile and build C/C++ source code. Makefiles just tell make how to compile and build a program. They consist in a series of instructions that perform automatically rather than having to manually type them in the command-line.

— Before we go further with make, let's check what happens when we call the compiler so we know how to structure steps inside the Makefile later.

A simple compiling process takes four steps:

At this point, all the code inside the header files that we have included using the directive #include "header.h" is copied and pasted in the program.

Flags

Each one of the steps needed to build a program can be invoked with a specific option to the compiler.

Flags give us the ability to enable or disable functionality for our building processes.

All the previous flags can be manually typed in a command-line environment:

$ cc -o demo_program main.c

but the idea here is to store those commands in a Makefile to automatically perform the build.

Basic structure

A Makefile (case sensitivity named) is a plain text file that can contain the following sections:

Rules

A Makefile rule needs three basic items:

Actions need to be indented by a tab character (not spaces) in order to work.

Note that we can have more than one action per target, and each one needs to be in a new line.

target: dependencies
    action

Let's pretend that we have a set of source files that we can compile into a program named calculator which depends on five independent source files.

calculator : main.c sum.h sub.h mult.h div.h
    cc -o calculator main.c

Compiled programming languages like C require us to recompile the program each time we change the source code. While the program keeps being simple there's no problem in rebuilding the whole program even if we only changed one file. But when we start to have a more complex program, compilation times increase, and recompiling everything just to update few changes is not effective.

The same way we create the target calculator we can make a target for each of the files that build it.

calculator: main.o sum.o sub.o mult.o div.o
    cc -o calculator main.o sum.o sub.o mult.o div.o

main.o: main.c main.h
    cc -c main.c
sum.o: sum.c sum.h
    cc -c sum.c
sub.o: sub.c sub.h
    cc -c sub.c
mult.o: mult.c mult.h
    cc -c main.c
div.o: div.c div.h
    cc -c div.c

This method forces us to write function declarations in separated .h header files and definitions in .c files to avoid multiple definitions. But we take the advantage of building only the objects that have modified dependencies.

make() checks the timestamp of the files to keep track of modifications. If an object dependency gets a timestamp that is newer than the object's timestamp, it'll recompile that object when executed.

We can also create rules for steps that don't involve compiling or building the program, such as placing the built program in the correct directory, removing it (the same as uninstalling) or cleaning the compiled objects.

clean: 
    rm -f *.o calculator

Now instead of manually removing those files when we need a clean build, we can call make clean and the rule will perform the action.

To make an install rule we can follow the same procedure, just adding the binary as a dependency to the rule:

install: calculator
    mkdir  -p /opt/calc
    cp $< /opt/calc/calculator

and the uninstall rule is a simple recipe that removes the copied file:

uninstall:
    rm -f /opt/calc/calculator

The rules that don't involve compiling or building a program can get us in trouble if we ever meet the situation where an object is named like them (clean, install or uninstall in this case). To solve this, make has PHONY targets which are just a name for a recipe to be executed when you make an explicit request.

.PHONY: clean
clean: 
    rm -f *.o calculator

This way we avoid conflicts with other files.

Macros

When programs start increasing the number of source files and library dependencies, the amount of objects and files to track increases. Luckily for us, make can handle this if we use macros (variables).

A macro has the following format:

name = data

where name is an identifier and data is the text that'll be substituted each time make sees ${name}.

Some predefined macros are:

CC = cc
CFLAGS = -c -g -Wall
LDFLAGS = -lm

Similarly we can make a macro for all our source files, dependencies and objects.

SRC = main.c sum.c sub.c mult.c div.c
OBJ = $(SRC:.c=.o)

We are storing all our source files in the SRC macro, and since the object files share names with the source files, we are transforming the content inside SRC by changing the .c suffix with .o and storing it in the OBJ macro.

Source files can be huge in number, and manually typing each source file name can end up being tedious and make the line hard to work with.

BSD Make can store all the source files in a variable by executing a command-line operation, by using !=:

SRC != ls src/*.c

and if you want to find files in subdirectories too, you can execute:

FILES!= find . -type f -name '*.c'

On the GNU Make version we can take the advantage of wildcards to get all source files stored in a variable:

SRC = $(wildcard \*.c)

Both of them which will take every .c file inside the current directory.

Note that the value for SRC is encapsulated between brackets and includes the explicit wildcard word. If we just associate src to *.c it will store the literal set of characters and won't behave as expected.

Source files may happen to be in different directories. In that case we only need to repeat the wildcard process:

SRC = $(wildcard src/*.c) $(wildcard src/modules/*.c)

Macros don't need to be upper case, and can be used arbitrarily to simplify name repetitions like our program's name:

prog_name = calculator

Our Makefile can be transformed in something cleaner:

CC = cc
CFLAGS = -c -g -Wall
LDFLAGS = -lm
SRC != ls src/*.c 
OBJ = ${SRC:.c=.o}

prog_name = calculator

calculator: ${OBJ}
    ${CC} -o ${prog_name} ${OBJ} ${LDFLAGS}

main.o: main.c main.h
    ${CC} -c main.c
sum.o: sum.c sum.h
    ${CC} -c sum.c
sub.o: sub.c sub.h
    ${CC} -c sub.c
mult.o: mult.c mult.h
    ${CC} -c mult.c
div.o: div.c div.h
    ${CC} -c div.c

.PHONY: clean
clean: 
    rm -f *.o ${prog_name}

.PHONY: install
install: ${prog_name}
    mkdir  -p /opt/calc
    cp $< /opt/calc/calculator

.PHONY: uninstall
uninstall:
    rm -f /opt/calc/calculator

make() can figure out that we want an object file from a source file as it has an implicit rule for updating an object .o file from a correspondingly named .c file using a cc -c command.

cc -c main.c -o main.o

The source .c file is automatically added to the dependencies, so we can reduce our rule:

main.o: main.c main.h
    ${CC} -c main.c

letting it appear as:

main.o: main.h 

Chances are that when building a program with make we get an error like this:

cannot find file "sum.h"

telling us that some required header isn't found. We can tell make where to look for prerequisites using the VPATH macro.

The value of VPATH specifies a list of directories that make should search expecting to find prerequisite files and rule targets that are not in the current directory.

VPATH = /inc /modules/inc

Note that VPATH will look through the directories list in the order we write them from left to right.

Another option to look for prerequisites is telling the compile where to look for them using the -I flag which indicates a directory where the requested code should be:

-I/src/inc

and should be included in the CFLAGS macro.

We can take our example and clean it with the new shown resources:

CC = cc
INC = -Iinclude
CFLAGS = -c -g -Wall ${INC}
LDFLAGS = -lm
SRCS != ls src/*.c
OBJ = ${SRC:.c=.o}

prog_name = calculator

calculator: ${OBJ}
    ${CC} -o ${prog_name} ${OBJ} ${LDFLAGS}

${OBJS}: ${SRCS}
    ${CC} ${CFLAGS} -c $< -o $@

.PHONY: clean
clean: 
    rm -f *.o ${prog_name}

.PHONY: install
install: ${prog_name}
    mkdir  -p /opt/calc
    cp $< /opt/calc/calculator

.PHONY: uninstall
uninstall:
    rm -f /opt/calc/calculator

Now we only need to save the file and execute make calling the desired command. To build the calculator program it'd be:

$ make calculator && install

Comments

Comments are pretty much self explanatory. They are lines of text that as in programming languages, do nothing but provide useful information or reminders.

We can place comments around our Makefile by using the hashtag # symbol. Anything after a # will be ignored.

# An example comment

Summing up

In addition to compiling and building our own C/C++ code, working inside a BSD system involves being working close with its source code, and most of the times we have to compile and build packages from ports. That process works the same way so you can now start tweaking and inspecting source Makefiles each time you need to change or install a program. It'll grant you access to custom install instructions specific for your machine.

We can do more things with make like building install menus, compiling libraries and including Makefiles inside other Makefiles. All those topics need a dedicated article for each of them.

There's an official manual for GNU Make that you can read for advanced knowledge in the tool, although staying POSIX is always a recommended thing.