The Gentlest Introduction to Building With Makefiles

Published on 2020-05-14 18:44:00+02:00

If you are here, you are most likely in need to build C or C++ program. Chances are you were not even looking for tutorial about GNU make or Makefiles in general. Chances are that you need to get you assignment done by yesterday, or you want to refresh your memory from back in the day you used C for the last time. No matter your background, I'll try to walk you through the process of building your C or C++ program using make command.

Sadly, the tutorial will explain the stuff, so that you have an overview after reading it. It won't go too deeply. Anyway, if you are only interested in an example to copy, there is one below.

If you have no idea, why using build system is nice. There are plenty of reasons. I will give you two. They automate the building process, so that you don't have to type same things all over again, and you don't need to remember your configuration at all times. If used consistently, they try to build only parts of your project that were changed. It can affect the building time greatly.

Building a single file project

You have just finished writing your first implementation of Hello, world!, you have terminal open or some kind of prompt up and running, and now you would like to build the program and execute it. You've probably seen it somewhere but let me remind how to do it by hand using gcc:

$ ls
hello.c
$ gcc hello.c -o hello
$ ./hello
Hello, world!

Nice! But writing gcc hello.c -o hello all the time when you want to rebuild the program sounds bothersome even if you consider using command history. If you were to extend the program with libraries or additional files it sounds even more tiresome.

Let's put make to use! All you need to do is replace gcc hello.c -o part with make, so that you have:

$ ls
hello.c
$ make hello
cc hello.c -o hello
$ ls
hello   hello.c
$ ./hello
Hello, world!

You probably noticed that make shamelessly prints out the command it used to build your program. How did it know? Make is a master of default variables, implicit rules, deduction, and hiding it's secrets from curious eyes of those who seek knowledge. Actually, no, the documentation is available for anyone in various forms. We'll not discuss it in detail, that wouldn't be gentle, so assume for now that make will know how to compile and link your C or C++ program. Rules that describe how make does that are called implicit rules. We'll use and affect them extensively.

colorful toy blocks

Using libraries with implicit rules

Make and makefiles heavily rely on your environment. If you don't know what it is, for simplicity let's say that the environment is a set of variables associated with your current shell/terminal/prompt session. Make is so greedy that it takes all of your environmental variables and copies them as own. The implicit rules may use those copied variables, and they do exactly that. Those variables are usually called implicit variables.

We can take advantage of it. Let's say we are building a game with SDL2. SDL2 requires an additional include directory, a flag, and a library in order to build with it. Firstly, we'll set selected variables in our environment (via export VARIABLE=value), and then we'll build the program:

$ ls
hello-sdl.c
$ export CFLAGS='-D_REENTRANT -I/usr/include/SDL2'
$ export LDLIBS='-lSDL2'
$ make hello-sdl
gcc -D_REENTRANT -I/usr/include/SDL2  hello-sdl.c  -lSDL2  -o hello-sdl
$ ./hello-sdl

The values I've used are specific to SDL2, don't mind them. What interests us in this example are the names of the variables: CFLAGS and LDLIBS. First one, is a set of parameters that describe how our thing should be handled during compilation. CFLAGS is for C language, for C++ programs there is an equivalent variable called CXXFLAGS. Second variable, LDLIBS may contain a list of libraries that the linker should link to our program. In the example above there is no clear difference between compilation and linking, and thus both variables are copied by make to a single command. Luckily, it makes no difference to us, especially when the outcome is satisfying.

First Makefile

Obviously, we would need to repeat those exports each time we start new session. This would bring us back to the level of repeating whole gcc call on and on. We could put them in some kind of file, couldn't we? Luckily, make predicted that and it may read the contents of so-called makefiles. Just put a file called Makefile in the project directory and insert the variables there:

CFLAGS=-D_REENTRANT -I/usr/include/SDL2
LDLIBS=-lSDL2

Now, if you run make, it will read the Makefile and use the variables that are defined in it:

$ ls
hello-sdl.c   Makefile
$ make hello-sdl
gcc -D_REENTRANT -I/usr/include/SDL2  hello-sdl.c  -lSDL2  -o hello-sdl

Less writing is always cool. How about getting rid of the hello-sdl from every call to make? That's also possible. hello-sdl is a target. Targets are associated with rules, any number of them, be it implicit or user-defined. If user doesn't provide a target name as an argument in command line, make uses first target that is specified in the makefile. We can create targets by writing rules. The syntax to do so is rather straight forward and contains: names of targets, prerequisites needed, and a recipe which may be a single-line command or may span the eternity. Knowing all of that, we can write a very peculiar rule. Everything will be handled by an implicit rule, and we'll only give a hint to make which thing we want it to build by default:

CFLAGS=-D_REENTRANT -I/usr/include/SDL2
LDLIBS=-lSDL2
hello-sdl:

Surprisingly, that's enough. hello-sdl is the name of our target. It's a first target that appears in this makefile, therefore it will be the default one. : (colon) is a required separator between the target and prerequisites list. We didn't add any dependencies, as the sole dependency on hello-sdl.c file is acknowledged thanks to the implicit rule. And because we didn't write any recipe, the recipe from the implicit rule is used. When we use it, it looks like this:

$ ls
hello-sdl.c   Makefile
$ make
gcc -D_REENTRANT -I/usr/include/SDL2  hello-sdl.c  -lSDL2  -o hello-sdl

Adding more files

more files

In a long run, it would be more useful to have more than one file in a project. Make also predicted that and allows users to build programs from multiple sources. Amazing, isn't it? Now is the moment we finally split up compilation from linking in a visible manner. Let's say we have a project with three files hello.c, sum.h and sum.c, their content is respectively:

#include <stdio.h>
#include "sum.h"
int main(int argc, char * argv[]) {
	printf("2 + 3 = %d", sum(2, 3));
}
#pragma once
int sum(int a, int b);
int sum(int a, int b) {
	return a + b;
}

The structure of this project is easily seen. Hello.c depend directly on sum.h due to the include, and it requires the sum function to be compiled and available when linking the final executable. First dependency is so stupidly easy to write, that you might be actually surprised about it: you just need to add sum.h file to prerequisites in the rule description. The other one, is slightly more interesting. We could just add sum.c to prerequisites, but we will die a horrible death after a while if we do that. Technically, it's not even the thing we're trying to accomplish, so don't do that.

Instead, let's use .o files that are products of compilation of a single source file. We can link them together with libraries to form an executable. We are finally clearly dividing our building process into compilation stage, and linking stage. Let's introduce two such files: hello.o and sum.o. They are build from their respective sources. This means we now have three files with compiled or linked code: hello, hello.o and sum.o. Latter doesn't depend on anything, so there is no need for us to write anything about it. hello.o depends on sum.h (again, due to already mentioned include). Despite the fact that we call the sum() function in it, it doesn't depend on sum.o. Why? Because it is just an intermediate file. It never executes anything. On the other hand, hello executes it, so it needs all of the intermediate .o files in its prerequisites list.

All in all, the Makefile will look like this:

hello: hello.o sum.o
hello.o: sum.h

When we use it:

$ make
cc -c -o hello.o hello.c
cc -c -o sum.o sum.c
cc hello.o sum.o -o hello
$ ./hello
2 + 3 = 5

Surprisingly, that's all you need to know. With that, you can build pretty much everything. With time and when you gain some additional knowledge you may want to write your makefiles more explicitly: things like CC=gcc to make sure that the correct compiler is used. Your own recipes for generating headers or targets that are not generating any files, but rather install the software or clean up the directory. Targets not associated with files are called .PHONY and sooner or later you will encounter them. Actually...

Clean up your project directory with make

For some reasons, you may want to remove all built executables and intermediate files, or any other garbage files that your workflow involves. In previous part, I've already noted that you can accomplish that using .PHONY targets. Such cleaning target is quite common and is usually called clean. Consider the following:

hello: hello.o sum.o
hello.o: sum.h

clean:
	$(RM) *.o hello

.PHONY: clean

As it's not the default target, you must invoke it by name:

$ make clean
rm -f *.o hello

The marked line is called the recipe. It describes what rule is supposed to do. Only one recipe per target is used, make discards previous recipes for the target if new one is defined, so only the bottom-most is effective. $(RM) is a default variable that is expected to describe the command that can be used to safely remove files no longer needed by the project. You've probably noticed that .PHONY exists as a target. We add clean to it's prerequisites list to let make know that clean is not expected to create a file called clean.

sweeping dust

Example makefile for C++ project

Following makefile is used to build a simple C++ pager, program for opening and scrolling through a file in a command line interface. Please note, that by default cc is used as a linker. It means that, if we are building a C++ project, the standard C++ library will be missing. We can avoid it by writing own linking recipe, or by adding -lstdc++ to libraries manually. Latter approach is used in the example.

CXXFLAGS=-std=c++17 -Wall -Wextra -Werror -O2
LDLIBS=-lstdc++ -lncurses++

pager: ansi.o content.o pager.o
pager.o: ansi.h content.h

clean:
	$(RM) pager *.o

.PHONY: clean

What's next

Obviously, that's not everything there is to make. In my opinion, this is all you need for regular usage in small to medium projects. From this point you can extend your knowledge. I would suggest to learn more about variables and built-in functions. They will help you to create more extendible makefiles, and write less in general. In case you'll end up needing to write a proper recipe and more complicated rule - head to sections: writing recipes or writing rules. The automatic variables are an amazingly useful tool when writing your own rules. Actually, these three things are usually, the first to be mentioned by other tutorials about make. However, with approach presented in here, you should be able to avoid them for quite a long time. Be wary though - don't be ignorant. You've been showed the basic usage of make that is heavily dependent on implicit rules and hidden mechanics. You know that they exists and now it's turn for you to go out there, use them and slowly learn how they really work.