Using GNU make to Build Programs

Program development is an iterative process of editing and rebuilding. You can save a lot of time by automating the building process with the GNU make program. I recommend downloading the make manual and consulting it as you read this post.

Using the make program is straightforward. You first create a makefile, which consists of a set of rules for building your program. In the simplest case, just entering the make command will execute the rules needed to build your program. The make program is very flexible. We’ll look only at some of the basic features here, which should be sufficient for the programming in my books.

I recommend placing the primary source files for each project in its own directory along with the makefile for your project. Starting in Chapter 14, we’ll write some functions that we’ll use in several projects. They should be placed in a separate directory that is accessible from each primary project directory. We’ll see how to access files in this separate directory from a project makefile later in this post.

We’ll start by looking at some conventions that make follows regarding the naming of a makefile.

Naming a Makefile

If you enter the make command without specifying the name of a makefile, make looks for a makefile named, in this order, GNUmakefile,makefile, or Makefile. If it finds a makefile with one of these names, make follows the rules in that makefile and ignores any others with subsequent names on this list. I generally use Makefile.

If you want to use a different name for your makefile, you can explicitly specify the name with the -f option. For example, the command make -f myMakefile will follow the rules in the myMakefile file.

The rules in a makefile are typically written such that if you change any of the source code files in the program, make will execute the appropriate rules needed to rebuild the program.

Writing a Makefile

A makefile rule has this general format:

target: prerequisites
    recipe

The target is usually the name of the file that will be generated by executing the recipe, a series of commands that produce target when executed. The prerequisites is a list of the files needed to produce the target. If any of the prerequisites is newer than target, make will execute the recipe to bring target up to date. But before executing the recipe, make first checks to see if any of the prerequisites is itself a target. If so, make first updates that target. We can also define rules that have a target but no prerequisites, which causes make to always execute the rule’s recipe, but we need to be a bit careful about this.

Let’s look at a makefile that could be used to build the intAndString program in Listing 2-1 from Chapter 2.

# Build intAndString program

intAndString: intAndString.c
    gcc intAndString.c -o intAndString

IMPORTANT

Each command in a recipe must be indented with a TAB character. If you have your text editor program set to use spaces when you press the TAB key, you need to figure out how to enter a TAB character at the beginning of each command in the recipe.

All text on a line following the # character is a comment.

If we don’t specify a target name in the make command, the make program will start with the first rule. The first rule in this makefile will use gcc to compile the code in the intAndString.c file and produce the intAndString program.

Even in the very simple case, make simplifies your life. Whenever you change the source code in intAndString.c, you simply type make. The make program compares the time that any of the prerequisite files were last changed with the time the target file was last changed. If the target file is out of date or missing, make executes the commands in the recipe. In this example, if the intAndString.c file is newer, or intAndString doesn’t exist, make executes the recipe:

$ make
gcc intAndString.c -o intAndString
$

IMPORTANT

The make program shows us what the rules are doing. Be sure to read this output to ensure it’s doing what you want.

We can also write rules that do other things for us. For example, the only files I need to back up (you do back up your files, don’t you?) are the source files and the makefile for my project. Let’s add a rule for deleting the file that doesn’t need to be backed up:

# Build intAndString program

intAndString: intAndString.c
    gcc intAndString.c -o intAndString

.PHONY: clean
clean:
    rm intAndString

The rule, clean, has no prerequisites. It simply executes its recipe, which deletes the file that is no longer needed (because we can easily build the program from the source file). Since there are no prerequisites to clean, if we happen to have a file named clean in the directory, the recipe would never get executed because make would think that the target is already up-to-date. We can avoid this problem by declaring our target as a prerequisite to a special built-in make target, .PHONY (see Section 4.8 in the make manual).

You tell make to go directly to a rule by giving the rule’s target as an argument to the make command. For example:

$ make clean
rm intAndString
$

Again, make shows us what it’s doing.

As you can probably guess, makefiles for programs with many files can become quite large, and the rules can be repetitive. In the next section, we’ll look at a way to help reduce the amount of typing you need to do, which helps us to avoid typos.

Implicit Rules

Much of what we do to build a program from source files is very common. The make program includes many implicit rules to handle common cases. If make can deduce what we want to do from the filenames in our project, and if we don’t provide an explicit rule, make will use what it thinks is the appropriate implicit rule. For example, if I write my makefile as:

# Build intAndString program

intAndString:

.PHONY: clean
clean:
    rm intAndString

make will look for a source file named intAndString.* in the directory where the makefile is located. If it finds, say, intAndString.c, it will assume that the file is a C source file and that we want to compile it to produce a program named intAndString

$ make
cc     intAndString.c   -o intAndString
$ 

If, instead, make finds a file named intAndString.cpp it will assume that the file is a C++ source file and that we want to compile it to produce a program named intAndString

$ make
g++     intAndString.cpp   -o intAndString
$ 

Implicit rules use built-in variables to specify their actions. Most built-in variables use uppercase letters in their names. In our examples here, if make found intAndString.c it assumes that this file is a prerequisite to building a program named intAndString and then uses the cc compiler to build it. Similarly, it uses the g++ compiler if instead it finds intAndString.cpp. You can find the correspondence to the filename extensions in Section 10.2 in the make manual.

You probably noticed that make used the cc compiler. The cc command is linked to the default compiler on your computer. Under Ubuntu 20.04, that’s the gcc compiler, but it may be different in other programming environments. We can see which compiler it’s linked to with the following command:

$ cc -v

<--snip-->

gcc version 9.3.0 (Ubuntu 9.3.0-17ubuntu1\~20.04)

But make has many built-in variables that it uses for its implicit rules. Almost all the built-in rules use uppercase letters. The C compiler is specified in the CC variable. I want to make sure that make uses gcc, so I can change the value of CC:

# Build intAndString program

CC = gcc

intAndString:

.PHONY: clean
clean:
    rm intAndString

Now I get:

$ make
gcc     intAndString.c   -o intAndString
$ 

We can also specify some options to the C compiler with the built-in CFLAGS variable:

# Build intAndString program

CC = gcc
CFLAGS = -Wall -O0 -masm=intel -g

intAndString:

.PHONY: clean
clean:
    rm intAndString

which gives:

$ make
gcc -Wall -O0 -masm=intel -g    intAndString.c   -o intAndString
$ 

You can find a list of the built-in variables in Section 10.3 in the make manual.

Programs with Multiple Source Code Files

Most programs have many source code files. With a properly designed makefile, only those files that are changed will be recompiled. Let’s first look at a C program with three source code files:

# Build sum9Ints program

CC = gcc
CFLAGS = -Wall -O0 -masm=intel -g

sum9Ints: addNine.h addNine.c

.PHONY: clean
clean:
    rm sum9Ints

The main function is in sum9Ints.c, which make finds when it looks for sum9Ints.*. The subfunction is in addNine.c which has an accompanying header file, addNine.h. Invoking this make file gives:

$ make
gcc -Wall -O0 -masm=intel -g    sum9Ints.c addNine.h addNine.c   -o sum9Ints
$ 

Although this works, every time one of the three files is changed, both sum9Ints.c and addNine.c will be recompiled. In addition, addNine.h is not needed in the compilation command. The effect is negligible in this very small program, but real-world programs have many source code files, and recompiling all of them can take a long time.

Instead of focusing on the source code files, let’s turn our attention to the object code files.

# Build sum9Ints program

CC = gcc
CFLAGS = -Wall -O0 -masm=intel -g

sum9Ints: sum9Ints.o addNine.o
sum9Ints.o: addNine.h
addNine.o: addNine.h

.PHONY: clean
clean:
    rm sum9Ints.o addNine.o sum9Ints

which gives

$ make
gcc -Wall -O0 -masm=intel -g   -c -o sum9Ints.o sum9Ints.c
gcc -Wall -O0 -masm=intel -g   -c -o addNine.o addNine.c
gcc   sum9Ints.o addNine.o   -o sum9Ints
$

To avoid having to retype the object filenames, I like to define my own variable and set it equal to the object filenames needed to build the program.

Defining Your Own Variables

I use lowercase for my variables to help distinguish them from the built-in variables. Here’s my entire makefile for the sum9Ints program.

# Build sum9Ints program

$(objects)
CC = gcc
CFLAGS = -Wall -O0 -masm=intel -g
genasm = -S -Wall -masm=intel -fno-asynchronous-unwind-tables \
-fcf-protection=none

sum9Ints: $(objects)
sum9Ints.o: addNine.h
addNine.o: addNine.h

sum9Ints.s: sum9Ints.c addNine.h
    gcc $(genasm) -o temp  $<
    expand -t 8 temp > $@
    rm temp

addNine.s: addNine.c addNine.h
    gcc $(genasm) -o temp $<
    expand -t 8 temp > $@
    rm temp

.PHONY: clean allclean
clean:
    rm -f $(objects)
allclean: clean
    rm sum9Ints    

The syntax for substituting the value of a variable is $(variable_name).

I’ve added recipes for telling gcc to generate the assembly language equivalent of the C source code. The gcc compiler uses tabs for spacing the assembly language code it produces. That doesn’t work well when I copy-and-paste into a Word document, so I use the expand command to convert tabs to the appropriate number of spaces.

I’ve also used two very useful built-in variables in these assembly-language generation recipes. The < refers to the first argument on the list of prerequisites, and the @ refers to the target file name. These single-letter variables don’t need to be enclosed in parentheses.

I may wish to delete the object files but not the executable program. So I’ve defined clean to delete the object files and allclean to also delete the program. Specifying clean as a prerequisite to allclean causes the clean recipe to be executed first. If I’ve already used clean to delete the object files, its recipe would fail when we use allclean, causing make to end with the clean recipe. The -f option tells rm to ignore nonexistent files, thus preventing the error condition from ending make.

Mixing C and Assembly

One of the reasons I’m using gcc in my books is that it very nicely integrates C and assembly source code. Our focus on the object code files works well here. Here’s the makfile I use for building a program that has one C source file and one assembly source file.

# Build threeFactorial program

objects = threeFactorial.o factorial.o
CC = gcc
CFLAGS = -Wall -O0 -masm=intel -g
AS = as
ASFLAGS = --gstabs
genasm = -S -Wall -masm=intel -fno-asynchronous-unwind-tables \
-fcf-protection=none

threeFactorial: $(objects)
threeFactorial.o: factorial.h

threeFactorial.s: threeFactorial.c factorial.h
    gcc $(genasm) -o temp $<
    expand -t 8 temp > $@
    rm temp

.PHONY: clean allclean
clean:
    rm -f $(objects)
allclean: clean
    rm threefactorial  

When I build the program with this makefile, we can see that it compiles the C source file and assembles the assembly source file, and then links the two object files:

$ make
gcc -Wall -O0 -masm=intel -g   -c -o threeFactorial.o threeFactorial.c
as   -o factorial.o factorial.s
gcc   threeFactorial.o factorial.o   -o threeFactorial
$ 

Searching Other Directories

So far, I’ve only discussed the case where all the files used in our program are in the same directory with the makefile. Of course, you could use files in other directories by creating symbolic links to them. But make includes a special variable, VPATH, that we can use to specify other directories to search.

You are asked in the book to write I/O functions that are used in Your Turn exercises in subsequent chapters. I place the source code files for these functions in a directory named common and use VPATH to access them from my working directory. Here’s an example for a program that is written entirely in assembly language:

# Build rulerAdd program

objects = rulerAdd.o getLength.o displayLength.o getUInt.o \
decToUInt.o putUInt.o intToUDec.o writeStr.o readLn.o
myio = ../common
VPATH = $(myio)
ASFLAGS = --gstabs
AS = as
CC = gcc

rulerAdd: $(objects)

.PHONY: clean
clean:
    rm $(objects)
allclean: clean
    rm rulerAdd 

I’ve defined my own variable, myio, to be the relative path to my common directory. Then I just set VPATH to equal this path.

The VPATH variable applies only to make searches. We still need to tell the compiler where the files are located if they’re not in the current directory. Here’s the make file I use to build the convertDec program in Chapter 16.

# Build convertDec program

objects = convertDec.o decToUInt.o writeStr.o readLn.o
myio = ../../../common
VPATH = $(myio)
CFLAGS = -O0 -Wall -masm=intel -g -I$(myio)
CC = gcc
AS = as
ASMFLAGS = --gstabs
genasm = -O0 -Wall -masm=intel -fno-asynchronous-unwind-tables \
-fcf-protection=none -I$(myio)

convertDec: $(objects)
convertDec.o: decToUInt.h writeStr.h readLn.h
decToUInt.o: decToUInt.h

convertDec.s: convertDec.c decToUInt.h writeStr.h readLn.h
    gcc -S $(genasm) -o temp $<
    expand -t 8 temp > $@
    rm temp

decToUInt.s:  decToUInt.c decToUInt.h
    gcc -S $(genasm) -o temp $<
    expand -t 8 temp > $@
    rm temp

.PHONY: clean allclean
clean:
    rm -f $(objects)
allclean: clean
    rm convertDec

Notice that I use the -I option to tell the compiler where to find the header files required by the compilation of the C files.

This brief discussion should help you to get started with using make. After you learn the program’s basic usage, if you want to become an expert, I recommend John Graham-Cumming’s book, The GNU Make Book (No Starch Press, 2015).