Vocabulary
Build system: Automates program configuration, compilation, testing, and flashing. Handles dependencies and build order automatically.
Make: One of the most widespread build systems available. Very powerful and very fast, but can be very confusing to learn and use. You can include other makefiles similarly to how it's used in C.
Makefile: Special file that defines a set of rules. This is processed by Make to determine what needs to be done to reach a specified goal, such as dependency generation or source compilation.
Non-recursive make: A build system that runs with a single instance of make instead of recursively creating new make instances per directory. Results in improved dependency detection, less boilerplate, and less overhead.
Overview
Our build system is built on non-recursive make. The basic structure of our build system looks like this:
Folder/File | Purpose |
---|---|
build | Contains build output |
libraries | Each folder within the libraries folder represents a library that can be used as a dependency. Each library must have a |
platforms | Each folder within the platforms folder represents a platform that projects can be compiled against. Each platform must have a |
projects | Each folder within the projects folder represents a project. Each project must have a |
make | Contains shared makefiles. These should be included in the root makefile with The generic build makefile generates the rules for libraries and projects to create static libraries and applications respectively. The build test makefile generates the rules for generating test runners for all unit tests in the specified library or project. |
Makefile | The main makefile. Its main purpose is to include the correct makefiles and validate input. |
The basic idea behind this system is that we have 2 generic makefiles: one that generates the rules required for creating static libraries and applications, and one for generating unit tests. When the main makefile is run, it repeatedly includes the generic makefiles, redefining the appropriate variables for all libraries and projects that it finds.
When either makefile is included, we define $(TARGET)
to the project or library's name and $(TARGET_TYPE)
to either PROJ
or LIB
. This is primarily so their build outputs go in the right place. We then use a complex variable substitution system to find all the source and header files associated with that target using its rules.mk
, creating the appropriate rules for compilation and linking. We also define a phony target that will only be looked at once to ensure that we keep track of which libraries we actually end up using.
When an application is built, make generates a dependency tree. It looks at what's required to build that application and sees many object files and a number of static libraries. Then it looks at what's required for those libraries, and so on. It works its way through the tree until it has found a target that relies on a file that can be found or something that it knows is up to date, and begins running through the set of rules required to compile and link all the object files and static libraries it found until it's finally able to build the application. Because we're using non-recursive make, this dependency tree knows the state of every dependency that is required to build the application and can quickly determine the steps that are required, reducing overhead and decreasing compilation time.
An interesting side effect of the way make works and how we've decided to implement our build system affects how we define our generic rules. Make does not run linearly, instead expanding variables as they are processed. This means that variables outside of a rule are processed as you'd expect, but variables within rules aren't processed until the rule itself is called. Since we redefine $(TARGET)
repeatedly, we cannot use its value within a rule as it will just hold the last project or library that was included, not the proper target that we expect. To handle this, we've been adding the target name as the first order-only dependency in rules that require a target-specific variable and using firstword
to get the first order-only dependency within the rule. This is not necessarily the best way to handle this situation, but it is currently the way we're doing it.