C programming
April 17, 2020

Makefiles | The power to build

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:

  • The first step a compiler does is take our .c files and call the preprocessor, which handle the directives that start with a # like #include and #define and gets rid of the comments that may be present in our code.

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.

  • The second step takes the source file and calls the compiler to translate C code into Assembly code, ending up with a file that has a .s extension.
  • Once the compiler is done, it needs to translate the Assembly code into machine code, creating an object file, which is done via the assembler. The result file .o isn't an executable yet.
  • The last step is bringing together all the object files to produce an executable. This part is done with the linker.

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.

  • -E calls the preprocessor only.
  • -S runs the compiler and stops at the assembly file.
  • -c is used to run the compiler up to the creation of an object file.
  • -o generates the executable program from the object files.
  • -g allows using gdb debugging.
  • -Wall enables the compiler to print all warnings encountered while building the program.
  • -I specifies a directory that contains prerequisites.

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 (that can be explicit or implicit)
  • Macros (variable definitions)
  • Comments

— Rules

A Makefile rule needs three basic items:

  • A target, which is the name of the generated file.
  • The dependencies needed to build the target.
  • An action to realize in order to get the target.

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
  • Our target can be named as the program we want to create, so in this case is calculator.
  • Our dependencies are five object files. Each of those files comes from it's own source.
  • Our action is to execute the desired compiler, in this case cc to generate an output executable with the name calculator .

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 lt; /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 is used to store the name of the compiler which we want to use (cc, gcc, clang, etc).
CC = cc
  • CFLAGS is used to list the flags we want the compiler to use.
CFLAGS = -c -g -Wall
  • LDFLAGS is used to link libraries. Some header files like <math.h> are part of the system and aren't locally present in our code, but as any other header file, they contain just declarations and the compiler needs to check for the actual definitions somewhere.
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. We can take the advantage of wildcards:

SRC = $(wildcard *.c)

which will take every .c file inside the current directory.

Note that the value for SRC is encapsulated between brackets and includes the explicit wilcard 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 = $(wildcard *.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 lt; /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
CFLAGS = -c -g -Wall -I/src/inc
LDFLAGS = -lm
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)

prog_name = calculator

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

main.o: main.h
sum.o: sum.h
sub.o: sub.h
mult.o: mult.h
div.o: div.h

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

.PHONY: install
install: ${prog_name}
      mkdir  -p /opt/calc
      cp lt; /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

— 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 hastag # 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.