To make things easier, we can put the compilation commands into a script. That script is called a Makefile. This post is a quick note on writing a Makefile with a simple example.
Writing a Makefile
Suppose we have four files: main.cpp, hello.cpp, square.cpp, and utils.h. Their contents are as follows:
1 | |
The final Makefile can look like this:
1 | |
Now let’s look at how and why it is written this way.
The simplest approach is to turn the compilation command into a script. For our files, that command is g++ main.cpp hello.cpp square.cpp -o main, which creates an executable named main.
The Makefile would look like this:
1 | |
Line 1 says that main (target) depends on main.cpp, hello.cpp, and square.cpp (dependencies). The command in Line 2 is called the recipe. When we run make, it checks file timestamps. If main is older than any of its dependencies, make runs the recipe.
A side note: The file doesn’t have to be named
Makefile. A different name can be used with runningmake -f <filename>.
The Problem with Simple Solutions
The above simple script is a poor solution. What if the target depends on 100 source files? Whenever one single file changes, everything would have to be recompiled, which takes a long time. What do we usually do on the command line? We compile each source file separately.
First, compile only, no linking.
1 | |
This generates three .o files, one for each source. The files are then linked together:
1 | |
This generates the executable main.
With this method, if only hello.cpp is modified, that single file is recompiled:
1 | |
The linking process is then repeated to generate the new main.
Following this more efficient idea, the Makefile looks like this:
1 | |
The key is in line 5. If any file in OBJ is newer than TARGET, line 6 runs to link them. The rules for how each .o file gets updated are defined in lines 8–15.
Simplifying with Automatic Variables and Pattern Rules
At this point, we have already made the compilation process efficient. But look at lines 8–15 in the previous script. They repeat the same pattern. So the next step is to use automatic variables and pattern rules to make the file shorter. We also add compiler flags and a clean rule.
1 | |
Why is
.PHONYneeded?
It avoids ambiguity. Assume that there is a file namedcleanin the directory.
Without.PHONY, runningmake cleanwould treatcleanas the target.
As for pattern rules, any documentation will explain them better than I can. Here, the focus is only on a few new symbols. @, ^ and <. When $ precedes these symbols, they become automatic variables. A simple way to remember their roles is to think of their position in a rule. The @ corresponds to the left side of the :, representing the target file. The arrows, ^ and <, correspond to the right side. The ^ symbol represents all dependencies, while < refers only to the first dependency.
This script already looks good. One small issue remains: whenever a new source file is added, we must manually update the OBJ variable. To avoid this, replace line 3 with the following:
1 | |
Here SRC lists all .cpp files in the directory. Replacing all .cpp gives the OBJ list.
With this change, we finally arrive at the complete Makefile shown at the beginning.
Using CMake to Generate a Makefile
Makefiles have a major drawback: they’re highly system-dependent. A Makefile for one development environment might not work on another. A common solution is to use a build system generator like CMake to automatically create the Makefile.
To start, create a file named CMakeLists.txt in the project directory. For this example, a minimal working version looks like this:
1 | |
Next, create a separate build directory and enter it (the reason will be clear soon).
1 | |
Running cmake <dir> command tells CMake to look for CMakeLists.txt in the specified directory. It then generates the Makefile and other build files inside the build folder. From here, we can run make to compile the program.
One important detail is clean up. A typical make clean will leave behind a lot of intermediate files created by CMake. To completely clean up the project, the common practice is to just delete the entire build directory by rm -rf build. This is the exact reason all compiled files go into a separate build folder. If we had created them directly in the main project folder, cleaning up would be a messy and tedious process.
Comments(not set up yet 🚧)