Jerry Ng
Posted on October 31, 2022
I love Makefile. Today, I use Makefile for most of the projects that I am working on. You may have seen Makefile
in many open-source projects on GitHub (e.g. this). Probably like me, you have wondered what Makefiles are and what they do.
There is a bazillion of incredible Makefile tutorials out there. My goal is to get you interested enough to start using Makefile in under 5 minutes. By the end, you should know enough to start using and self-learn about Makefile.
TL;DR: read Level 1 & Level 2
What Are Make and Makefile
In short, Makefile
is a special format file with rules that tells the GNU Make utility tool (i.e. make
) how to execute commands that run on *nix. Typically, Make is used to compile, build, or install the software.
While Makefile is commonly used to compile C or C++, it is NOT limited to any particular programming language. You can use Make for all sorts of stuff:
- Execute a chain of commands to set up your dev environment
- Automate build
- Run test suites
- Deployments, etc.
Why Use Makefile
Compiling source code can be cumbersome, especially when you have to include multiple source files.
At its core, Makefile
is a utility for writing and executing a series of command-line instructions for things like compiling code, testing code, formatting code, running code, etc.
Basically, it helps to automate your development workflows into simple commands (make build
, make test
, make format
, make run
).
Also:
-
make
is preinstalled at most *nix systems that you can find -
make
is programming language/framework agnostic
Alright enough talking, let’s get to the real deal.
A Quick Guide to Makefile
While going through the levels below, I highly encourage you to create a Makefile
(note: always name it Makefile
) and try things out yourself.
Level 1: “Tell me all I need to know”
At this level, you’ll learn the basics of Makefile; probably all you ever need to know to benefit from it.
Suppose you have a project using that uses Docker. To build and run your app iteratively using a Docker container, you would typically:
- Run
docker build
- Make sure there are no running container
- Run
docker run
- Repeat
To do this yourself:
docker build -t image-name . --build-arg ENV_VAR=foo
docker stop container-name || true && docker rm container-name || true
docker run -d -e ANOTHER_VAR=bar--name container-name image-name
What a hassle! There is a lot to remember and type. On top of that, many chances to make silly mistakes.
Sure, you could just run all 3 commands every time you make changes. That would work. However, it is just so inefficient. Instead, we could write a Makefile
just like the below:
all: build stop run # build -> stop -> run
build:
@docker build -t image-name . --build-arg ENV_VAR=foo
stop:
@docker stop container-name || true && docker rm container-name || true
run:
@docker run -d -e ANOTHER_VAR=bar--name container-name image-name
Now to build and run a new Docker image, all you need is a single make all
command. In the case above, we can just call make
because all
is the first rule (note: the first rule is selected by default).
To summarize, a rule within a Makefile
generally looks like this:
# run `make <target>` to run this rule
<target>: <prerequisite 1> <prerequisite 2> <prerequisite N> # a comment block is prefixed with a "#"
<command 1>
<command 2>
<command N>
Takeaways for Level 1:
-
<target>
— typically file name; could be any name -
<command>
— typically *nix commands/steps used to make the<target>
. These MUST start with a TAB character -
<prerequisite>
— optional. This tellsmake
that each prerequisite must exist before the commands are run. Therefore, prerequisites run in order of 1 to N (in the example above). - What is the
@
syntax for? If a command line starts with@
, the echoing of that command is suppressed (reference) - The first
<target>
is selected by default when running justmake
That’s it. Simple right?
Now go forth and make a Makefile at work!
Level 2: “Cool, but I need a little bit more”
The use of variable substitution is rather common when it comes to all aspects of programming. Makefile is not exempted.
So, how to use environment variables (with default)?
Suppose you want to docker build
your app with different build arguments (e.g. ENV_VAR
), here’s how your Makefile
would look like:
NAME := my-app
DOCKER := $(shell command -v docker 2> /dev/null)
ENV_VAR := $(shell echo $${ENV_VAR-development}) # NOTE: double $ for escaping
ENV_ANOTHER_VAR ?= bar
.PHONY: build
build: ## build docker image based on shell ENV_VAR
@if [ -z $(DOCKER) ]; then echo "docker is missing."; exit 2; fi # tip
@docker build -t $(NAME) . --build-arg ENV_VAR=$(ENV_VAR)
Takeaways for Level 2:
-
.PHONY
— by defaultmake
assumes your<target>
is a file. So if they are not, just mark them with.PHONY
in case you have a filename that is the same as<target>
(reference and read this for more info) - Declare a Makefile variable with
=
or:=
syntax (reference) -
:=
— to execute a statement once -
=
— to execute a statement every time. An example use case is when you need a newdate
value every time you call a function. -
?=
— set the Makefile variable only if it's not set or doesn't have a value (reference) - Finally, you can use an environment variable to check if a command exists too as shown in the
if
statement above -
Be careful about adding in-line comments as below. You may end up adding extra space to your variable:
FOO = /my/path/to # <- a white space!
Bonus:
-
echo $${ENV_VAR-development}
setsENV_VAR
shell variable based on your current shell environment with a default value todevelopment
. - Alternatively,
make
allows you to pass variables and environment variables from the command line, e.g.ENV_VAR=development make build
By now, you probably have everything you need to create a Makefile for small and medium-sized projects.
Now go forth and make awesomeness with Makefile!
Level 3: “Now, show me the fancy stuff”
This is probably my favorite part. Everyone loves a good help
message right? Right…?
Let’s create a useful help
target in case our users need help on how to use Make in our project:
.DEFAULT_GOAL := help
.PHONY: help
help:
@echo "Welcome to $(NAME)!"
@echo "Use 'make <target>' where <target> is one of:"
@echo ""
@echo " all run build -> stop -> run"
@echo " build build docker image based on shell ENV_VAR"
@echo " stop stop docker container"
@echo " run run docker container"
@echo ""
@echo "Go forth and make something great!"
all: build stop run
.PHONY: build
build:
@docker build -t image-name . --build-arg ENV_VAR=foo
.PHONY: stop
stop:
@docker stop container-name || true && docker rm container-name || true
.PHONY: run
run:
@docker run -d -e ANOTHER_VAR=bar--name container-name image-name
-
.DEFAULT_GOAL
— remember how I said the first rule is selected by default when you run justmake
? You can override that with this - With this, you can simply run
make
to display the error message every time
However, every time you add a new target to your Makefile
, you’ll need to add a new line of echo.
Now, how does a self-documenting Makefile help message sound to you?
Modify your help
target as follow (modify accordingly):
.DEFAULT_GOAL := help
.PHONY: help
help: ## display this help message
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
Next, add comments with the ##
tag to print the comments as part of the help
message:
all: build stop run ## run build -> stop -> run
.PHONY: build
build: ## build docker image
@docker build -t image-name . --build-arg ENV_VAR=foo
.PHONY: stop
stop: ## stop running container
@docker stop container-name || true && docker rm container-name || true
.PHONY: run
run: ## run docker container
@docker run -d -e ANOTHER_VAR=bar--name container-name image-name
Bam! Here you have it, a nice self-documenting help
message:
$ make
Usage:
make <target>
Targets:
help display this help
all run build -> stop -> run
build build docker image
stop stop running container
run run docker container
Credits: I would like to thank the author of make help - Well documented Makefiles for this.
Level 4: “Your article does not provide the level of details I need!”
Here are some Makefile references that I’ve curated that I highly recommend you to check out; I owe my thanks to all of them:
- Learn Makefiles With the tastiest examples — a really good general Makefile tutorial; I prefer to rather than going through the 180+ pages long manual
- Tips for your Makefile with Python — if you’re working on a Python project
- Need a decent example? Here’s a Makefile with 1.8k+ Stars on GitHub
- If all else fails — GNU Make manual (you probably won’t need most of it)
Final Thoughts
Even if you’re writing good documentation, chances are Makefile is much more reliable. The beauty of Makefile is that it’s simply a rigorous way of documenting what you’re already doing.
Contributing to an open-source project? Refer to the Makefile
to identify the development workflow.
Current State of Makefile
First appearing in 1976, the use of Makefile is known to have its quirks.
Some of the most common complaints about Make is that it has rather complicated syntax. As a result, you’ll find people who absolutely love or hate Makefile.
I think this kind of utility gets very personal. Everyone has their favorite. I for one am less interested in having to download yet another fancy program/tool/framework/library for many other reasons.
Makefile “just works”.
Posted on October 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.