Programming with Makefiles

C programmers never die. They are just cast into void.
--Alan Perlis

Hi all! Today we will be looking at Makefiles. First we will understand why we need them, after which we will dive into building a makefile for a simple C++ program. This tutorial is by no means exhaustive as to the capabilities of makefiles, but is intended as a helpful starting point for beginner programmers. This tutorial is adapted for a Linux environment.

Makefiles are used to automate the build process for large projects. They are very common for C/C++ projects but can be used for other purposes once understood. To better appreciate the need for makefiles, let’s consider the following C++ program which creates an object that prints a message to the terminal. Our program consists of a header file message.hpp which defines a class Message, a C++ source file message.cpp which defines the methods of class Message, and a second C++ file which defines the main entrypoint of the program. The contents of the files are as follows:


message.hpp
1
2
3
4
5
6
7
8
9
10
11
#ifndef MESSAGE_HPP
#define MESSAGE_HPP

class Message
{

public:
    void hello();
};

#endif /* MESSAGE_HPP */
message.cpp
1
2
3
4
5
6
7
8
#include "message.hpp"
#include <iostream>
using namespace std;

void Message::hello()
{
    cout << "Hello Peterson!\n";
}
main.cpp
1
2
3
4
5
6
7
8
9
#include "message.hpp"
#include <cstdlib>

int main()
{
    Message m;
    m.hello();
    return 0;
}

The files are placed following the directory structure below:

app
|───src
|   |───headers
|   |   | message.hpp
|   | message.cpp
|   | main.cpp
|       

To compile the program files into an executable binary app, we can run the following command from within the app directory: g++ src/main.cpp src/message.cpp -Isrc/headers -o app -lstdc++. Here the g++ (GNU C++) compiler compiles the .cpp files into object files .o which are then bundled up by the linker to create the final executable binary app.

The figure below summarises this process:

We can then run our executable file with ./app.

Now this is nice and simple when we have a small project with just 2 or 3 source files to compile. Imagine a scenario where we had hundreds or thousands of source files. Using the above command becomes extremely tedious. Moreover, doing the above will recompile each file every time, regardless of whether it was modified or not. This is very inefficient, and here is where makefiles come into the picture.


Structure of a makefile

In its simplest form, the content of a makefile comprises chunks of instructions known as rules with the following structure:

target: dependencies
    recipe

A target is the output generated by the program, e.g executable files or object files. The dependencies or prerequisites are files used to create the target, e.g .c, .cpp, or .o files. And lastly, the recipe is a command or action that the make tool carries out to produce a target using the given dependencies. Note: we have a tab after : and just before recipe. This is an important detail to note because trailing whitespaces in makefiles are problematic and often tedious to fix.

Going back to our example program, we have 3 outputs (our make targets): message.o (target 1) after compilation of message.cpp (a dependency), main.o (target 2) after compilation of main.cpp, and lastly the final binary, app (target 3), after linking message.o, main.o (its dependencies), and the C++ standard library stdc++. Targets message.o and main.o will have message.hpp as a dependency since they should be recompiled/rebuilt if that header file is changed.

Using this information, we can create a very simple make file called Makefile in the app directory, and input 3 rules which will permit us to build the final program. The rules are as follows:


message.o: src/message.cpp src/headers/message.hpp
	g++ -Isrc/headers -c src/message.cpp -o message.o

main.o: src/main.cpp src/headers/message.hpp 
	g++ -Isrc/headers -c src/main.cpp -o main.o

app: message.o main.o
	g++ -Isrc/headers message.o main.o -o app -lstdc++

We can then build our program by running make app which will produce 3 files: message.o, main.o, and app. You notice that we instructed make to build the target app but make builds the first two targets because they are dependencies of app, and the make file contains rules to build them too. If the rule to build message.o or main.o was absent, make will inform you it cannot find the rule to build that dependency and exit sadly.

Extended makefile

The makefile above is enough for us to understand the basic principles of make. However you will hardly see such simple makefiles in practice. You can have a look at a makefile from the linux kernel here to have an idea. Frightful :fearful: right ? So in this section, we will extend our makefile so it looks much more professional and geeky:sunglasses:.

If we observe our rules above, we notice some repetition such as -Isrc/headers, src/headers/message.hpp or even g++. These could be much longer in practice, which will lead to a very clumsy make file. This brings us to the idea of make variables. A variable in make is simply a name defined in the makefile to represent a string of text. It is good practice to name variables in all CAPITALS. If we define a variable PATH := /path/to/file (you can use = too), we can access its value with $(PATH). You must define a variable before you try to reference its value.

Using this knowledge, we shall define a few variables at the top of our previous make file as follows:


CXX := g++

SRC := src
INCLUDE_PATHS := -Isrc/headers
COMPILE_FLAGS := $(INCLUDE_PATHS)
LD_FLAGS := -lstdc++

HEADERS := $(wildcard src/headers/*.hpp)

OBJ_NAMES := message.o main.o 
APP_OBJS := $(addprefix $(SRC)/,$(OBJ_NAMES))

APP_NAME := app

Most of the variable definitions above are self-explanatory except for a few. For the HEADERS variable, the wildcard syntax simply means all the .hpp files in the src/headers folder. The OBJ_NAMES variable is a list with the object file names. As for the APP_OBJS variable, the addprefix command appends src/ to each of the object file names. So APP_OBJS expands to: APP_OBJS := src/message.o src/main.o.

Next thing we notice is the set of rules. We observe that the rules for targets message.o and main.o are quite similar. Using make patterns, we can create a generic rule for both targets. We can also add an additional action in our rules to print messages. Using our newly defined variables, our new rule (for both targets: message.o and main.o) would look like this follows:


$(SRC)/%.o: $(SRC)/%.cpp $(HEADERS)
	@$(CXX) $(COMPILE_FLAGS) -c $< -o $@
	@echo "CXX <= $<" 

It looks a bit frightful but we will break it down. $(SRC)/%.o is a target-patten which will match all object file names (targets) in the src directory. Similarly, $(SRC)/%.cpp is a prereq-patten which will match all the .cpp files in the src directory. The @ symbol before an action prevents the full command from being printed to the terminal. $< is an automatic variable which represents the first prerequisite in the rule (i.e $(SRC)/%.cpp) in our case, while $@ is an automatic variable which represents the target of the rule (i.e $(SRC)/%.o). The rule with echo simply prints the prerequisite being compiled by g++.

Similarly, we can create a more concise rule for the app target as follows:


$(APP_NAME): $(APP_OBJS)
	@$(CXX) $(COMPILE_FLAGS) $^ -o $@ $(LD_FLAGS)

This is similar to the generic rule above except for a new automatic variable $^. The latter represents all the prerequisites in the rule (i.e $(APP_OBJS) in our case).

Our makefile looks really geeky and professional at the moment but we will add one final ingredient to the mix.

PHONY targets

Sometimes we may want a rule whose target is not necessarily a file. An example could be a rule to simply delete or clean previously compiled targets. We call the corresponding targets PHONY targets. The latter have no dependencies. We will define a PHONY target called clean to delete all previously compiled files as follows:


.PHONY: clean

clean:
	@rm $(APP_NAME) $(APP_OBJS) 

Now our makefile is complete !! You can bundle up the variable definitions, the two generic rules, and the last PHONY target rule into your makefile and rebuild your application again with the make app command in your terminal. To delete the previously compiled files, we simply type the command make clean in our terminal.

For more in-depth information on make, you can visit the official site.


Thanks for keeping up until this point. I hope you learnt alot. Stay tuned for the next tips in my tech diaries.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • Productivity tools for every PhD student
  • a distill-style blog post
  • a post with images
  • a post with code
  • a post with math