Sometimes you just want a single way to build a software project, regardless of what platform or build tool you are using. The promise of CMake is that this should be possible, but in practice, it sometimes doesn’t always seem that way. One particular area where this becomes apparent is scripted builds, especially for things like continuous integration systems, automated testing processes, etc. Since each platform typically has its own commonly used build tool and developers tend to be more familiar with that tool than with CMake, the tendency is to invoke that tool directly in scripts. Unfortunately, this means such scripts end up handling each platform’s build tool separately. But it doesn’t have to be that way. This article will address this and a few other smaller details associated with setting up a platform independent scripted CMake build.
The less generic way
CMake builds typically follow a fairly standard process:
cmaketo produce a set of project files. CMake allows you to choose the type of project files with the
-Goption, which selects a particular project generator.
- Run a generator-specific build tool on those project files to produce executables, libraries, run tests, create packages, etc.
Common generators include Unix Makefiles, Ninja, Xcode and various versions of Visual Studio. For each generator type, there is a corresponding build tool which can be called from a command line or from scripts. Depending on the generator, the type of build (Debug, Release, etc.) may need to be specified either at CMake time or at build time, leading to a workflow something like one of the following for a typical scripted build:
cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release /path/to/source make someTarget
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release /path/to/source ninja someTarget
cmake -G Xcode /path/to/source xcodebuild -target someTarget -configuration Release
cmake -G "Visual Studio 14 2015" C:\path\to\source msbuild /target someTarget /property:Configuration=Release
Some of the above options may be omitted if the defaults are suitable for the developer’s purposes, but for robustness, scripted builds would ideally be explicit about what they are doing.
Making the build more generic
There are a few problems with the above which complicate executing a build in a platform independent way. There are essentially five pieces of information which are platform dependent or need to be handled in a platform dependent manner:
- The generator type (Unix Makefiles, Ninja, etc.)
- The build tool
- The build configuration (Debug, Release, etc.)
- The build target
- The relative path of the source tree relative to the build directory
The path handling is the easiest point to address, since CMake will accept Unix format paths even on Windows. As long as we can express the build directory relative to the source directory, CMake hides the platform specific path separator differences. For illustrative purposes, let us assume the build directory is a sibling of the source directory like so:
BASE_DIR |-- source |-- build
CMake also helps us create the build directory in a platform independent way. It provides a very useful command line option
-E which implements various basic tasks, one of which is creating directories. Using this and assuming we start at
BASE_DIR, we can prepare for the
cmake command as follows:
cmake -E make_directory build cd build
Abstracting away the build tool
CMake also comes to our aid in helping us not have to deal with the platform differences of the build tool. The
cmake --build option directs CMake to invoke the appropriate build tool for us, which allows us to specify the whole build something like this:
cmake -E make_directory build cd build cmake -G GENERATOR -DCMAKE_BUILD_TYPE=Release ../source cmake --build . --target someTarget --config Release
Note how the
cmake --build command abstracts away the build tool and allows us to eliminate three of the five platform differences (build tool, specifying the configuration and the target). It may seem a little odd that the configuration (Release in the above example) is specified twice. This is because some generator types require the build configuration to be specified at CMake time (Makefiles, Ninja), while others require the configuration to be specified at build time (Xcode, Visual Studio). Conveniently, CMake will ignore the configuration if we try to set it at the wrong place, so by specifying it at both, we ensure we get the configuration we want regardless of the generator type.
That leaves just the selection of the generator type. There are a few choices here, depending on the requirements of the project. If the project doesn’t need any generator specific features on any of the platforms, then the simplest solution is to not specify the generator at all. The default generator for the platform will then be used. Alternatively, the Ninja generator is supported on all platforms and it has excellent parallel build capabilities, so if that is an acceptable option, it would be a good choice. In that case, the full build can be specified like this:
cmake -E make_directory build cd build cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ../source cmake --build . --target someTarget --config Release
Note that the above block of code would work unmodified in both Unix shell scripts and Windows batch files. This could be very useful for a continuous integration system which provides the ability to specify the commands to run but perhaps uses different command interpreters on each platform (e.g. DOS command shell on Windows, Unix shell elsewhere). Where the generator is being hard coded to Ninja, it would be just as valid to invoke it directly:
cmake -E make_directory build cd build cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ../source ninja someTarget
That said, it is easier to experiment with different generators if the build tool invocation is handled via
cmake --build, so for scripts, that may still be preferable.
Realistically, many real world projects require generator specific features, such as setting Xcode-specific options like
CMAKE_XCODE_ATTRIBUTE_... to control code signing, etc. In such cases, the generator will have to be supplied and it will vary by platform. Unfortunately, at this point the best solution really depends on how the script is being used. For example, if the script is being run by a CI system, then the CI system itself may have its own way of providing values into a script (e.g. token substitution) or even have its own scripting language. Alternatively, it may be possible to use a scripting language like Python which is supported on all platforms. Another option would be to use Unix shell script on all platforms, with Windows being handled through something like cygwin, mingw or even the Unix shell script that comes with git. Whatever approach you choose, armed with the above, it should at least be possible to minimise the amount of platform specific logic required.
A more detailed discussion on how to specify the build configuration can be found in this excellent StackOverflow answer. The material presented there also shows some undocumented CMake command line options for handling paths which offer an alternative to the arrangement used above.