How to Build Software With Make

Published on 2023-03-21 20:00:00+02:00

To compile an executable from a single C source file we need just one compiler invocation in our shell:

$ cc myprogram.c -o myprogram
$ ./myprogram
Hello, World!

As we expand our projects they naturally become harder to build. If not due to dependencies then due to pure repetitiveness of the task. Build systems are a response to that and GNU Make is one of them. GNU Make is part of a larger family of programs that usually share the make(1) (or similar, e.g., nmake, mk). Although timeline doesn't necessarily agree, we can say that make is defined in POSIX standard.

Principles of Make are straight-forward: as a user we define a set of rules that consist of targets, dependencies and commands. Targets are usually filenames that describe what rule is expected to produce. Dependencies are names of other targets or source files. That's it. Later this concept is expanded upon to reduce the amount of lines we need to write.

When we build native software we are mainly interested in:

Since make live close to the compiler we will be also interested in object files (e.g., *.o, *.obj).

How to Build Executables With Make

Make helps us to reduce number of characters we need to type in terminal and lines we need to put to setup the build. In fact, the simplest case of one source file to one executable, like the one I showed above, does not need any preparation at all:

$ make myprogram
cc myprogram.c -o myprogram
$ ./myprogram
Hello, World!

First thing that we should observe is that make nonchalantly prints the commands it executes to build stuff for us. I'll keep them visible in samples so it's hopefully more clear what's happening.

We invoke make directly with a name of the target we intend to build. Remember: targets are usually filenames that are expected to be produced by a rule. Based on the target name and built-in rules make guesses what to do to get myprogram. It finds myprogram.c in the working directory and selects an appropriate rule.

Yet, we still need to type the target name each time we want to build it. We can avoid it by creating a file called Makefile in the project directory. This is the file containing all build rules and other helpful definitions. If make is ran without any targets then the first target found in Makefile is used. Consider Makefile:

myprogram:

Let's try it:

$ make
cc myprogram.c -o myprogram

Selects the first and the only target as intended.

broom

There are traditional targets that users of our makefiles may expect. A less bare-bone makefile would look like this:

all: myprogram
clean:
	rm -f myprogram
.PHONY: all clean

Now, someone can run following to ensure that a clean build is made:

$ make clean all
rm -f myprogram
cc myprogram.c -o myprogram

Let's note that we can specify multiple targets in the command line. They will be build in order.

Next, all: myprogram - this notation specifies that target all depends on myprogram. We can specify multiple dependencies if needed. All dependencies will be built and once they are done the dependant target will be processed.

.PHONY is a special target that is used to mark targets that are not expected to produce files. We are not expecting all or clean files to be present at any stage. Instead we can imagine these as tasks or actions. Other such targets would be for example install, uninstall or dist.

How to Build Object Files With Make

Usually, we won't implement our projects inside a single source file. We want to split logical parts of the software into several files. Consider greeting.h:

#pragma once
void greet(void);

With accompanying greeting.c:

#include <stdio.h>
#include "greeting.h"
void greet(void)
{
	printf("Hello, World!");
}

And our program that uses them, let's stay with myprogram.c:

#include "greeting.h"
int main()
{
	greet();
}

As simple as it gets while it still illustrates the problem.

files

When we build our program from a single source the compiler does two things: compiles and links. Now we want to do these separately. An intermediate result of compilation is an object file. On Linux and Unix platforms they usually use *.o extension. We can instruct make to use them by adding new rules to our existing makefile:

myprogram: myprogram.o greeting.o
myprogram.o: greeting.h
greeting.o: greeting.h

First we declare that in order to build myprogram two object files are needed. Then we define some additional relationships between the object files and the only header file that we have.

Note that we did not specify explicitly dependency between the object files and the respective source files. Technically we could also skip the relationship between myprogram and myprogram.o and make would still figure it out. This is a matter of what we want to declare explicitly, how lazy we are at the time of writing and how permissive are the built-in rules that we use.

Let's try it out:

$ make
cc -c -o myprogram.o myprogram.c
cc -c -o greeting.o greeting.c
cc myprogram.o greeting.o -o myprogram
$ ./myprogram
Hello, World!

How to Build Static Libraries With Make

The best part here is that we're pretty much already set. Static libraries are archives containing object files. They are created and updates using ar(1). There are built-in rules available for them, too, but with one additional gimmick. They use archive targets that look like this:

myprogram: myprogram.o libgreeting.a
libgreeting.a: libgreeting.a(greeting.o)

We replaced greeting.o prerequisite with libgreeting.a which is defined line below. Dependencies of the library target are expressed as archive members.

How to Build Shared Libraries With Make

Shared/dynamic libraries are different. The process of building them with make is similar to executables, but we want to modify compilation and linking options, and write our own command:

CFLAGS=-fPIC
LDFLAGS=-shared
libgreeting.so: greeting.o
	$(LD) -shared $(LDLIBS) $(LDFLAGS) $^ -o $@

First two lines are variable definitions (called "macros" in POSIX). We set CFLAGS - C compiler flags - to -fPIC to enable position-independent code, which is needed when compiling code for shared libraries that are expected to work nicely on Linux. Then, we set LDFLAGS - linker flags - to -shared which (put simply) tells the linker to build a shared object.

Now let's analyse linker command itself. Starting with $(LD) which is substituted with linker executable. We already know $(LDFLAGS), so $(LDLIBS) sounds like "linker libraries" and that's correct. Finally, we have two automatic variables also known as internal macros: $^ (substituted by all dependencies) and $@ (substituted by target name). Note that $^ is not part of POSIX.

When we finally run make we will see how it performs all predicted substitutions:

$ make
cc -fPIC -c -o greeting.o greeting.c
ld -shared greeting.o -o libgreeting.so

Value for $(LD) is provided by default by make.

In some cases, we may want to invoke the linker via the compiler to have all defaults implicitly defined. For example for C++ we would replace $(LD) with $(CXX) to imply e.g., -lstdc++.

How to Build Against External Libraries With Make

This one starts to be tricky. The simple case where we use libraries installed in /usr or /usr/local is exactly that - simple. All we need to do is put linker options in a variable (macro) that we have already seen: $(LDLIBS):

LDLIBS=-llua -lm

Since managing these manually easily becomes a problem there are tools that can generate list of these options for us. One of them is pkgconfig(1). In most make implementations we can use backticks to run command and use its output:

CFLAGS=`pkg-config --cflags lua`
LDLIBS=`pkg-config --libs lua`

This will link Lua and make its headers available.

What's next?

This is only a beginning. I hope it's a good overview, but the true journey awaits. This is but a dump of information and playing around with make sounds like a reasonable next step. Because of its simple design make is not limited to C, C++, yacc/bison or Fortran. I use it for most small weekend projects I do, be it RST to HTML generation or image rendering. Anything that can be described as processing from one file to another (or generation from thin air) is good enough to be put into a makefile.

Next step after playing around some is diving deeper into automatic variables and pattern rules. I'd recommend to skip over inference rules/suffixes.