Dec 6, 2022 11 min read

Using the Make Utility and Makefiles in Linux [Guide]

Table of Contents

This is a complete beginner's guide to using the make command in Linux.

You'll learn:

  • The purpose of the make command
  • Installation of the make command
  • Creating and using the makefile for a sample C project

What is the make utility?

The make utility is one of the handiest utilities for a programmer. Its primary purpose is to compile a medium-to-large software project. The make utility is so helpful and versatile that even the Linux kernel uses it!

To understand the usefulness of the make utility, one must first understand why it was needed in the first place.

As your software gets more extensive, you start relying more and more on external dependencies (i.e., libraries). Your code starts splitting into multiple files with God knows what is in each file. Compiling each file and linking them together sanely to produce necessary binaries becomes complicated.

"But I can create a Bash script for that!"

Why yes, you can! More power to you! But as your project grows, you must deal with incremental rebuilds. How will you handle it generically, such that the logic stays true even when your number of files increases?

This is all handled by the make utility. So let us not reinvent the wheel and see how to install and make good use of the make utility.

Installing the make utility

The make utility is already available in the first-party repositories of almost all Linux distributions.

To install make on Debian, Ubuntu, and their derivatives, use the apt package manager like so:

sudo apt install make

To install make on Fedora and RHEL-based Linux distributions, use the dnf package manger like so:

sudo dnf install make

To install make on Arch Linux and its derivatives, use the pacman package manager like so:

sudo pacman -Sy make

Now that the make utility is installed, you can proceed to understand it with examples.

Creating a basic makefile

The make utility compiles your code based on the instructions specified in the makefile in the top level directory of your project's code repository.

Below is the directory structure of my project:

$ tree make-tutorial

make-tutorial
└── src
    ├── calculator.c
    ├── greeter.c
    ├── main.c
    └── userheader.h

1 directory, 4 files

Below are the contents of the main.c source file:

#include <stdio.h>

#include "userheader.h"

int main()
{
    greeter_func();

    printf("\nAdding 5 and 10 together gives us '%d'.\n", add(5, 10));
    printf("Subtracting 10 from 32 results in '%d'.\n", sub(10, 32));
    printf("If 43 is  multiplied with 2, we get '%d'.\n", mul(43, 2));
    printf("The result of dividing any even number like 78 with 2 is a whole number like '%f'.\n", div(78, 2));

    return 0;
}

Next are the contents of the greeter.c source file:

#include <stdio.h>

#include "userheader.h"

void greeter_func()
{
    printf("Hello, user! I hope you are ready for today's basic Mathematics class!\n");
}

Below are the contents of the calculator.c source file:

#include <stdio.h>

#include "userheader.h"

int add(int a, int b)
{
    return (a + b);
}

int sub(int a, int b)
{
    if (a > b)
        return (a - b);
    else if (a < b)
        return (b - a);
    else return 0;
}

int mul(int a, int b)
{
    return (a * b);
}

double div(int a, int b)
{

    if (a > b)
        return ((double)a / (double)b);
    else if (a < b)
        return ((double)b / (double)a);
    else
        return 0;
}

Finally, below are the contents of the userheader.h header file:

#ifndef USERHEADER_DOT_H
#define USERHEADER_DOT_H

void greeter_func();

int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
double div(int a, int b);

#endif /* USERHEADER_DOT_H */

Basics of a makefile

Before we create a bare-bones makefile, let us take a look at the syntax of a makefile. The basic building block of a Makefile consists of one or many "rules" and "variables".

Rules in a makefile

Let us first take a look at rules in the makefile. A rule of makefile has the following syntax:

target : prerequisites
    recipe
    ...
  • A target is the name of file that will be generated by make. These are usually object files that are later on used for linking everything together.
  • A prerequisite is a file that is necessary for the target to be generated. This is where you usually specify your .c, .o and .h files.
  • Finally, a recipe is one or many steps needed to generate the target.

Macros/Variables in makefile

In C and C++, a basic language feature is variables. They allow us to store values that we might want to use in a lot of places. This helps us use the same variable name where needed. An added benefit is we only need to make one change if we need to change the value.

Similarly, a makefile can contain variables. They are sometimes referred to as macros. The syntax to declare a variable in a Makefile is as follows:

variable = value

A variable and the value(s) it holds are separated by an equals (=) sign. Multiple values are separated by spaces between each other.

In general, variables are used to store various items necessary for compilation. Let's say that you want to enable run-time buffer overflow detection and enable full ASLR for the executable; this can be achieved by storing all the compiler flags in one variable, like CFLAGS.

Below is a demonstration doing this:

CFLAGS = -D_FORTIFY_SOURCE=2 -fpie -Wl,-pie

We created a variable called CFLAGS (compiler flags) and added all of our compiler flags here.

To use our variable, we can enclose it in parentheses beginning with a dollar sign, like so:

gcc $(CFLAGS) -c main.c

The above line in our makefile will add all of our specified compiler flags and compile the main.c file as we require.

Automatic variables

The make utility has a few automatic variables to help ease repetition even further. These variables are commonly used in a rule's recipe.

Some of the automatic variables are as follows:

Automatic variables Meaning
[email protected] Name of the rule of target. Usually used to specify the output filename.
$< Name of the first pre-requisite.
$? Names of all pre-requisites that are newer than the target. i.e. files that have been modified after the most recent code compilation
$^ Names of all pre-requisites with spaces between them.

You can find the full list of the automatic variables on GNU Make's official documentation.

Implicit Variables

Like the automatic variables covered above, make also has some variables that have a set use. As I previously used the CFLAGS macro/variable to store compiler flags, there are other variables that have an assumed use.

This can be thought not of as "reserved keywords" but more like the "general consensus" of naming variables.

These conventional variables are as follows:

Implicit variables Description
VPATH Make utility's equivalent of Bash's PATH variable. Paths are separated by the colon sign (:). This is empty by default.
AS This is the assembler. The default is the as assembler.
CC The program for compiling C files. The default is cc. (Usually, cc points to gcc.)
CXX The program for compiling C++ files. The default is the g++ compiler.
CPP The program that runs the C pre-processor. The default is set to $(CC) -E.
LEX The program that turns Lexical grammars into source code. The default is lex. (You should change this to flex.)
LINT The program that lints your source code. The default is lint.
RM The command to remove a file. The default is rm -f. (Please pay strong attention to this!)
ASFLAGS This contains all the flags for the assembler.
CFLAGS This contains all the flags for the C compiler (cc).
CXXFLAGS This contains all the flags for the C++ compiler (g++).
CPPFLAGS This contains all the flags for the C pre-processor.
.PHONY Specify targets that do not resembe name of a file. An example is the "make clean" target; where clean is a value of .PHONY

Comments in a makefile

Comments in a makefile are like those in a shell script. They start with the pound/hash symbol (#) and the contents of said line (after the pound/hash symbol) are considered as a comment by the make utility and is ignored.

Below is an example demonstrating this:

CFLAGS = -D_FORTIFY_SOURCE=2 -fpie -Wl,-pie
# The '-D_FORTIFY_SOURCE=2' flag enables run-time buffer overflow detection
# The flags '-fpie -Wl,-pie' are for enabling complete address space layout randomization

Initial draft of a makefile

Now that I have described the basic syntax of a makefile's elements and also the dependency tree of my simple project, let us now write a very bare-bones Makefile to compile our code and link everything together.

Let us start with setting up the CFLAGS, CC and the VPATH variables that are necessary for our compilation. (This is not the complete makefile. We will be building this progressively.)

CFLAGS = -Wall -Wextra
CC = gcc
VPATH = src

With that done, let us define our rules for building. I will create 3 rules, for each .c file. My executable binary will be called make_tutorial but yours can be whatever you want!

CFLAGS = -Wall -Wextra
CC = gcc
VPATH = src


make_tutorial : main.o calculator.o greeter.o
        $(CC) $(CFLAGS) $? -o [email protected]

main.o : main.c
        $(CC) $(CFLAGS) -c $? -o [email protected]

calculator.o : calculator.c
        $(CC) $(CFLAGS) -c $? -o [email protected]

greeter.o : greeter.c
        $(CC) $(CFLAGS) -c $? -o [email protected]

As you can see, I am compiling all the .c files into object files (.o) and linking them together at the end.

When we run the make command, it will start with the first rule (make_tutorial). This rule is to create a final executable binary of the same name. It has 3 prerequisite object files for each .c files.

Each consecutive rule after the make_tutorial rule is creating an object file from the source file of same the name. I can understand how complex this feels. So let us break down each of these automatic and implicit variables and understand what they mean.

  • $(CC): Calls the GNU C Compiler (gcc).
  • $(CFLAGS): An implicit variable to pass in our compiler flags like -Wall, etc.
  • $?: Names of all prerequisite files that are newer than the target. In the rule for main.o, $? will expand to main.c IF main.c has been modified after main.o had been generated.
  • [email protected]: This is the target name. I am using this to omit typing the rule name twice. In rule for main.o, [email protected] expands to main.o.

Finally, the options -c and -o are gcc's options for compiling/assembling source files without linking and specifying an output file name respectively. You can check this by running the man 1 gcc command in your terminal.

Now let's try and run this makefile and hope it works on first try!

$ make
gcc -Wall -Wextra -c src/main.c -o main.o
gcc -Wall -Wextra -c src/calculator.c -o calculator.o
gcc -Wall -Wextra -c src/greeter.c -o greeter.o
gcc -Wall -Wextra main.o calculator.o greeter.o -o make_tutorial

If you look closely, each step of compilation contains all the flags we specified in the CFLAGS implicit variable. We can also see that the source files were automatically sourced from the "src" directory. This occurred automatically because we specified "src" in the VPATH implicit variable.

Let's try and run the make_tutorial binary and verify if everything works as intended.

$ ./make_tutorial
Hello, user! I hope you are ready for today's basic Mathematics class!

Adding 5 and 10 together gives us '15'.
Subtracting 10 from 32 results in '22'.
If 43 is  multiplied with 2, we get '86'.
The result of dividing any even number like 78 with 2 is a whole number like '39.000000'.

via GIPHY

Improving the makefile

"What is there to improve?"
Let us run the ls command you can see that for yourself ;)

$ ls --group-directories-first -1
src
calculator.o
greeter.o
main.o
Makefile
make_tutorial

Do you see the build artifacts (object files)? Yeah, they can clutter things up for the worse. Let's use our build directory and reduce this clutter.

Below is the modified makefile:

CFLAGS = -Wall -Wextra
CC = gcc
VPATH = src:build


make_tutorial : main.o calculator.o greeter.o
        $(CC) $(CFLAGS) $? -o [email protected]

build/main.o : main.c
        mkdir build
        $(CC) $(CFLAGS) -c $? -o [email protected]

build/calculator.o : calculator.c
        $(CC) $(CFLAGS) -c $? -o [email protected]

build/greeter.o : greeter.c
        $(CC) $(CFLAGS) -c $? -o [email protected]

Here, I have made one simple change: I added the build/ string before each rule that generates an object file. This will put each object file inside the "build" directory. I also added "build" to the VPATH variable.

If you look closely, our first compilation target is make_tutorial. But it will not be the target that is pedantically the first. The first target whose recipe runs is main.o (or rather build/main.o). Therefore, I added the "mkdir build" command as a recipe in the main.o target.

If I were to not create the "build" directory, I would get the following error:

$ make
gcc -Wall -Wextra -c src/main.c -o build/main.o
Assembler messages:
Fatal error: can't create build/main.o: No such file or directory
make: *** [Makefile:12: build/main.o] Error 1

Now that we have modified our makefile, let us remove the current build artifacts along with the compiled binary and rerun the make utility.

$ rm -v *.o make_tutorial
removed 'calculator.o'
removed 'greeter.o'
removed 'main.o'
removed 'make_tutorial'

$ make
mkdir build
gcc -Wall -Wextra -c src/main.c -o build/main.o
gcc -Wall -Wextra -c src/calculator.c -o build/calculator.o
gcc -Wall -Wextra -c src/greeter.c -o build/greeter.o
gcc -Wall -Wextra build/main.o build/calculator.o build/greeter.o -o make_tutorial

This compiled perfectly! If you look closely, we had already specified the "build" directory in the VPATH variable, making it possible for the make utility to search for our object files inside the "build" directory.

Our source and header files were automatically found from the "src" directory and the build artifacts (object files) were kept inside and linked from the "build" directory, just as we intended.

Adding .PHONY targets

We can take this improvement one step further. Let's add the "make clean" and "make run" targets.

Below is our final makefile:

CFLAGS = -Wall -Wextra
CC = gcc
VPATH = src:build


build/bin/make_tutorial : main.o calculator.o greeter.o
        mkdir build/bin
        $(CC) $(CFLAGS) $? -o [email protected]

build/main.o : main.c
        mkdir build
        $(CC) $(CFLAGS) -c $? -o [email protected]

build/calculator.o : calculator.c
        $(CC) $(CFLAGS) -c $? -o [email protected]

build/greeter.o : greeter.c
        $(CC) $(CFLAGS) -c $? -o [email protected]


.PHONY = clean
clean :
        rm -rvf build


.PHONY = run
run: make_tutorial
        ./build/bin/make_tutorial

Everything about the build targets is the same, except for a change where I specify that I want the make_tutorial binary executable file placed inside the build/bin/ directory.

Then, I set .PHONY variable to clean, to specify that clean is not a file that the make utility needs to worry about. It is... phony. Under the clean target, I specify what must be removed to "clean everything".

I do the same for the run target. If you are a Rust developer you will like this pattern. Like the cargo run command, I use the make run command to run the compiled binary.

For us to run the make_tutorial binary, it must exist. So I added it to the prerequisite for the run target.

Let's run make clean first and then run make run directly!

$ make clean
rm -rvf build
removed 'build/greeter.o'
removed 'build/main.o'
removed 'build/calculator.o'
removed 'build/bin/make_tutorial'
removed directory 'build/bin'
removed directory 'build'

$ make run
mkdir build
gcc -Wall -Wextra -c src/main.c -o build/main.o
gcc -Wall -Wextra -c src/calculator.c -o build/calculator.o
gcc -Wall -Wextra -c src/greeter.c -o build/greeter.o
mkdir build/bin
gcc -Wall -Wextra build/main.o build/calculator.o build/greeter.o -o build/bin/make_tutorial
./build/bin/make_tutorial
Hello, user! I hope you are ready for today's basic Mathematics class!

Adding 5 and 10 together gives us '15'.
Subtracting 10 from 32 results in '22'.
If 43 is  multiplied with 2, we get '86'.
The result of dividing any even number like 78 with 2 is a whole number like '39.000000'.

As you see here, we did not run the make command to compile our project first. Upon running the make run, compilation was taken care of. Let's understand how it happened.

Upon running the make run command, the make utility first looks at the run target. A prerequisite for the run target is our binary file that we compile. So our make_tutorial binary file gets compiled first.

The make_tutorial has its own prerequisites which are placed inside the build/ directory. Once those object files are compiled, our make_tutorial binary is compiled; finally, the Make utility returns back to the run target and the binary file ./build/bin/make_tutorial is executed.

such elegance much wow

Conclusion

This article covers the basics of a makefile, a file that the make utility depends on, to simplify compilation of your software repository. This is done by starting from a basic Makefile and building it as our needs grow.

Pratham Patel
haccerman
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Linux Handbook.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.