Automation with Make
Typing all of these commands out every time we want to build the project is
tiring and error-prone. It’s nice to be able to have a single command that
builds our entire project. To do this, we’ll use make
. Make is a classic
bit of software that’s used for this purpose. At its core, make
is fairly
simple:
- You create a file called
Makefile
. - In this file, you define rules. Rules are composed of three things: targets, prerequisites, and commands.
- Targets describe what you are trying to build.
- Targets can depend on other targets being built before they can be built. These are called ‘prerequisites’.
- Commands describe what it takes to actually build the target.
Let’s start off with a very straightforward rule. Specifically, the first step
that we did was to build the Multiboot header by running nasm
. Let’s build a
Makefile
that does this. Open a file called Makefile
and put this in it:
multiboot_header.o: multiboot_header.asm
nasm -f elf64 multiboot_header.asm
It’s very important that that nasm
line uses a tab to indent. It can’t be
spaces. It has to be a tab. Yay legacy software!
Let’s try to run it before we talk about the details:
$ make
nasm -f elf64 multiboot_header.asm
$
If you see this output, success! Let’s talk about this syntax:
target: prerequisites
command
The bit before the colon is called a ‘target’. That’s the thing we’re trying to
build. In this case, we want to create the multiboot_header.o
file, so we name
our target after that.
After the colon comes the ‘prerequisites’. This is a list of other targets that must
be built for this target to be built. In this case, building multiboot_header.o
requires that we have a multiboot_header.asm
. We have no rule describing how
to build this file but it existing is enough to satisfy the dependency.
Finally, on the next line, and indented by a tab, we have a ‘command’. This is the shell command that you need to build the target.
Building boot.o
is similar:
multiboot_header.o: multiboot_header.asm
nasm -f elf64 multiboot_header.asm
boot.o: boot.asm
nasm -f elf64 boot.asm
Let’s try to build it:
$ make
make: ‘multiboot_header.o’ is up to date.
$
Wait a minute, what? There’s two things going on here. The first is that make
will build
the first target that you list by default. So a simple make
will not build boot.o
. To
build it, we can pass make
the target name:
$ make boot.o
nasm -f elf64 boot.asm
Okay, so that worked. But what about this ‘is up to date’ bit?
By default, make
will keep track of the last time you built a particular
target, and check the prerequisites’ last-modified-time against that time. If
the prerequisites haven’t been updated since the target was last built, then it
won’t re-execute the build command. This is a really powerful feature,
especially as we grow. You don’t want to force the entire project to re-build
just because you edited one file; it’s nicer to only re-build the bits that
interact with it directly. A lot of the skill of make
is defining the right
targets to make this work out nicely.
It would be nice if we could build both things with one command, but as it
turns out, our next target, kernel.bin
, relies on both of these .o
files,
so let’s write it first:
multiboot_header.o: multiboot_header.asm
nasm -f elf64 multiboot_header.asm
boot.o: boot.asm
nasm -f elf64 boot.asm
kernel.bin: multiboot_header.o boot.o linker.ld
ld -n -o kernel.bin -T linker.ld multiboot_header.o boot.o
Let’s try building it:
$ make kernel.bin
ld -n -o kernel.bin -T linker.ld multiboot_header.o boot.o
Great! The kernel.bin
target depends on multiboot_header.o
, boot.o
, and linker.ld
. The
first two are the previous targets we defined, and linker.ld
is a file on its own.
Let’s make make
build the whole thing by default:
default: kernel.bin
multiboot_header.o: multiboot_header.asm
nasm -f elf64 multiboot_header.asm
boot.o: boot.asm
nasm -f elf64 boot.asm
kernel.bin: multiboot_header.o boot.o linker.ld
ld -n -o kernel.bin -T linker.ld multiboot_header.o boot.o
We can name targets whatever we want. In this case, default
is a good
convention for the first rule, as it’s the default target. It relies on
the kernel.bin
target, which means that we’ll build it, and as we previously
discussed, kernel.bin
will build our two .o
s.
Let’s try it out:
$ make
make: Nothing to be done for ‘default’.
We haven’t edited our files, so everything is built. Let’s modify one. Open up
multiboot_header.asm
in your editor, save it, and then run make
:
$ make
nasm -f elf64 multiboot_header.asm
ld -n -o kernel.bin -T linker.ld multiboot_header.o boot.o
It re-built multiboot_header.o
, and then kernel.bin
. But it didn’t rebuild
boot.o
, as we didn’t modify it at all.
Let’s add a new rule to build our iso. Rather than show the entire Makefile
, I’m
going to start showing you what’s changed. First, we have to update our default
target, and then we have to write the new one:
default: os.iso
os.iso: kernel.bin grub.cfg
mkdir -p isofiles/boot/grub
cp grub.cfg isofiles/boot/grub
cp kernel.bin isofiles/boot/
grub-mkrescue -o os.iso isofiles
This is our first multi-command rule. make
will execute all of the commands
that you list. In this case, to build the ISO, we need to create our isofiles
directory, and then copy grub.cfg
and kernel.bin
into the right place
inside of it. Finally, grub-mkrescue
builds the ISO from that directory.
This rule assumes that grub.cfg
is at our top-level directory, but it’s
currently in isofiles/boot/grub
already. So let’s copy it out:
$ cp isofiles/boot/grub/grub.cfg .
And now we can build:
$ make
mkdir -p isofiles/boot/grub
cp grub.cfg isofiles/boot/grub
cp kernel.bin isofiles/boot/
grub-mkrescue -o os.iso isofiles
Sometimes, it’s nice to add targets which describe a semantic. In this case, building
the os.iso
target is the same as building the project. So let’s say so:
default: build
build: os.iso
The default action is to build the project, and to build the project, we need to build
os.iso
. But what about running it? Let’s add a rule for that:
default: run
run: os.iso
qemu-system-x86_64 -cdrom os.iso
You can choose the default here: do you want the default to be build, or run? Here’s what each looks like:
$ make # build is the default
$ make run
or
$ make # run is the default
$ make build
I prefer to make run
the default.
Finally, there’s another useful common rule: clean
. The clean
rule should remove all
of the generated files, and allow us to do a full re-build. As such it’s a bunch of rm
statements:
clean:
rm -f multiboot_header.o
rm -f boot.o
rm -f kernel.bin
rm -rf isofiles
rm -f os.iso
Now there's just one more wrinkle. We have four targets that aren't really files
on disk, they are just actions: default
, build
, run
and clean
. Remember
we said earlier that make
decides whether or not to execute a command by
comparing the last time a target was built with the last-modified-time of its
prerequisites? Well, it determines the last time a target was built by looking
at the last-modified-time of the target file. If the target file doesn't exist,
then it's definitely out-of-date so the command will be run.
But what if we accidentally create a file called clean
? It doesn't have any
prerequisites so it will always be up-to-date and the commands will never be
run! We need a way to tell make
that this is a special target, it isn't really
a file on disk, it's an action that should always be executed. We can do this
with a magic built-in target called .PHONY
:
.PHONY: default build run clean
Here’s our final Makefile
:
default: run
.PHONY: default build run clean
multiboot_header.o: multiboot_header.asm
nasm -f elf64 multiboot_header.asm
boot.o: boot.asm
nasm -f elf64 boot.asm
kernel.bin: multiboot_header.o boot.o linker.ld
ld -n -o kernel.bin -T linker.ld multiboot_header.o boot.o
os.iso: kernel.bin grub.cfg
mkdir -p isofiles/boot/grub
cp grub.cfg isofiles/boot/grub
cp kernel.bin isofiles/boot/
grub-mkrescue -o os.iso isofiles
build: os.iso
run: os.iso
qemu-system-x86_64 -cdrom os.iso
clean:
rm -f multiboot_header.o
rm -f boot.o
rm -f kernel.bin
rm -rf isofiles
rm -f os.iso
You'll notice that there is a fair amount of repetition here. At first, that's pretty okay: make can be a bit hard to understand, and while it has features that let you de-duplicate things, they can also get unreadable really fast.
Creating a build subdirectory
Here's one example of a tweak we can do: nasm
supports a -o
flag, which
controls the name of the output file. We can use this to build everything in
a build
subdirectory. This is nice for a number of reasons, but one of the
simplest is that all of our generated files will go in a single directory,
which means that it’s much easier to keep track of them: they’ll all be in one
place.
Let’s make some changes: More specifically, three of them:
build/multiboot_header.o: multiboot_header.asm
mkdir -p build
nasm -f elf64 multiboot_header.asm -o build/multiboot_header.o
The first one is the name of the rule. We have to add a build/
in front of
the filename. This is because we’re going to be putting this file in that
directory now.
Second, we added another line: mkdir
. We used -p
to make directories
before, but in this case, the purpose of the flag is to not throw an error
if the directory already exists. We need to try to make this directory
when we build so that we can put our .o
file in it!
Finally, we add the -o
flag to nasm
. This will create our output file in
that build
directory, rather than in the current one.
With that, we’re ready to modify boot.o
as well:
build/boot.o: boot.asm
mkdir -p build
nasm -f elf64 boot.asm -o build/boot.o
These changes are the same, just with boot
instead of multiboot_header
.
Next up: kernel.bin
:
build/kernel.bin: build/multiboot_header.o build/boot.o linker.ld
ld -n -o build/kernel.bin -T linker.ld build/multiboot_header.o build/boot.o
We add build
in no fewer than six places. Whew! At least it’s
straightforward.
build/os.iso: build/kernel.bin grub.cfg
mkdir -p build/isofiles/boot/grub
cp grub.cfg build/isofiles/boot/grub
cp build/kernel.bin build/isofiles/boot/
grub-mkrescue -o build/os.iso build/isofiles
Seeing a pattern yet? More prefixing.
run: build/os.iso
qemu-system-x86_64 -cdrom build/os.iso
... and here as well.
clean:
rm -rf build
Now some payoff! To get rid of our generated files, all we have to do is rm
our build
directory. Much easier.
Here’s our final version:
default: run
.PHONY: default build run clean
build/multiboot_header.o: multiboot_header.asm
mkdir -p build
nasm -f elf64 multiboot_header.asm -o build/multiboot_header.o
build/boot.o: boot.asm
mkdir -p build
nasm -f elf64 boot.asm -o build/boot.o
build/kernel.bin: build/multiboot_header.o build/boot.o linker.ld
ld -n -o build/kernel.bin -T linker.ld build/multiboot_header.o build/boot.o
build/os.iso: build/kernel.bin grub.cfg
mkdir -p build/isofiles/boot/grub
cp grub.cfg build/isofiles/boot/grub
cp build/kernel.bin build/isofiles/boot/
grub-mkrescue -o build/os.iso build/isofiles
run: build/os.iso
qemu-system-x86_64 -cdrom build/os.iso
build: build/os.iso
clean:
rm -rf build
We can go further, and eventually, we will. But this is good enough for now. Like I said, there’s a fine balance between keeping it DRY and making it non-understandable.
Luckily, we’ll only be using Make for these assembly files. Rust has its own build tool, Cargo, that we’ll integrate with Make. It’s a lot easier to use.