How to Build Software With Make
Published on 2023-03-27 22:35: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:
- Executables (e.g., ELF, PE *.exe)
- Static libraries (e.g., *.a, *.lib)
- Dynamic/shared libraries (e.g., *.so, *.dll)
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.
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.
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)
; it's substituted with the linker
executable. We already know $(LDFLAGS)
and $(LDLIBS)
sounds quite similar. This one is
intended to store all external libraries that linker should use. Finally, we have two
automatic variables also known
as internal
macros: $^
(substituted with all dependencies) and $@
(substituted with target name). Note
that $^
is not part of POSIX.
When we finally run make we can observe predicted commands:
$ 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.