C-Making a Build System
Jack Frank and Aidan McNay
April 22, 2024
As a software engineer, abstraction is our best friend; limiting the scope of where we think about code is the key to productivity, not even considering the headache you might get when thinking about all the tools that help you in the background. We take this approach with our coding languages, but we often also utilize abstraction when compiling as well. Instead of manually running hundreds or thousands of commands to compile our software, we rely on build systems to keep track of our software and compile it for us. On C2S2, we use a couple build systems - but which one is best?
Makefiles
Makefiles are one of the first build systems to come around and are the backbone of many software programs. These are run using the make command, and their popularity is shown by the fact that this command is mandated by POSIX (a common standard for operating systems, such as Linux and MacOS)
Makefiles define how to build objects with rules; you would have a rule for each program you want to make. Each rule contains:
- A target: The name of what you're trying to make
- Any prerequisites: Things we need in order to make our target
- A recipe: The list of commands that we need to run in order to make our target
For example, the following code is a rule to build the target hello.c from a prerequisite source file named hello.c using a recipe to compile using GCC:
To see this actually in play, let's consider a "real-life" setup of a software project organization! The example below might show how our folders are organized; we have our main code in main.cpp, and then some source code in the src folder, which contains src.hpp and src.cpp (don't worry too much if those file names aren't too familiar - we just want to understand how we might compile them)
If we're compiling these with Makefiles, we might have to have rules for how to compile both the code in the src folder (for C++, this would compile to a src.o), as well as the main code (into a program named prog) using src.o:
The downside here is that our current system doesn't scale well - you'd have to define a whole new rule for every file you add! Instead, Makefiles allow us to define pattern rules to specify a rule for how to build a specific type of file:
Here, we specify that prog depends on all of the object files listed in OBJECT_FILES (in this case, only src.o ). Additionally, we use the % wildcard to make a rule for how to make any .o file. This gives our Makefile a lot more flexibility and avoids repeating rules. We could build our code using the command make prog , which will run the recipe for the prog command (building our program!)
However, we still have to specify all of the source files currently in OBJECT_FILES; while there are ways around this, they're often confusing and hard to understand. We also rely on a very specific organization structure (with all of the source files in the src folder); if we were to include other folder structures, such as when working with other projects, this may not work well.
CMake
On the other hand, we could also choose to build our project using CMake. CMake is a build system that allows developers to build their projects across multiple different environments with only one set of files. Additionally, CMake has many other built-in tools that allow for further functionality such as cross-platform testing.
We can demonstrate this by using the same folder structure as above. We begin by creating a file named CMakeLists.txt in the root of the project. All of our CMake code will be written in this file. To start, we need to write some boilerplate code that will set the CMake version, name the current project, and specify the C++ version:
Next, we need to allow the CMake project to see the src folder. To do this, we can create a new CMakeLists.txt within the src folder, which contains the line add_library(src src.cpp), to tell CMake that we have a library that our software can use named src (including the code in src.cpp) Now, we return to the CMakeLists.txt file in the project root and add the line add_subdirectory(src) which as the name suggests allows the greater CMake project to see the src folder (folders are sometimes called "directories") and include its files (namely the header file src.hpp, as well as our src library code) when compiling.
Finally, we can get to creating the program prog which will be used, as seen above, to actually run the code in main.cpp. To set this up, we have the following code:
The first thing that happens here is the creation of a new executable (another word for "program") called prog which is built from the main program in main.cpp. We then link the src library to this executable, so that it can use the code contained in the src folder.
Now that we have written our CMake code, we can actually build our project! First, we create a new folder called build in the main root of our project (CMake actually does not care where this build folder is, but by convection, we put it here). Now, we move into the build folder and run the following commands:
The first command (cmake ..) goes through our CMake files and generates supporting code to help build our project (under the hood, it creates a super-complicated Makefile to build our code!). Then, make prog will build the program using the generated Makefile, similar to before. Now we're finally done, and can run our program, just the same as with Makefiles!
Tradeoffs
After exploring both CMake and Makefiles, we can come away with some concrete tradeoffs that may help to inform you which build system may be worth investing in for your project!
- Startup Cost: Makefiles are relatively easy to get started with; all you need to specify is the rules you want, nothing else! In contrast, CMake requires some boilerplate code to get started, including CMake files in sub-folders, increasing the barrier between you and your first compilation.
- Scalability: Makefiles may quickly become hard to scale; handling complicated folder structures may prove difficult, as well as keeping track of different projects and parts of compilation. You may also have to edit the Makefile as you add more files, adding one more step in your development workflow. In contrast, CMake build structures have a variety of commands for working with complicated build structures, and can also easily integrate with each other, making it relatively easy to include other projects that also use CMake. While our example also manually specified the files we use for simplicity, CMake also can help easily detect the files present in your folders, meaning you don't have to edit them every time you add new code.
- Syntax: Lastly, an important attribute of code is how understandable it is! The syntax of Makefiles can quickly get confusing and isn't necessarily intuitive for others to pick up and understand at a glance (looking back at the code above, would it have made sense without explanation?). However, CMake makes use of intuitive, human-readable function names and code, making it a lot easier to tell what's going on at a glance.
Because of all of these, Makefiles are best for quick pieces of code; if your project is small, and you just want to avoid running a few commands over and over again, Makefiles can help automate these away. However, if you're starting a large piece of software, it's worthwhile to invest in CMake; while it takes a little more to start up, its scalability will prove worthwhile overall.