This guide aims to teach you how to use the CMake build system, with a special focus on Fortran (there are many fine C++ tutorials already around, but not many on Fortran). Instead of trying to cover all of the necessary information to become a CMake expert, it instead aims to get you started with a working build system in as little time as possible. If you want to know more, there’s a list of recommended reading at the end of the document. Finally, this guide uses the command-line throughout because it’s the most portable interface across platforms.

Table of Contents

TL;DR - a simple template

If you’ve already read this guide and just want to copy-and-paste the template, here it is.

Top level CMakeLists.txt file (project_root/CMakeLists.txt):

cmake_minimum_required(VERSION 3.1)

project(my_project VERSION 0.1
        DESCRIPTION "My Fortran program"
        LANGUAGES Fortran)
enable_language(Fortran)

# Currently setting the Fortran compiler to use -std=gnu, change this if you
# want a specific standard
set(FVERSION "-std=f95")
set(CMAKE_Fortran_FLAGS "${CMAKE_Fortran_FLAGS} ${FVERSION}")

# Source code
add_subdirectory(src)

install(TARGETS my_exe     DESTINATION "bin")

Source directory (project_root/src/CMakeLists.txt):

set(MY_MODS   my_module.f90
              another_module.f90
              CACHE INTERNAL "")
add_library(mylib "${MY_MODS}")
add_executable(my_exe main.f90)
target_link_libraries(my_exe PRIVATE mylib)

Configure the build by setting the CMake variables CMAKE_Fortran_COMPILER and CMAKE_Fortran_FLAGS, e.g.:

cmake -B build -DCMAKE_Fortran_COMPILER=ifort -DCMAKE_Fortran_FLAGS="-O0 -g"

Compile commands

Build and install to project_root/install:

cmake -B build -DCMAKE_INSTALL_PREFIX=./install
cmake --build build
cmake --install build

Build using all CPU cores in parallel with the -j flag:

cmake --build build -j $(nproc)

Installing CMake

macOS

The easiest way to install the latest version of CMake is with the Homebrew package manager by doing:

brew install cmake

Linux

The best way to install CMake on Linux is through your distro’s package manager, e.g.

apt install cmake

on Ubuntu/Debian and derivatives, or

dnf install cmake

on RHEL or Fedora.

Some Linux distributions (e.g. Debian or RHEL) package old and out-of-date versions of CMake. If you need a newer version than the one in your distro’s repository, then you’ll have to install from source or download a pre-built binary from https://cmake.org/download/. This is also useful if your HPC cluster only supplies old versions of CMake.

Windows

CMake is natively supported by Visual Studio: https://docs.microsoft.com/en-us/cpp/build/cmake-projects-in-visual-studio?view=msvc-170. If you can’t or don’t want to use Visual Studio, then the easiest way is to download the pre-built binaries from https://cmake.org/download/.

The basics of CMake

CMake is a cross-platform program to automate the software build process. It’s a higher level tool than a build system like “Make”: you specify the source structure of your code and compiler parameters needed to build it, then CMake generates a set of Makefiles customised to build the code on the target system.

CMake has a few benefits compared to writing Makefiles by hand:

  1. CMake is cross-platform (that’s what the “C” in “CMake” means): you can write one CMake specification which will work on Windows, Mac and Linux.
  2. CMake has convenient functions to automatically determine include and link paths for many common libraries such as GSL or LAPACK. For most systems, you don’t need to fuss around with hard-coding library paths, and the exceptions are still pretty simple to deal with.
  3. CMake has native support for a wide range of languages and compilers, so you can specify high-level desired properties and let CMake figure out the right compiler flags.
  4. CMake is extremely widely used in open-source software, making it easier for people to use and contribute to your code.

You specify build properties in files called CMakeLists.txt, which are sort of like CMake’s equivalent of Makefiles. These files can be included throughout your project’s source tree as needed to give you fine-grained control over how different parts of your code should be compiled and linked (similar in concept to the old-school technique of “recursive makefiles”).

Getting started with CMake

To start using CMake, make a file in your project’s root directory called CMakeLists.txt (the capitalisation is significant). This file will contain the so-called “top-level” configuration options for your project - things like the name and version of your project, which directories to include in the build and compiler options which should be used for all components of your project.

CMakeLists.txt is not a Makefile and should not be confused with one. It simply sets up the parameters of the build. The commands in the file are executed sequentially by CMake, similar to any other programming language, but unlike make it does not actually build any software as it reads it. Keep this distinction in mind throughout the rest of this guide.

Configuring the project

The top-level CMakeLists.txt file should always start with some variation on the following lines (at least until you’re familiar enough with CMake to know when to leave them out):

cmake_minimum_required(VERSION 3.1)

project(my_project VERSION 0.1
        DESCRIPTION "My Fortran program"
        LANGUAGES Fortran)

The first command specifies that the build recipe requires a version of CMake at least as recent as 3.10 - it will immediately terminate the build if run with an older version of CMake. This is important because CMake has changed a lot over the years and some of the examples in this guide will only work for newer versions.

The second command defines the project to be built: its name, version, a short description and what programming language(s) it uses. This is important information for configuring the build and applies to everything in the project directory. Everything other than the project name (my_project, in this case) is optional, but for Fortran programs you must have LANGUAGES Fortran to get any of CMake’s special Fortran-aware functionality (if you leave it out, CMake will assume the default language C++). The project name must always be specified - CMake will issue a warning and make one for you if it’s not specified.

CMake supports mixing source files in multiple languages and is able to automatically handle the compiling and linking steps for (most) mixed-language projects. For example, if we had a mix of C and Fortran, we would modify the project() command like so:

project(my_project VERSION 0.1
        DESCRIPTION "My Fortran program"
        LANGUAGES Fortran C)

And CMake will automatically detect and configure C-specific build options.

CMake can also handle compiling binaries which contain both C and Fortran code. You’ll still need to define an interface using compatible data types (as outlined in e.g. this guide), but CMake will automatically generate the correct compiler and linker commands for hybrid Fortran/C code.

Configuring the Fortran compiler

Fortran builds require a little bit of extra configuration on top of just specifying the language.

Fortran compilers have a wide variety of supported options and flags, so it’s usually necessary to set different compiler options for gfortran than for the Intel or Cray Fortran compilers. The following lines of code create a variable (internal to CMake) to store the name of the Fortran compiler, then sets up a conditional if-then-else based on the compiler:

get_filename_component (Fortran_COMPILER_NAME ${CMAKE_Fortran_COMPILER} NAME)
if (Fortran_COMPILER_NAME MATCHES "gfortran.*")
  set (CMAKE_Fortran_FLAGS "<gfortran flags go here>")
elseif (Fortran_COMPILER_NAME MATCHES "ifort.*")
  set (CMAKE_Fortran_FLAGS "<Intel flags go here>")
endif()

CMake will attempt to automatically determine the system’s default Fortran compiler, but this can be overridden by specifying the CMAKE_Fortran_COMPILER CMake variable when configuring the build. For example, to tell CMake to use ifort to build your project, you can do:

cmake -B build -DCMAKE_Fortran_COMPILER=ifort

Compiling an executable

The CMake command to compile an executable (binary) file is add_executable(), which takes the following form:

add_executable(exe_name file1.f90 file2.f90 ...)

You can add as many files as you want to the add_executable statement, which will add a statement to the Makefile to compile them together into an executable with the specified name (exe_name in this case). The executable’s name also serves as a label used internally by CMake during the build-process, and is referred to as a target. Many CMake commands act on an exe target and modify various properties of the build. There can also be multiple executables per project, each of which requires its own call to add_executable() and its own unique name.

Linking to an external library

What if you want to use an external library with your code, such as LAPACK or GSL? Fortunately, CMake has a set of feature which automate a lot of the fiddly work of linking with external libraries. The command target_link_libraries(exe_name <args>) tells CMake to create a linker command based on the supplied arguments. According to the CMake documentation, the arguments can be one of the following:

  • A full path to a library file (e.g. /usr/lib64/libgsl.so)
  • A plain library name, which will be resolved according to the conventions of the current system. For example, the call target_link_libraries(my_exe blas) will generate the linker command -lblas. CMake can also automatically find and resolve the link path for a number of common libraries via the find_package() command, which searches through both pre-configured paths and paths set by environment variables such as modules. See the CMake documentation for more details.
  • A library target name: corresponding to a library you have created in the current project (more about this below).

The executable given to target_link_libraries must have already been defined in the CMake file by the add_executable() command.

Creating your own library

Even though it’s possible to include multiple files in the add_executable, it can quickly get unwieldy for large projects. This is doubly true if you need to generate multiple binaries which share common files (e.g. files of parameters or physical constants). An alternative is to group your auxiliary files into a library and then link that to your binary.

The process for creating and linking a library is very similar to adding an external library, but requires you to compile your library first.

First, you need to add_executable() with the smallest viable number of files for each binary (e.g. a “main” loop), since you need to define the binary first before setting any properties like library dependencies. Then, you can create a new library with the add_library() command, which takes a list of files to compile into the library. Here is an example CMake script:

add_executable(exe1 main1.f90)
add_executable(exe2 main2.f90)

add_library(shared_mods module1.f90 module2.f90)
target_link_library(exe1 shared_mods)
target_link_library(exe2 shared_mods)

Finally, if you have a lot of files to compile into different libraries, you can store the list of files in a CMake variable, which is a little bit cleaner than passing the full list verbatim. For example:

set(QED_MODS  uehling.c
              uehling_ml.c
              electric.c
              magnetic.c
              CACHE INTERNAL "")
add_library(qed "${QED_MODS}")

Note: the set command defines a CMake variable, which in this case is a list of strings (filenames). Variables are referenced by the ${} syntax, similar to some Unix shell scripting. The CACHE part tells CMake to cache and reuse the value between builds in the same directory (unless you specify otherwise), while INTERNAL means that it will not be printed in any user-facing help messages.

Building the code

Now that you’ve got a working CMake configuration (or downloaded a project which has one), it’s time to actually compile the code. As outlined above, there are three steps to this: configuration, compilation and installation.

The basic steps in the build process are as follows:

  1. CMake parses the CMakeLists.txt file(s), figures out how to build your software for the target platform (usually the one you’re running CMake on), then generates a set of Makefiles encoding those build options 1. This is known as the “configuration step”, and corresponds to the command:
    cmake -B <build_dir> <cmake_flags>
    

    The -B flags creates a new “build directory” in the project’s top-level directory which will contain all of the object/module files during the build step. This is called an “out-of-tree” build. You don’t strictly need to do this, but it’s cleaner to keep all of the temporary build files in one place that you can easily delete if you need to. I usually call this folder build.

The <cmake_flags> are variables you pass to CMake to configure the build that you don’t want to hard code into the CMakeLists.txt file and have the form -D<flag>, e.g. -DCMAKE_Fortran_COMPILER=gfortran.

  1. The Makefiles actually compile the code, either through CMake’s wrapper interface via:
    cmake --build <build_dir>
    

    or directly through the make command via:

    cd build
    make
    

    This is known as the “build step”. If your build is taking a long time, you can compile the code using multiple CPU processors by passing the -j flag to cmake--build, along with the number of processors to use:

    cmake --build <build_dir> -j 4
    

    will launch 4 parallel compilation jobs using 4 CPU cores.

  2. CMake then installs the compiled code to a set of “install directories”, along with any libraries or header files the code needs to run. By default, these files are installed in a system-wide default (which may or may not require root privileges), but this can be overridden during the configuration step. This is known as the “install step” and can again be done through CMake’s wrapper interface:
    cmake --install <build_dir>
    

    or through a Unix-style make install command (in the build directory). If you don’t tell it otherwise, CMake will try to install your code in the default system location, usually something like /usr/ on Unix. You may or may not have permission to install to this directory, so it’s usually a good idea to specify a custom location to install the code (called the “install prefix”). This is specified by passing the --prefix flag to the install command. For example, the command:

    cmake --install <build_dir> --prefix=./install
    

    will set the install prefix to a sub-directory called install. The executable files will be installed to ./install/bin, libraries will be installed to ./install/lib and so on. If you install the code in a non-standard location, you may need to add the new location to the PATH variable so your shell knows where to find it. On Unix, this is easiest to accomplish by adding the following line to your .bashrc file:

    export PATH=<install_prefix>/bin:$PATH
    

(This is a bit of an oversimplification, but it’s close enough for our purposes.)

If there are any errors in your CMakeLists.txt files, they will be reported during the configuration stage. Errors in the source code files will be reported during the compilation stage. The installation phase should only fail due to either insufficient permissions of file-system errors (e.g. disk-full), provided the first two steps were successful.

If the build is successful, you should see output like the following:

$ cmake -B build
-- The Fortran compiler identification is GNU 11.2.1
-- The C compiler identification is GNU 11.2.1
-- Detecting Fortran compiler ABI info
-- Detecting Fortran compiler ABI info - done
-- Check for working Fortran compiler: /usr/bin/f95 - skipped
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Setting build type to 'RelWithDebInfo' as none was specified.
-- Found PkgConfig: /usr/bin/pkg-config (found version "1.8.0")
-- Found GSL: /usr/include (found version "2.6")
-- Configuring done
-- Generating done
-- Build files have been written to: /home/emily/Code/rhf/build/

$ cmake --build build
[  1%] Building C object src/qed/CMakeFiles/qed.dir/uehling.c.o
[  2%] Building C object src/qed/CMakeFiles/qed.dir/magnetic.c.o
[  4%] Building C object src/qed/CMakeFiles/qed.dir/uehling_ml.c.o
[  5%] Building C object src/qed/CMakeFiles/qed.dir/electric.c.o
...
[ 97%] Built target rhf_utils
Scanning dependencies of target rhf
[ 98%] Building Fortran object src/CMakeFiles/rhf.dir/rhf.f90.o
[100%] Linking Fortran executable rhf
[100%] Built target rhf

$ cmake --install build --prefix=./install
-- Install configuration: "RelWithDebInfo"
-- Installing: /home/emily/Code/rhf/./install/bin/rhf
-- Installing: /home/emily/Code/rhf/./install/lib64/libtoml-f.a
...

Further reading

This guide only contains the barest minimum needed to get started with CMake and Fortran. If you want to learn more, I recommend the following resources:

  1. Make is the default build-system “backend” CMake uses on Unix, but other choices such as Ninja are possible through the “-G” flag when configuring the build.