Makefiles to improve your life

Boot.dev Blog ยป Stories ยป Makefiles to Improve Your Life
Casper Andersson
Casper Andersson

Last published October 19, 2022

Subscribe to curated backend podcasts, videos and articles. All free.

During development you may sometimes notice you run a lot of commands to set up, build, test, and manage your project. Sometimes, these end up requiring several steps, or you have trouble remembering the exact command. One way to manage this is to set up aliases in your shell configuration. While this would work fine for yourself and for a single build system, it would not be possible to share it in a convenient manner.

One alternative is shell scripts, but a more popular and flexible way is Makefiles. Make is a build automation tool originally made for building C projects, but can be used for any task and programming language. However, every language may not benefit as much as C does from it. Many languages today come with already complete build systems; don’t expect Make to replace them. It would rather be a layer on top for adding custom build steps.

To use Make, start by creating a file called Makefile, usually in the root directory of a project. This file is automatically used when invoking make.

Like many build systems, one of Make’s key features is keeping track of file changes. Make looks at the last modified field of a file to determine if it should “build” it again. “build” in this context could be anything. It doesn’t have to be a compiler. It could be any shell command, even something as simple as copying a file to another directory in preparation for the build step. It could be a pre-build script to generate a file dynamically.

Here’s a list of common Make commands to have and what they typically do.

make ๐Ÿ”—

Without any arguments Make will run the first target specified in the makefile. This is typically the all target, equivalent to make all. It should build the whole program with all its dependencies. The command can be invoked repeatedly to rebuild the program.

make run ๐Ÿ”—

Runs the program. Useful if the program needs some sort of setup before starting the actual program. Maybe starting an emulator. If you happen to be developing an OS it can start the kernel in a virtual machine.

make install ๐Ÿ”—

Installs the program. Is usually invoked with sudo and after doing make to build it first. Instructions in README files often looks like

make
sudo make install

Make can handle separate install instructions for Windows and Mac/Linux when selecting where to install it. install should typically support PREFIX= argument if the user wants to decide where to install it. Specifying PREFIX=/usr will attempt to install it to /usr/bin/.

You can provide an uninstall target as well. But be careful to not delete any third-party dependencies that other packages also depends on. Even if your program installed it doesn’t mean it is the only one using it.

make clean ๐Ÿ”—

Removes any files generated by the build system. It provides a clean template for doing a completely new build. Sometimes issues arise from partial builds and cached data, in which case it is nice with an easy way to start from a clean plate. Clean can be complemented with a make distclean that, on top of doing a make clean, also removes any files created building external dependencies (e.g., a submodule or downloaded files) to ascertain a totally clean build.

make dist ๐Ÿ”—

Create a release tarball. When doing an official release you may want to package just the essential files (tends to not include .git), and sometimes multiple tar, or even exe, files preconfigured for different platforms. This will usually read the version name from a configuration file and use in the filename.

make doc ๐Ÿ”—

Builds documentation. A popular way to handle documentation is through a website. This might generate the HTML from some other format, perhaps from markdown files using Pandoc, or some tool that parses the code and comments.

Example ๐Ÿ”—

We will use the below project structure.

|------------------------
|- Makefile
|- src/
|  |- default_config.json
|- output/
|  |- program.exe
-------------------------

In this scenario we want to copy the default_config.json to our output/ directory when building because program.exe requires it in the same folder during runtime. To do this we will use Make’s feature of keeping track of changes. Whenever the default config is changed we want those changes to be reflected in the output, just as if we changed a line of code. output/ is not checked into Git and exists only as a place to store the output of the build. It could be created by the Makefile as well.

output/default_config.json: src/default_config.json
	cp src/default_config.json output/default_config.json

In Make terminology this is called a target. The target is output/default_config.json, which is the file we want to create. After the colon : comes the dependency src/default_config.json, the original file. Indented by one tab are the commands. Here we only use one command to copy the file. This target says that if the target does not exist then it must be created by executing the commands. The dependency indicates that if it has been modified since last build it should also run the commands.

Invoking make now will run the one and only target available. Do it again and it says there is nothing to be done. Change something in src/default_config.json and try again. Now it has to copy it again.

To improve our Makefile we will make some more additions. Let’s start with a clean command.

clean:
	rm -f output/default_config.json

I’m using -f to have it fail silently if there is nothing to remove. clean depends on nothing and will therefore always run. Unless a file named clean happens to exist. In that case it will never run because the target already exists an no dependency will cause it to be rebuilt. To avoid collisions between commands and filenames we can add .PHONY: clean to the makefile. Now clean is never interpreted as a filename.

Next we add some commands to more easily continue building upon it later.

all: copy_config

copy_config: output/default_config.json

.PHONY: all copy_config clean

With this we can invoke make all to do everything, placing it first will also make it the default when invoking make without arguments. Building program.exe could also be included as a dependency here. make copy_config as short command for only copying the config. Finally, we add all and copy_config to .PHONY.

In the end we are left with this:

.PHONY: all copy_config clean

all: copy_config

copy_config: output/default_config.json

output/default_config.json: src/default_config.json
	cp src/default_config.json output/default_config.json

clean:
	rm -f output/default_config.json

Now you’ve gotten a little peek of the utility of Make. You can do much more with variables, wildcards, parallel build tasks, ordered dependencies, and even writing Make code; it is a Turing complete language (though not everything should be done in it).

Find a problem with this article?

Report an issue on GitHub