A quick introduction to GNU Make
(Make Series pt.1)

A quick introduction to GNU Make (Make Series pt.1)

because build tools don't have to be intuitive

ยท

7 min read

When GNU Make was first introduced to the world in 1976, it was named as such because it would Make you hate build systems. Today, Make remains a powerful yet hard-to-use build tool. In this first article in a series on GNU Make, we will dive into the basics and attempt to demystify this jackhammer of a tool.


The basics

Make is a build tool, that is to say, a program that is used to automate the creation of executables based on source code.

When you build a c application using gcc

$ gcc main.c -o main

you are manually compiling your source code. While this is fine for small projects, it gets tedious for anything beyond a simple hello world application.

A Makefile is a file containing a set of rules that specify how to build your project. This makes it simpler to compile large projects on the fly.

# uses the makefile in the current directory to compile your project
$ make all

Pitfalls of manual compilation

Manual compiling isn't just tedious: it's slow. When you recompile a project using gcc, you recompile all source files. But what if you didn't modify each file? Imagine you have the following project structure:

Calling gcc will recompile all files individually

# a.c is recompiled even if it hasn't changed!
$ gcc src/a.c src/b.c -Iinclude -o main

On large projects with hundreds, if not thousands of source files, this can mean the difference between waiting hours for your project to build, or just minutes. So how do we fix this? Well, hold on a second! First, we need to learn a bit more about Make.


Make syntax

Here is a simple Makefile that we can use to generate an executable for our toy project:

all: src/a.o src/b.o
    gcc src/a.o src/b.o -Iinclude -o main

src/a.o: src/a.c
    gcc -c src/a.c -o src/a.o -Iinclude

src/b.o: src/b.c
    gcc -c src/b.c -o src/b.o -Iinclude

A Makefile is made up of targets, prerequisites, and recipes.

  • target: these are the files we want to generate. They are the keywords to the left of the ':' symbol.

  • prerequisites: these are the dependencies needed to generate a file. They are the keywords to the right of the ':' symbol.

  • recipe: these are the commands used to generate a file. They are the lines under the target and the prerequisites.

Together, a target, prerequisite and recipe form a rule.

target: prerequisite1 prerequisite2 ...
    recipe ๐Ÿฐโ€Š
    ...

for example:

all: a.c b.c
    gcc src/a.o src/b.o -Iinclude -o main
  • all is a target

  • src/a.o and src/b.o are prerequisites

  • gcc src/a.o src/b.o -Iinclude -o main is a recipe

gcc options:

  • -I: the directory in which header files are located

  • -o: the name of the output binary

  • -c: compilation will be done without linking (creates an object file instead of an executable)


Make execution

When Make is executing a Makefile, it will recursively try and resolve any prerequisites a rule has. What does this mean?

Let's break this down, one rule at a time:

all: src/a.o src/b.o
    # this won't be executed until src/a.o and src/b.o exist
    gcc src/a.o src/b.o -Iinclude -o main

Make will always execute the first target in a Makefile. By convention, the first target should be called all.

src/a.o is a dependency for all. However, src/a.o does not exist already, so Make will try and generate it. For this, it will have to find a rule that specifies how to generate src/a.o

src/a.o: src/a.c
    gcc -c src/a.c -o src/a.o -Iinclude

Ah, we found it! Make will use this rule to generate src/a.o. This rule depends on src/a.c, which already exists, so Make will not try and generate it.

Note that if Make cannot find a.c in the current working directory, the build will fail.

Now that Make has generated src/a.o, it will do the same for src/b.o. Once all prerequisites for all have been met (that is to say, src/a.o and src/b.o now exist), Make will finally execute the recipe associated with all.

all: src/a.o src/b.o
    # Now that src/a.o and src/b.o exist, this can be executed
    # and the main binary compiled
    gcc src/a.o src/b.o -Iinclude -o main

Let's run this code and see what happens!

$ make # runs the code in our Makefile
gcc -c src/a.c -o src/a.o -Iinclude # builds src/a.o
gcc -c src/b.c -o src/b.o -Iinclude # builds src/b.o
gcc src/a.o src/b.o -Iinclude -o main # builds the main binary

Hurray! But what is so special about this? Couldn't I just have written these commands myself? Let's run the build again to see Make's greatest advantage.

$ make
gcc src/a.o src/b.o -Iinclude -o main

Well... As you can see Make only recompiles main, but why is it doing this? Let's break down exactly what is happening and why this common mistake isn't quite the behavior we want.


Dependency tracking

Make is a dependency-tracking build tool. This means that Make follows a set of rules to minimize the number of recompilations required to build your project. For example:

  • If a target does not exist, Make will try and build it.

  • If a target's prerequisites have been modified, Make will execute that target's recipe.

These rules are applied recursively. So, for example, if src/a.c is modified then src/a.o will be rebuilt, which will trigger the task all to have its recipe executed.

dependency tree

Dependency tree for project Makefile. If you start from the right, you can see which files will have to be recompiled upon change.

Notice how I used the term task and not executable? That is because, by default, Make does not have any way to know what files you are generating.

Make is only aware of the tasks and dependencies specified to it. If you want to minimize recompilation, make sure these correspond to the files you are generating so Make can keep track of them!

Remember our previous Makefile? In particular, let us focus on the first rule:

all: src/a.o src/b.o
    gcc src/a.o src/b.o -Iinclude -o main
  • task all (make will try and build this)

  • dependencies src/a.o src/b.o (make will look for these before building the task)

By specifying all as a task, we are telling Make that this file should be generated. When we call Make again, it will look for all. Since all does not exist, it will try and generate it, by calling its recipe, triggering recompilation even if no file has been modified.

Here is an updated version of the Makefile which avoids this pitfall.

all: main

main: src/a.o src/b.o
    gcc src/a.o src/b.o -Iinclude -o main

src/a.o: src/a.c
    gcc -c src/a.c -o src/a.o -Iinclude

src/b.o: src/b.c
    gcc -c src/b.c -o src/b.o -Iinclude

Notice that all does not have any recipe associated with it! This way, when Make runs again, it will check if any dependencies for all have changed, but it will not run any recipe or recompile any files unnecessarily.

$ make
make: Nothing to be done for 'all'.

Just to be sure, let's change the first rule to:

all: main
    echo "If a rule is present in all, it will always run ๐Ÿ˜ฑ"

If we run Make again, the output is:

$ make
echo "If a rule is present in all, it will always run ๐Ÿ˜ฑ"
If a rule is present in all, it will always run ๐Ÿ˜ฑ

The power of Make

Perhaps now you can begin to see the superpower which Make provides: by automatically checking for changes in files, Make allows us to keep our recompilation fast and to a minimum. But that isn't all!

We've only just scratched the surface of what Make allows us to do. Right now, our makefile is still very inflexible, and we have to write a rule for every file we want to compile.

Check out our next episode to find out how to use variables and pattern matching to make your code more reusable and easier to maintain!

ย