Building with Make, CMake and Meson

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Fortran 2023 (ISO/IEC 1539-1:2023, J3/23-007r1) and was checked on 2026-10-19. Every Fortran source on it was compiled warning-free with gfortran -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -fcheck=all (GNU Fortran 16.2) and run, and where the page says so also with LLVM Flang 23 (in the container the LLVM apt package installs it as flang-23; upstream names the executable flang). Every Makefile, CMakeLists.txt and meson.build was run for real in a Debian container with GNU Make 4.4.1, CMake 3.31.6 (the Debian package) and 4.4.4 (the current release, installed from PyPI), Meson 1.7.0 (the Debian package) and 1.12.1 (the current release, from PyPI) and Ninja 1.12.1 and 1.13.2. The output shown is real output, trimmed only where the text says so. Where an older and a newer release of a tool disagree, the page says which one did what.

The Fortran standard says nothing about build tools. It defines source text, program units and the way a use statement makes a module’s public entities available (14.2.2), and it states plainly that a processor may implement this by translating the module first and storing the result so that other program units can refer to it (C.10.2, "Dependent compilation"). Every compiler does exactly that, and the stored result, the .mod file, is what makes building Fortran different from building C. This page explains that difference, then shows the same project, heatflow (eleven library files, a program, tests, OpenMP, MPI and coarray demos and a C client), built with the three build systems most Fortran projects use. The Fortran-specific package manager has its own page, Fortran Package Manager (fpm), compiler options are in Compilers and Compiler Options, and the language side of the module files is in Modules and Submodules.

Modules make the compilation order matter

In C, each source file can be compiled on its own, in any order, as long as the headers are present. In Fortran a file that contains use heatflow_kinds cannot be compiled until the compiler has already compiled the file that defines heatflow_kinds, because the compiler needs the module’s contents (its module file) to check the use. Compiling heatflow_kernels.f90 first fails:

gfortran -std=f2023 -c src/heatflow_kernels.f90
src/heatflow_kernels.f90:3:7:

    3 |   use heatflow_kinds, only: dp, ip
      |       1
Fatal Error: Cannot open module file 'heatflow_kinds.mod' for reading at (1): No such file or directory
compilation terminated.

LLVM Flang reports the same mistake in its own words, and goes on to list every consequence of the missing module (the twelve further errors, one for each use of dp, are left out here):

error: Semantic errors in /work/hf/src/heatflow_kernels.f90
/work/hf/src/heatflow_kernels.f90:3:7: error: Cannot parse module file for module 'heatflow_kinds': Source file 'heatflow_kinds.mod' was not found
    use heatflow_kinds, only: dp, ip
        ^^^^^^^^^^^^^^
/work/hf/src/heatflow_kernels.f90:12:10: error: No explicit type declared for 'dp'
      real(dp), intent(in) :: u(:, :)
           ^^

Compiling heatflow_kinds.f90 first writes heatflow_kinds.mod next to the object file, and the second compilation succeeds. This is the whole problem a Fortran build system has to solve: a compiler run produces two kinds of output (an object file and one module file per module), and some compiler runs consume the module files of others. The order is the topological order of the graph of use statements, and a submodule additionally depends on the module it extends.

flowchart LR kinds[heatflow_kinds] --> constants[heatflow_constants] kinds --> grid[heatflow_grid] kinds --> kernels[heatflow_kernels] kinds --> solver[heatflow_solver] kinds --> config[heatflow_config] kinds --> io[heatflow_io] kinds --> app[app/main.f90] boundary[heatflow_boundary] --> grid boundary --> solver boundary --> config constants --> config grid --> solver grid --> io kernels --> capi[heatflow_capi] kernels --> jacobi[submodule heatflow_jacobi] kernels --> ftcs[submodule heatflow_ftcs] solver --> jacobi solver --> ftcs config --> app solver --> app io --> app grid --> app

An arrow means "the target needs the source’s module file first". heatflow_kinds and heatflow_boundary use no module of the project (only the intrinsic modules, which the compiler supplies), so they can be built at once; everything else waits. A build system that knows this graph can compile independent files in parallel and, after an edit, recompile only what the edit can affect.

Where the module files go

By default a compiler writes module files into the current directory and looks for them there. Every compiler has an option to choose another directory for the files it writes, and the ordinary include-path option for the ones it reads:

Purpose GNU Fortran 16.2 LLVM Flang 23

Directory where the compiler writes .mod and .smod files

-J dir

-module-dir dir

Directories where it searches for module files it reads

-I dir (also the search path for include files)

-I dir

File extension and format

.mod is gzip-compressed text, .smod for submodules

.mod is plain text, .smod for submodules

The output directory has to exist already. Neither compiler creates it:

f951: Warning: Nonexistent include directory 'nodir' [-Wmissing-include-dirs]
/work/hf/src/heatflow_kinds.f90:10:25:

   10 | end module heatflow_kinds
      |                         1
Fatal Error: Cannot open module file 'nodir/heatflow_kinds.mod0' for writing at (1): No such file or directory
compilation terminated.
error: Semantic errors in /work/hf/src/heatflow_kinds.f90
/work/hf/src/heatflow_kinds.f90:1:8: error: Error writing nodir/heatflow_kinds.mod: No such file or directory
  module heatflow_kinds
         ^^^^^^^^^^^^^^

The first block is gfortran -c -J nodir and the second flang -c -module-dir nodir. Flang also accepts -J as an alias (the same error appears with -J), which is why one Makefile can use -J for both. Note that -J dir does not add dir to the search path: a later compilation that reads the modules needs -I dir as well, as the examples below do. A sequence that works with both compilers:

mkdir -p mod
gfortran -c -J mod src/heatflow_kinds.f90
gfortran -c -J mod -I mod src/heatflow_kernels.f90

Module files belong to one compiler

The module file is not a portable format. It is private to the compiler and, for GNU Fortran, to the compiler’s module format version (the first line of an uncompressed GNU module file reads GFORTRAN module version '16' for GCC 16). A file written by one compiler cannot be read by another, and both compilers say so:

/work/hf/src/heatflow_kernels.f90:3:7:

    3 |   use heatflow_kinds, only: dp, ip
      |       1
Fatal Error: File 'fl/heatflow_kinds.mod' opened at (1) is not a GNU Fortran module file
compilation terminated.
error: Semantic errors in /work/hf/src/heatflow_kernels.f90
/work/hf/src/heatflow_kernels.f90:3:7: error: Cannot read module file for module 'heatflow_kinds': 'heatflow_kinds.mod' is not a module file for this compiler
    use heatflow_kinds, only: dp, ip
        ^^^^^^^^^^^^^^

The first is gfortran reading a file written by Flang (directory fl), the second is Flang reading one written by gfortran. The consequences for builds are practical: the module files of a library are valid only for the compiler (and release family) that built it, a build directory must not be shared between compilers, and a system library with Fortran bindings (MPI, HDF5) can be used from Fortran only with a compiler that reads its .mod files. This is why the install layouts in the sections below put the module files in their own directory, and why some packages name that directory after the compiler and its version. (GNU Fortran 16.2 did read older GNU module files in the one case tried: the HDF5 1.14.5 module files of the Debian package begin with GFORTRAN module version '15', and gfortran 16.2 compiled and linked a program that uses them in the HDF5 examples below. No claim is made here about the opposite direction.)

Finding the order

Each tool learns the dependency graph in a different way:

Tool How it learns which file needs which module

GNU Make

Nothing: you write the rules, or generate them. The Makefile below generates them with a short awk script that reads the use and submodule statements.

CMake

Scans the sources itself. With the Ninja generator CMake runs a scanner step per target ("Generating Fortran dyndep file" in the output) and hands Ninja a dynamic dependency file; the scan is done by CMake itself for the Makefile generator too.

Meson

Runs a scanning step ("Scanning modules" in Meson 1.7, "Generating dynamic dependency information" in Meson 1.12) and uses Ninja’s dynamic dependencies.

fpm

Reads the sources itself, no configuration at all (see Fortran Package Manager (fpm)).

The compiler cannot do the scanning for you. gfortran -M prints a dependency rule, but it needs the preprocessor and it needs the module files it names to exist already:

gfortran -cpp -M src/heatflow_kernels.f90
heatflow_kernels.mod heatflow_kernels.o: \
 src/heatflow_kernels.f90 \
 heatflow_kinds.mod

(A third line naming a system include file, math-vector-fortran.h, is left out.) Without -cpp the command stops with f951: Fatal Error: To enable preprocessing, use '-cpp', and without heatflow_kinds.mod already on disk it fails with the Cannot open module file error shown above, so it cannot produce the first rule of a fresh build. The GCC options that produce dependency files for C and C++ modules (-fdeps-format, -fdeps-file, -fdeps-target) are accepted by the driver for Fortran but ignored: gfortran 16.2 prints the warning command-line option '-fdeps-format=p1689r5' is valid for C/C++/ObjC/ObjC++ but not for Fortran. The generated-dependencies approach is therefore always a separate scanner, which is what CMake, Meson and fpm each contain.

Libraries

A library is a collection of object files plus, for Fortran, the module files needed to use it. Both parts must be delivered; the library file alone cannot be used from Fortran source, only from C or from a Fortran call with a hand-written interface.

Static libraries

A static library is an archive of object files. The ar program creates it, and the linker copies from it only the members that satisfy undefined symbols. The heatflow library in a hand-written build, with the files compiled in dependency order and module files collected in mod:

mkdir -p mod obj lib bin
for m in kinds constants boundary grid kernels solver jacobi ftcs config io capi; do
  gfortran -std=f2023 -fPIC -O2 -c -J mod -I mod src/heatflow_$m.f90 -o obj/heatflow_$m.o
done
ar rcs lib/libheatflow.a obj/*.o
ar t lib/libheatflow.a
gfortran -I mod app/main.f90 -L lib -lheatflow -o bin/heatflow_static
heatflow_boundary.o
heatflow_capi.o
heatflow_config.o
heatflow_constants.o
heatflow_ftcs.o
heatflow_grid.o
heatflow_io.o
heatflow_jacobi.o
heatflow_kernels.o
heatflow_kinds.o
heatflow_solver.o

Running bin/heatflow_static from the directory that holds heatflow.nml prints steps=2000 residual= 1.38E-04 and max temperature= 0.139. The module directory contains one .mod file per module and, for the module with submodules, heatflow_solver.smod and one heatflow_solver@heatflow_jacobi.smod-style file for each submodule. Three practical rules follow:

  • The order of the files on the link line matters to the linker, as in C: the program object first, then the libraries that satisfy it. -L lib -lheatflow searches libheatflow.a (or .so) in lib.

  • The archive contains no use information. Compiling app/main.f90 without -I mod fails with the Cannot open module file error above even though the library is on the link line.

  • Only the symbols are in the archive, so the Fortran runtime library (libgfortran, or Flang’s runtime) is not part of it. Link with the Fortran compiler driver, not ld or gcc, or add the runtime explicitly. CMake and Meson have a property for this, shown below.

Shared libraries

A shared library is built from position-independent code (-fPIC, already used above so the same objects serve both) and linked with -shared:

gfortran -shared -o lib/libheatflow.so obj/*.o
gfortran -I mod app/main.f90 -L lib -lheatflow -o bin/heatflow_shared
bin/heatflow_shared
bin/heatflow_shared: error while loading shared libraries: libheatflow.so: cannot open shared object file: No such file or directory

The program links but does not start, because the dynamic loader does not know where libheatflow.so is. ldd shows the same:

	libheatflow.so => not found
	libgfortran.so.5 => /usr/local/lib64/libgfortran.so.5 (0x0000ffff91540000)

There are three remedies. The environment variable LD_LIBRARY_PATH finds the library at run time without touching the program (the output is again steps=2000 residual= 1.38E-04). Better, link the directory in as a run-time search path with -Wl,-rpath,:

gfortran -I mod app/main.f90 -L lib -lheatflow -Wl,-rpath,"$PWD/lib" -o bin/heatflow_rpath
readelf -d bin/heatflow_rpath | grep -E 'RUNPATH|NEEDED'
 0x0000000000000001 (NEEDED)             Shared library: [libheatflow.so]
 0x0000000000000001 (NEEDED)             Shared library: [libgfortran.so.5]
 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000001d (RUNPATH)            Library runpath: [/tmp/o3/lib]

(The path in the last line is where the container built it.) The third remedy is to install the library into a directory the loader already searches. On macOS the loader uses @rpath install names and -Wl,-rpath,@loader_path/...; on Windows a DLL is found next to the program or on PATH. The build systems below take care of the flags on each platform, which is the main reason to use them. Both kinds of library need the same module files, so -I mod is needed in either case.

include files

Fortran has its own source-inclusion mechanism, independent of the preprocessor. An include line (6.4 "Including source text") is replaced by the contents of the named file before the program unit is processed. It is not a statement: it has to be alone on its line, apart from a trailing comment, it cannot carry a label, and the text it brings in must not start with a continuation line or end with a continued statement. How the character constant names a file is processor dependent; compilers treat it as a file name, and the experiment below shows that both gfortran and Flang need -I to find a file that is in another directory.

! Shared constants for heatflow tools; pulled in with the Fortran include statement.
integer, parameter :: max_grid_points = 4096
integer, parameter :: max_name_length = 64
module heatflow_limits
  implicit none
  private
  include 'limits.inc'
  public :: max_grid_points, max_name_length
end module heatflow_limits

program include_demo
  use heatflow_limits, only: max_grid_points, max_name_length
  implicit none
  print '(a,i0)', 'max_grid_points=', max_grid_points
  print '(a,i0)', 'max_name_length=', max_name_length
end program include_demo

Compile it with -I include for the directory that holds limits.inc; without -I both compilers fail, with their own wording:

src/limits_demo.f90:4:0:

    4 |   include 'limits.inc'
Fatal Error: Cannot open included file 'limits.inc'
compilation terminated.
error: Could not scan src/limits_demo.f90
src/limits_demo.f90:4:1: error: INCLUDE: Source file 'limits.inc' was not found
    include 'limits.inc'
  ^
gfortran -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -I include src/limits_demo.f90 -o limits_demo
./limits_demo
max_grid_points=4096
max_name_length=64

The same output comes from Flang (flang -I include src/limits_demo.f90). An include line is the old mechanism for sharing declarations, and the standard’s own rationale explains why modules replace it: the identical text can mean different things in different program units, for example under a different implicit statement (C.10.2.1). Keep include for what really is text (a table of constants generated by another program, a licence header, an MPI mpif.h), not for interfaces. The build systems must treat the included file as a dependency of the including file; CMake’s scanner, Meson’s and fpm’s do, and in a Makefile it is one more prerequisite. It is a different thing from the C preprocessor’s #include, which needs a preprocessed file (.F90 or -cpp, see the CMake section).

Distributing a library

A usable installed Fortran library has four parts, and the three build systems handle them differently:

Part GNU Make CMake Meson

Library file

your install rule

install(TARGETS ...)

library(..., install: true)

C header, if any

your rule

install(FILES ...)

install_headers()

Module files

your rule, copying .mod and .smod

install(DIRECTORY ... FILES_MATCHING ...) over the target’s module directory

not installed by Meson itself: an install script (below)

Description for consumers

a pkg-config file by hand

an exported package, find_package

pkgconfig.generate()

Put the module files in their own sub-directory of the include directory (include/heatflow), not directly in include, so that the consumer adds one -I and a compiler-specific suffix can be added later. test-drive, which heatflow uses for its unit tests, installs into include/test-drive/GNU-16.2.0 for this reason (CMake build, visible in the install log) and include/test-drive/gcc-16.2.0 (Meson build). Because the module file format changes between compilers, a package that wants to serve several compilers has to install one set per compiler and version.

GNU Make

Make is the oldest of the three and has no knowledge of Fortran modules, which is why the dependencies have to be spelled out. The Fortran Programming Language site’s make tutorial writes them by hand; for more than a few files that does not scale, so the Makefile below generates them from the source with a short awk script. It builds the library as a static archive and the program, honours FC= and FFLAGS=, and knows the module-directory option of both compilers.

# Build heatflow with GNU Make. Usage: make [FC=flang-23] [-j]
# Make predefines FC as f77, so a plain "FC ?= gfortran" would never take effect.
ifeq ($(origin FC),default)
  FC := gfortran
endif
FFLAGS ?= -O2 -g -std=f2023 -Wall -Wextra -pedantic -fimplicit-none
BUILD  ?= build
MODDIR := $(BUILD)/mod
OBJDIR := $(BUILD)/obj

# The module directory flag differs between compilers.
ifneq ($(findstring flang,$(FC)),)
  MODFLAG := -module-dir $(MODDIR)
else
  MODFLAG := -J $(MODDIR)
endif
COMPILE = $(FC) $(FFLAGS) $(MODFLAG) -I $(MODDIR) -c $< -o $(OBJDIR)/$*.o

LIB_SRC := $(wildcard src/*.f90)
SUB_SRC := $(shell grep -il '^[[:space:]]*submodule' $(LIB_SRC))
LIB_OBJ := $(patsubst src/%.f90,$(OBJDIR)/%.o,$(LIB_SRC))
SUB_OBJ := $(patsubst src/%.f90,$(OBJDIR)/%.o,$(SUB_SRC))

.PHONY: all clean
all: $(BUILD)/heatflow

# One compiler run writes the object file and the module file: a grouped target (GNU Make 4.3).
$(OBJDIR)/%.o $(MODDIR)/%.mod &: src/%.f90 | $(OBJDIR) $(MODDIR)
	$(COMPILE)

# A submodule writes .smod files and no .mod file, so it gets a plain rule (this wins
# over the grouped pattern above).
$(SUB_OBJ): $(OBJDIR)/%.o: src/%.f90 | $(OBJDIR) $(MODDIR)
	$(COMPILE)

$(OBJDIR)/main.o: app/main.f90 | $(OBJDIR) $(MODDIR)
	$(FC) $(FFLAGS) $(MODFLAG) -I $(MODDIR) -c $< -o $@

$(BUILD)/libheatflow.a: $(LIB_OBJ)
	ar rcs $@ $^

$(BUILD)/heatflow: $(OBJDIR)/main.o $(BUILD)/libheatflow.a
	$(FC) $^ -o $@

# Dependency rules, generated from the use and submodule statements.
$(BUILD)/deps.mk: $(LIB_SRC) app/main.f90 scripts/fdeps.awk | $(BUILD)
	awk -v OBJDIR=$(OBJDIR) -v MODDIR=$(MODDIR) -f scripts/fdeps.awk $(LIB_SRC) app/main.f90 > $@
-include $(BUILD)/deps.mk

$(BUILD) $(OBJDIR) $(MODDIR):
	mkdir -p $@

clean:
	rm -rf $(BUILD)

The dependency generator reads the sources (one module per file, file name equal to module name, which is the convention that heatflow and most projects follow) and prints one rule for each use or submodule statement, skipping intrinsic modules:

# Print "object: module-file ..." rules from the use and submodule statements of the
# files named on the command line. Convention: one module per file, file name = module name.
function base(path,   n, parts) { n = split(path, parts, "/"); sub(/\.f90$/, "", parts[n]); return parts[n] }
FNR == 1 { self = base(FILENAME); delete seen }
{
  line = tolower($0)
  sub(/!.*/, "", line)
  if (match(line, /^[ \t]*use[ \t]*(,[ \t]*non_intrinsic[ \t]*::)?[ \t]+[a-z0-9_]+/)) {
    s = substr(line, RSTART, RLENGTH); sub(/^[ \t]*use[ \t]*(,[ \t]*non_intrinsic[ \t]*::)?[ \t]+/, "", s)
    if (!(s in seen) && s != self) { seen[s] = 1; printf "%s/%s.o: %s/%s.mod\n", OBJDIR, self, MODDIR, s }
  } else if (match(line, /^[ \t]*submodule[ \t]*\([a-z0-9_]+/)) {
    s = substr(line, RSTART, RLENGTH); sub(/^[ \t]*submodule[ \t]*\(/, "", s)
    printf "%s/%s.o: %s/%s.mod\n", OBJDIR, self, MODDIR, s
  }
}

Running make on a fresh tree creates the directories, generates build/deps.mk and compiles in dependency order (the awk line and the compile lines of some modules are left out of the log; all lines shown are real):

mkdir -p build
mkdir -p build/obj
mkdir -p build/mod
gfortran -O2 -g -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -J build/mod -I build/mod -c src/heatflow_kinds.f90 -o build/obj/heatflow_kinds.o
gfortran -O2 -g -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -J build/mod -I build/mod -c src/heatflow_boundary.f90 -o build/obj/heatflow_boundary.o
gfortran -O2 -g -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -J build/mod -I build/mod -c src/heatflow_constants.f90 -o build/obj/heatflow_constants.o
...
gfortran -O2 -g -std=f2023 -Wall -Wextra -pedantic -fimplicit-none -J build/mod -I build/mod -c app/main.f90 -o build/obj/main.o
...
ar rcs build/libheatflow.a build/obj/heatflow_boundary.o build/obj/heatflow_capi.o build/obj/heatflow_config.o build/obj/heatflow_constants.o build/obj/heatflow_ftcs.o build/obj/heatflow_grid.o build/obj/heatflow_io.o build/obj/heatflow_jacobi.o build/obj/heatflow_kernels.o build/obj/heatflow_kinds.o build/obj/heatflow_solver.o
gfortran build/obj/main.o build/libheatflow.a -o build/heatflow
./build/heatflow
make
steps=2000 residual= 1.38E-04
max temperature=   0.139
make: Nothing to be done for 'all'.

make -j4 produced the same result and the same output of the program; the dependency rules are what makes it safe. Without them a parallel build compiles heatflow_kernels.f90 before heatflow_kinds.f90 has finished and fails with the Cannot open module file error from the start of this page, intermittently, which is the typical symptom of a missing rule. With Flang the same Makefile works (make FC=flang-23 FFLAGS='-O2 -g', same program output).

Details of this Makefile

  • FC is predefined. Make defines FC itself as f77, so the usual FC ?= gfortran never takes effect and the build dies with make: f77: No such file or directory. The ifeq ($(origin FC),default) guard replaces only the built-in default and still respects FC=... on the command line and in the environment.

  • One compiler run, two outputs. The rule with &: (grouped targets, GNU Make 4.3 or newer) says that one run writes both the .o and the .mod. With an ordinary rule Make would not know that the module file appears as a side effect, and a rule that only names the object file as the prerequisite of its dependents works too but recompiles more (see the cascade section).

  • Submodules write no .mod. A file that starts with submodule produces .smod files only. With the grouped rule alone Make prints warning: pattern recipe did not update peer target 'build/mod/heatflow_jacobi.mod' for each submodule, so they get their own static-pattern rule, which takes priority over the pattern rule.

  • Order-only prerequisites (after the |) create the directories without making every file out of date when a directory’s timestamp changes.

  • Built-in rules. GNU Make 4.4.1 has built-in rules for .f and .F files ($(FC) $(FFLAGS), and with $(CPPFLAGS) for .F) but none for .f90, so every free-form file needs an explicit rule like the one above, and the variable names FC and FFLAGS are the conventional ones to reuse.

  • Flags per compiler. Only the module-directory option differs here. Warning and checking options are compiler specific, and a Makefile that wants -Wall for both has to select flags on the compiler (see Compilers and Compiler Options).

  • Alternatives. Many projects instead call a dependency tool (makedepf90, fortdepend, or a short Python or Perl script) from the Makefile. They all do what fdeps.awk does. Make cannot avoid this step because it has no way to ask the compiler.

CMake

CMake generates native build files (Ninja or Makefiles) from a CMakeLists.txt. It has first-class Fortran support: the language is enabled with project(... LANGUAGES Fortran), the compiler is found and tested, module dependencies are scanned for you, and dozens of standard modules (FindOpenMP, FindMPI, FindLAPACK, FindHDF5) know how to locate and use the usual Fortran libraries. The complete project file for heatflow:

cmake_minimum_required(VERSION 3.28)
project(heatflow
  VERSION 0.1.0
  DESCRIPTION "2D heat diffusion on a rectangular plate"
  LANGUAGES Fortran C)

include(GNUInstallDirs)
include(CTest)                       # defines BUILD_TESTING and enables ctest

option(HEATFLOW_WITH_MPI "Build the MPI example" ON)

# Module files go to one directory per target, not into the build tree root.
set(HEATFLOW_MODULE_DIR ${PROJECT_BINARY_DIR}/modules)

add_library(heatflow
  src/heatflow_kinds.f90    src/heatflow_constants.f90 src/heatflow_boundary.f90
  src/heatflow_grid.f90     src/heatflow_kernels.f90   src/heatflow_solver.f90
  src/heatflow_jacobi.f90   src/heatflow_ftcs.f90      src/heatflow_config.f90
  src/heatflow_io.f90       src/heatflow_capi.f90)
add_library(heatflow::heatflow ALIAS heatflow)
set_target_properties(heatflow PROPERTIES
  Fortran_MODULE_DIRECTORY ${HEATFLOW_MODULE_DIR}
  VERSION ${PROJECT_VERSION}
  SOVERSION 0)
target_include_directories(heatflow PUBLIC
  $<BUILD_INTERFACE:${HEATFLOW_MODULE_DIR}>
  $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/heatflow>)
target_compile_options(heatflow PRIVATE
  $<$<COMPILE_LANG_AND_ID:Fortran,GNU>:-std=f2023 -Wall -Wextra -pedantic -fimplicit-none>)

add_executable(heatflow-app app/main.f90)
target_link_libraries(heatflow-app PRIVATE heatflow::heatflow)
set_target_properties(heatflow-app PROPERTIES OUTPUT_NAME heatflow)

# A C client of the bind(c) API; the Fortran runtime must still be linked.
add_executable(heatflow-c parallel/c_client.c)
target_link_libraries(heatflow-c PRIVATE heatflow::heatflow)
set_target_properties(heatflow-c PROPERTIES LINKER_LANGUAGE Fortran)

# OpenMP and MPI
find_package(OpenMP COMPONENTS Fortran)
if(OpenMP_Fortran_FOUND)
  add_executable(heatflow-omp parallel/openmp_stencil.f90)
  target_link_libraries(heatflow-omp PRIVATE heatflow::heatflow OpenMP::OpenMP_Fortran)
endif()
if(HEATFLOW_WITH_MPI)
  find_package(MPI COMPONENTS Fortran)
  if(MPI_Fortran_FOUND AND MPI_Fortran_HAVE_F08_MODULE)
    add_executable(heatflow-mpi parallel/mpi_decomposition.f90)
    target_link_libraries(heatflow-mpi PRIVATE heatflow::heatflow MPI::MPI_Fortran)
  endif()
endif()

# Tests
if(BUILD_TESTING)
  include(FetchContent)
  FetchContent_Declare(test-drive
    GIT_REPOSITORY https://github.com/fortran-lang/test-drive
    GIT_TAG v0.6.1
    EXCLUDE_FROM_ALL)               # keeps test-drive out of the install
  set(TESTDRIVE_BUILD_TESTING OFF CACHE BOOL "do not run test-drive's own tests")
  FetchContent_MakeAvailable(test-drive)
  add_executable(check test/check.f90 test/test_grid.f90)
  target_link_libraries(check PRIVATE heatflow::heatflow test-drive)
  add_test(NAME grid COMMAND check)
  add_test(NAME app COMMAND heatflow-app ${PROJECT_SOURCE_DIR}/heatflow.nml)
  set_tests_properties(app PROPERTIES
    WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
    PASS_REGULAR_EXPRESSION "steps=2000")
  add_test(NAME c-client COMMAND heatflow-c)
  if(TARGET heatflow-mpi)
    add_test(NAME mpi COMMAND ${MPIEXEC_EXECUTABLE} ${MPIEXEC_NUMPROC_FLAG} 4
             $<TARGET_FILE:heatflow-mpi>)
  endif()
endif()

# Install the library, the module files and a CMake package.
install(TARGETS heatflow heatflow-app EXPORT heatflowTargets)
install(DIRECTORY ${HEATFLOW_MODULE_DIR}/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/heatflow
        FILES_MATCHING PATTERN "*.mod" PATTERN "*.smod")
install(FILES include/heatflow.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT heatflowTargets NAMESPACE heatflow:: FILE heatflowTargets.cmake
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/heatflow)
include(CMakePackageConfigHelpers)
write_basic_package_version_file(heatflowConfigVersion.cmake COMPATIBILITY SameMajorVersion)
install(FILES cmake/heatflowConfig.cmake ${PROJECT_BINARY_DIR}/heatflowConfigVersion.cmake
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/heatflow)

Configure, build and test, with Ninja, as the standard three commands:

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build
-- The Fortran compiler identification is GNU 16.2.0
-- The C compiler identification is GNU 16.2.0
...
-- Found OpenMP_Fortran: -fopenmp (found version "5.2")
-- Found OpenMP: TRUE (found version "5.2") found components: Fortran
-- Found MPI_Fortran: /usr/lib/aarch64-linux-gnu/mpich/lib/libmpichfort.so (found version "4.1")
-- Found MPI: TRUE (found version "4.1") found components: Fortran
...
Test project /tmp/c7/build
    Start 1: grid
1/4 Test #1: grid .............................   Passed    0.00 sec
    Start 2: app
2/4 Test #2: app ..............................   Passed    0.00 sec
    Start 3: c-client
3/4 Test #3: c-client .........................   Passed    0.00 sec
    Start 4: mpi
4/4 Test #4: mpi ..............................   Passed    0.08 sec

100% tests passed, 0 tests failed out of 4

(The same project configures, builds and passes the same four tests with CMake 4.4.4 and with the Unix Makefiles generator.) The build log of the Ninja generator contains steps that do not exist in C projects:

[22/50] Generating Fortran dyndep file CMakeFiles/check.dir/Fortran.dd
[23/50] Building Fortran object CMakeFiles/heatflow.dir/src/heatflow_kinds.f90.o

The "dyndep" step is the dependency scan: it reads the preprocessed source, finds the use and module statements and tells Ninja which module files each object file produces and consumes. Ninja therefore compiles in the right order and in parallel without any dependency written by hand. Note also that heatflow-mpi and check appear in the same build and compile in any order relative to the library only where the graph permits it.

The parts of the project file

  • project(heatflow ... LANGUAGES Fortran C) enables the languages. Fortran alone suffices for a pure Fortran project; C is listed because of the C client. A project that never lists Fortran cannot use .f90 files.

  • Fortran_MODULE_DIRECTORY is a target property (initialised from the variable CMAKE_Fortran_MODULE_DIRECTORY) that places the module files of a target in a directory of your choice, the CMake spelling of -J/-module-dir. CMake adds the matching search option for the target and for the targets that link it. Without the property the files go to the target’s own directory in the build tree, which is still enough for linking within the project. Using one directory per target lets the install(DIRECTORY ...) rule pick up exactly the module files of the library and nothing else.

  • target_include_directories(... PUBLIC $<BUILD_INTERFACE:...> $<INSTALL_INTERFACE:...>) says where consumers find the module files: in the build tree while building, in include/heatflow once installed. PUBLIC makes the property travel to everything that links heatflow::heatflow.

  • target_compile_options(... $<$<COMPILE_LANG_AND_ID:Fortran,GNU>:...>) adds the strict options to GNU Fortran only. CMake has no CMAKE_Fortran_STANDARD, so the standard is selected with a compiler flag, and a flag must not be given to a compiler that rejects it.

  • BUILD_SHARED_LIBS is the standard switch between static and shared libraries for add_library without a type; -DBUILD_SHARED_LIBS=ON configured, built and passed the same four tests here, with RUNPATH set for the build tree and rewritten at installation.

  • set_target_properties(heatflow-c ... LINKER_LANGUAGE Fortran) is the CMake answer to "the Fortran runtime is missing" for a program whose only source is C: link with the Fortran driver. The C client of the library is described in Calling Fortran from C, C++ and Python.

  • find_package(OpenMP COMPONENTS Fortran) defines the imported target OpenMP::OpenMP_Fortran, which carries -fopenmp for compiling and linking. find_package(MPI COMPONENTS Fortran) defines MPI::MPI_Fortran and the variable MPI_Fortran_HAVE_F08_MODULE, which the file tests before building the mpi_f08 demo. The MPI test runs through ${MPIEXEC_EXECUTABLE} and ${MPIEXEC_NUMPROC_FLAG}, which FindMPI also sets.

  • include(CTest) and add_test register tests; ctest runs them. PASS_REGULAR_EXPRESSION makes a test pass when the output matches, whatever the exit code (the app test). A program that ends with error stop has a non-zero exit status and fails its test, which is the convention test-drive follows.

  • FetchContent downloads test-drive at configure time. EXCLUDE_FROM_ALL (CMake 3.28 or newer, the reason for the version at the top) keeps its install rules out of the project’s install. Two details of test-drive v0.6.1 are worth knowing: its own tests are registered into your CTest unless TESTDRIVE_BUILD_TESTING is OFF, and the target to link is plain test-drive, because the alias the project defines is spelled with a single colon (test-drive:test-drive); the double-colon name exists only in the installed package.

Source form, preprocessing, C interfaces, feature checks and LAPACK

Several Fortran-specific CMake features are not needed by heatflow itself. A small separate project shows each one (it was configured and built with CMake 3.31.6 and 4.4.4, and with both gfortran and Flang):

cmake_minimum_required(VERSION 3.20)
project(cmake_tour LANGUAGES C Fortran)

# 1. Source form is decided by the extension; Fortran_FORMAT overrides it per file.
add_library(forms OBJECT row_sum.f banner_free.f)
set_source_files_properties(banner_free.f PROPERTIES Fortran_FORMAT FREE)   # free form in a .f file
set_source_files_properties(row_sum.f PROPERTIES Fortran_FORMAT FIXED)      # the default for .f, stated explicitly

# 2. Fortran_PREPROCESS: run the preprocessor on a lowercase .f90 file (uppercase .F90 is preprocessed by default).
add_executable(preproc preproc_demo.f90)
set_source_files_properties(preproc_demo.f90 PROPERTIES Fortran_PREPROCESS ON COMPILE_DEFINITIONS "HEATFLOW_DEBUG=1")

# 3. FortranCInterface: call a Fortran procedure that has no bind(c) from C.
include(FortranCInterface)
FortranCInterface_VERIFY()
FortranCInterface_HEADER(${CMAKE_BINARY_DIR}/fc_mangle.h MACRO_NAMESPACE "FC_" SYMBOLS scale_vector)
add_executable(c_calls_fortran main.c legacy_scale.f90)
target_include_directories(c_calls_fortran PRIVATE ${CMAKE_BINARY_DIR})
set_target_properties(c_calls_fortran PROPERTIES LINKER_LANGUAGE Fortran)

# 4. CheckFortranSourceCompiles: probe the compiler for a feature.
include(CheckFortranSourceCompiles)
check_fortran_source_compiles("
program t
  implicit none
  integer :: i
  real :: s
  s = 0.0
  do concurrent (i = 1:10) reduce(+: s)
    s = s + real(i)
  end do
  print *, s
end program t" HAVE_DO_CONCURRENT_REDUCE SRC_EXT f90)
check_fortran_source_compiles("
program t
  implicit none
  enumeration type :: color
    enumerator :: red, green
  end enumeration type
  type(color) :: c
  c = red
end program t" HAVE_ENUMERATION_TYPE SRC_EXT f90)
foreach(feature DO_CONCURRENT_REDUCE ENUMERATION_TYPE)
  if(HAVE_${feature})
    message(STATUS "Fortran feature ${feature}: yes")
  else()
    message(STATUS "Fortran feature ${feature}: no")
  endif()
endforeach()

# 5. LAPACK: a solved 2x2 system.
find_package(LAPACK REQUIRED)
add_executable(solve solve.f90)
target_link_libraries(solve PRIVATE LAPACK::LAPACK)

include(CTest)
add_test(NAME solve COMMAND solve)
add_test(NAME preproc COMMAND preproc)
add_test(NAME c_calls_fortran COMMAND c_calls_fortran)
set_tests_properties(solve PROPERTIES PASS_REGULAR_EXPRESSION "x = +1.0 +2.0")

Fortran_FORMAT. By default CMake derives the source form from the file extension (a .f file is fixed form, a .f90 file free form). The source property, FIXED or FREE, overrides this and CMake passes the matching option. The files:

      SUBROUTINE ROW_SUM(N, X, S)
      IMPLICIT NONE
      INTEGER, INTENT(IN) :: N
      DOUBLE PRECISION, INTENT(IN) :: X(N)
      DOUBLE PRECISION, INTENT(OUT) :: S
      INTEGER :: I
      S = 0.0D0
      DO I = 1, N
         S = S + X(I)
      END DO
      END SUBROUTINE ROW_SUM
subroutine banner()
  print '(a)', 'free form inside a .f file'
end subroutine banner

In the Ninja build file gfortran receives -ffree-form -fpreprocessed for banner_free.f and -ffixed-form -fpreprocessed for row_sum.f; Flang receives -ffree-form -ffixed-line-length-72 and -ffixed-form -ffixed-line-length-72. row_sum.f is written without the labelled do statement on purpose: with -std=f2023 -pedantic gfortran warns Fortran 2018 obsolescent feature: Labeled DO statement for DO 10 I = 1, N. See Program Structure and Source Form and Legacy Features and Modernization.

Fortran_PREPROCESS. Uppercase extensions (.F90) are preprocessed by default; a lowercase .f90 file is not. Setting the source property to ON makes CMake run the preprocessor (it was added in CMake 3.18; the documentation also says that with the Ninja generator CMake preprocesses every source anyway, to scan for modules, and OFF skips this). COMPILE_DEFINITIONS is the source-level -D:

program preproc_demo
  implicit none
#ifdef HEATFLOW_DEBUG
  print '(a)', 'debug build: HEATFLOW_DEBUG is defined'
#else
  print '(a)', 'release build'
#endif
end program preproc_demo
debug build: HEATFLOW_DEBUG is defined

FortranCInterface. Calling a Fortran procedure that has no bind(c) from C requires knowing how the compiler mangles its name (scale_vector_ for gfortran). The module probes the compiler pair, verifies that C and Fortran can be linked together, and writes a header with the mangling macros:

! No bind(c): the linker symbol is compiler specific (scale_vector_ for gfortran).
subroutine scale_vector(n, x, a)
  implicit none
  integer, intent(in) :: n
  double precision, intent(inout) :: x(n)
  double precision, intent(in) :: a
  x = a*x
end subroutine scale_vector
#include <stdio.h>
#include "fc_mangle.h"

void scale_vector(const int *n, double *x, const double *a);   /* the macro maps it to scale_vector_ */

int main(void) {
    int n = 3;
    double x[3] = {1.0, 2.0, 3.0}, a = 2.5;
    scale_vector(&n, x, &a);
    printf("%.1f %.1f %.1f\n", x[0], x[1], x[2]);
    return 0;
}
-- Verifying Fortran/C Compiler Compatibility
-- Verifying Fortran/C Compiler Compatibility - Success

The generated fc_mangle.h (shortened to its macros) defines the global-symbol macro as FC_GLOBAL(name,NAME) equal to name##_, a symbol-specific macro scale_vector that expands to FC_GLOBAL_(scale_vector, SCALE_VECTOR), and for module procedures FC_MODULE(mod_name,name,mod_NAME,NAME). With gfortran the module macro expands to __##mod_name##_MOD_##name; with Flang it expands to _QM##mod_name##P##name. The program prints 2.5 5.0 7.5 with both. New code should use bind(c) and need none of this (see Interoperability with C); FortranCInterface is for existing libraries such as the reference BLAS and LAPACK, whose names carry a trailing underscore.

CheckFortranSourceCompiles. The CMake counterpart of a configure test, or of Meson’s fc.compiles():

-- Fortran feature DO_CONCURRENT_REDUCE: yes
-- Fortran feature ENUMERATION_TYPE: no

The first probe is a do concurrent with a reduce locality specifier, which both gfortran 16.2 and Flang 23 accept, the second an F2023 enumeration type, which both reject (the result is the same with Flang, and the heatflow page on Enumerations uses enum, bind(c) for that reason). Use the probe to select an implementation or to fail early with a clear message, rather than to discover a missing feature from a cascade of compiler errors. The module checks compiling and linking (check_fortran_source_compiles takes SRC_EXT to choose the file extension and FAIL_REGEX for warnings that must count as failure); CheckFortranSourceRuns also runs the program.

FindLAPACK. find_package(LAPACK REQUIRED) finds BLAS and LAPACK through the usual search and defines the imported target LAPACK::LAPACK, which carries the BLAS library as well:

program solve
  use, intrinsic :: iso_fortran_env, only: real64
  implicit none
  interface
    subroutine dgesv(n, nrhs, a, lda, ipiv, b, ldb, info)
      import :: real64
      integer, intent(in) :: n, nrhs, lda, ldb
      real(real64), intent(inout) :: a(lda, *), b(ldb, *)
      integer, intent(out) :: ipiv(*), info
    end subroutine dgesv
  end interface
  real(real64) :: a(2, 2), b(2, 1)
  integer :: ipiv(2), info
  a = reshape([2.0_real64, 1.0_real64, 1.0_real64, 3.0_real64], [2, 2])   ! [2 1; 1 3]
  b(:, 1) = [4.0_real64, 7.0_real64]
  call dgesv(2, 1, a, 2, ipiv, b, 2, info)
  print '(a,i0,a,2f6.1)', 'info=', info, ' x =', b(:, 1)
end program solve
-- Found BLAS: /usr/lib/aarch64-linux-gnu/libopenblas.so
-- Found LAPACK: /usr/lib/aarch64-linux-gnu/libopenblas.so;-lm;-ldl
info=0 x =   1.0   2.0

The explicit interface is the correct way to call a library procedure from modern Fortran, as BLAS, LAPACK and Numerical Libraries explains. The same solve.f90 ran with Flang 23 and printed the same line. The test solve registered in the project passes because its PASS_REGULAR_EXPRESSION is x = +1.0 +2.0.

FindHDF5. find_package(HDF5 REQUIRED COMPONENTS Fortran) and the imported target HDF5::HDF5 give a program that uses the hdf5 module (HDF5 1.14.5 from the Debian development package installed inside the container; it prints HDF5 1.14.5). It works only if the HDF5 Fortran module files can be read by your compiler, as discussed above.

Installing and consuming the library

The last block of the project file installs the library, the module files, the header and a CMake package (heatflowTargets exported in the heatflow:: namespace, and a heatflowConfig.cmake; a version file written by write_basic_package_version_file lets consumers ask for a version):

include(CMakeFindDependencyMacro)
include("${CMAKE_CURRENT_LIST_DIR}/heatflowTargets.cmake")

cmake --install build --prefix <prefix> puts the module files in include/heatflow (twelve .mod and .smod files) and heatflow.h in include. A second project uses it with three lines:

cmake_minimum_required(VERSION 3.20)
project(consumer LANGUAGES Fortran)
find_package(heatflow 0.1 CONFIG REQUIRED)
add_executable(consumer consumer.f90)
target_link_libraries(consumer PRIVATE heatflow::heatflow)
program consumer
  use heatflow_kinds, only: dp
  use heatflow_grid, only: grid_t, make_grid
  implicit none
  type(grid_t) :: g
  g = make_grid(11, 11, 1.0_dp, 1.0_dp)
  call g%set_hot_spot(100.0_dp)
  print '(a,f6.1)', 'max temperature = ', g%max_temperature()
end program consumer
cmake -S consumer -B cbuild -G Ninja -DCMAKE_PREFIX_PATH=<prefix>
cmake --build cbuild
./cbuild/consumer
max temperature =  100.0

Linking heatflow::heatflow supplies the library and the include directory with the module files; with a shared library the run-time path is set by CMake for the build tree (a program that runs from elsewhere needs the library directory on the loader path or CMAKE_INSTALL_RPATH). The consumer compiled and ran with both CMake 3.31.6 and 4.4.4, with static and shared builds of the library.

CMake with LLVM Flang

Selecting Flang is one variable, -DCMAKE_Fortran_COMPILER=flang-23 (or the path of the flang executable):

-- The Fortran compiler identification is LLVMFlang 23.1.4
-- The C compiler identification is GNU 16.2.0
-- Found OpenMP_Fortran: -fopenmp (found version "5.0")
-- Found OpenMP: TRUE (found version "5.0") found components: Fortran
-- Could NOT find MPI_Fortran (missing: MPI_Fortran_WORKS)
-- Could NOT find MPI (missing: MPI_Fortran_FOUND Fortran)

CMake 3.31.6 identifies the compiler as LLVMFlang, so the COMPILE_LANG_AND_ID:Fortran,GNU expression in the project file correctly skips the GNU-only options. The build then compiled the library, the program and the OpenMP demo, linked the tests, and ctest passed grid, app and c-client (three tests: the mpi test is not registered). The MPI check fails honestly: the MPICH in the container was built for gfortran and its mpi_f08 module files cannot be read by Flang, which is the module-file incompatibility from the start of this page. The if(MPI_Fortran_FOUND AND MPI_Fortran_HAVE_F08_MODULE) guard in the project file is what lets the configuration continue. The test-drive sources compile under Flang with a -Wfinal warning, not an error.

Meson

Meson is a Python build system that generates Ninja files. Its project description is shorter than CMake’s and has no variables to set before targets exist; Fortran support is built in. The heatflow project file, with its options file and the wrap file for the one external dependency:

project('heatflow', 'fortran', 'c',
  version: '0.1.0',
  license: 'MIT',
  meson_version: '>=1.1.0',
  default_options: [
    'warning_level=2',
    'fortran_std=f2018',     # the newest value Meson accepts; there is no f2023
    'buildtype=release',
    'default_library=both',
  ])

fc = meson.get_compiler('fortran')

heatflow_sources = files(
  'src/heatflow_kinds.f90',    'src/heatflow_constants.f90', 'src/heatflow_boundary.f90',
  'src/heatflow_grid.f90',     'src/heatflow_kernels.f90',   'src/heatflow_solver.f90',
  'src/heatflow_jacobi.f90',   'src/heatflow_ftcs.f90',      'src/heatflow_config.f90',
  'src/heatflow_io.f90',       'src/heatflow_capi.f90')

heatflow_lib = library('heatflow', heatflow_sources,
  version: meson.project_version(),
  soversion: '0',
  install: true)

# Targets that link_with the library find its .mod files in the library's private directory.
heatflow_dep = declare_dependency(
  link_with: heatflow_lib,
  include_directories: include_directories('include'))

# Meson installs libraries and headers but not .mod files: copy them with an install script.
meson.add_install_script('scripts/install_mods.py', get_option('includedir') / 'heatflow')
pkg = import('pkgconfig')
pkg.generate(heatflow_lib, description: 'Heat diffusion on a rectangular plate',
  subdirs: 'heatflow')

heatflow_app = executable('heatflow', 'app/main.f90',
  dependencies: heatflow_dep, install: true)

# A C client of the bind(c) API; link with the Fortran driver so the runtime is found.
c_client = executable('heatflow-c', 'parallel/c_client.c',
  dependencies: heatflow_dep, link_language: 'fortran')

install_headers('include/heatflow.h')

# Optional dependencies
omp = dependency('openmp', language: 'fortran', required: false)
if omp.found()
  omp_demo = executable('heatflow-omp', 'parallel/openmp_stencil.f90',
    dependencies: [heatflow_dep, omp])
endif

mpi = dependency('mpi', language: 'fortran', required: get_option('mpi'))
if mpi.found() and fc.compiles('use mpi_f08\nend', dependencies: mpi, name: 'mpi_f08 module')
  mpi_demo = executable('heatflow-mpi', 'parallel/mpi_decomposition.f90',
    dependencies: [heatflow_dep, mpi])
endif

# dependency('coarray') found OpenCoarrays here but passed no -fcoarray flag, so GCC 16's
# own shared-memory library is used directly.
caf_shmem = fc.find_library('caf_shmem', required: get_option('coarray'))
if caf_shmem.found()
  caf_demo = executable('heatflow-caf', 'parallel/coarray_halo.f90',
    dependencies: [heatflow_dep, caf_shmem], fortran_args: ['-fcoarray=lib'])
endif

hdf5 = dependency('hdf5', language: 'fortran', required: false)
lapack = dependency('lapack', required: false)
summary({'OpenMP': omp, 'MPI': mpi, 'coarray': caf_shmem, 'HDF5': hdf5, 'LAPACK': lapack},
  bool_yn: true, section: 'Optional dependencies')

# Tests
testdrive = subproject('test-drive', required: false,
  default_options: ['default_library=static'])    # a static subproject is not installed
if testdrive.found()
  check = executable('check', 'test/check.f90', 'test/test_grid.f90',
    dependencies: [heatflow_dep, testdrive.get_variable('testdrive_dep')])
  test('grid', check)
endif
test('app', heatflow_app, args: files('heatflow.nml'), workdir: meson.current_build_dir())
test('c-client', c_client)
if caf_shmem.found()
  test('coarray', caf_demo, env: {'GFORTRAN_NUM_IMAGES': '4'})
endif
if mpi.found() and fc.compiles('use mpi_f08\nend', dependencies: mpi, name: 'mpi_f08 module')
  test('mpi', find_program('mpiexec'), args: ['-n', '4', mpi_demo], is_parallel: false)
endif
option('mpi', type: 'feature', value: 'auto', description: 'Build the MPI example')
option('coarray', type: 'feature', value: 'auto', description: 'Build the coarray example')
[wrap-git]
url = https://github.com/fortran-lang/test-drive
revision = v0.6.1
depth = 1

[provide]
test-drive = testdrive_dep

And an install script, because Meson installs libraries, headers and pkg-config files but not module files:

#!/usr/bin/env python3
"""Meson install script: copy the Fortran module files into <prefix>/<includedir>/heatflow."""
import os
import shutil
import sys

build_root = os.environ["MESON_BUILD_ROOT"]
prefix = os.environ.get("MESON_INSTALL_DESTDIR_PREFIX", os.environ["MESON_INSTALL_PREFIX"])
target = os.path.join(prefix, sys.argv[1])
os.makedirs(target, exist_ok=True)
for entry in sorted(os.listdir(build_root)):
    private = os.path.join(build_root, entry)
    if entry.startswith("libheatflow") and entry.endswith(".p"):
        for name in sorted(os.listdir(private)):
            if name.endswith((".mod", ".smod")):
                print("Installing", name, "to", target)
                shutil.copy(os.path.join(private, name), target)

Configure, build, test and install:

meson setup build --prefix=/usr/local --libdir=lib
meson compile -C build
meson test -C build
meson install -C build

The configuration summary (Meson 1.12.1; "Optional dependencies" is produced by the summary() call in the file):

Fortran compiler for the host machine: gfortran (gcc 16.2.0 "GNU Fortran (GCC) 16.2.0")
Fortran linker for the host machine: gfortran ld.bfd 2.44
C compiler for the host machine: cc (gcc 16.2.0 "cc (GCC) 16.2.0")
Run-time dependency OpenMP for fortran found: YES 5.2
mpifort found: YES (/usr/bin/mpifort) 4.2.1
Run-time dependency MPI for fortran found: YES 4.2.1
Checking if "mpi_f08 module" with dependency mpifort compiles: YES
Library caf_shmem found: YES
h5fc found: NO
Run-time dependency HDF5 for fortran found: NO  (tried pkg-config, pkg-config and config-tool)
Run-time dependency lapack found: YES 0.3.29+ds
Build targets in project: 10

heatflow 0.1.0

  Optional dependencies
    OpenMP    : YES 5.2
    MPI       : YES 4.2.1
    coarray   : YES
    HDF5      : NO
    LAPACK    : YES 0.3.29+ds
1/5 heatflow:app      OK              0.00s
2/5 heatflow:c-client OK              0.00s
3/5 heatflow:grid     OK              0.00s
4/5 heatflow:coarray  OK              0.02s
5/5 heatflow:mpi      OK              0.08s

Ok:                5
Fail:              0

(The line HDF5 : NO is the honest state of that container, where the HDF5 development package is not installed; with it, dependency('hdf5', language: 'fortran') found HDF5 1.14.5 and the one-file program built and printed HDF5 1.14.5 with both Meson releases.)

The parts of the project file

  • project('heatflow', 'fortran', 'c', ...) enables the languages, as in CMake. meson_version: '>=1.1.0' is a floor for the features used.

  • fortran_std is the built-in option that selects the language standard (it becomes -std=f2018 for gfortran). The accepted values are none, legacy, f95, f2003, f2008 and f2018. There is no f2023; asking for it is an error in both releases:

    meson.build:1:0: ERROR: Value "f2023" (of type "string") for option "fortran_fortran_std" is not one of the choices. Possible choices are (as string): "none", "legacy", "f95", "f2003", "f2008", "f2018".

    For a project that follows Fortran 2023 this means fortran_std=f2018 as a ceiling with the strict F2023 check done separately, or none plus explicit -std=f2023 through fortran_args. The valid values depend on the compiler: with LLVM Flang 23 the only choice is none, so a project that sets fortran_std=f2018 in default_options fails to configure with Flang (Possible choices are (as string): "none") and needs -Dfortran_std=none on the command line.

  • warning_level=2 maps to -Wall -Wextra for gfortran. The command that Meson ran for heatflow_kinds.f90 was gfortran -Ilibheatflow.so.0.1.0.p -I. -I../hf -fdiagnostics-color=always -D_FILE_OFFSET_BITS=64 -Wall -Wextra -std=f2018 -O3 -fPIC -Jlibheatflow.so.0.1.0.p ...: the module files of a target go in its private directory (libheatflow.so.0.1.0.p), selected with -J, and -I points at the private directory of every library it links to.

  • library() with default_library=both builds a static and a shared library from the same objects. A target that lists the library in link_with automatically finds its module files; a plain executable('e', 'e.f90', link_with: lib) compiled use heatflow_kinds without any extra include directory. (lib.private_dir_include() is the explicit spelling used by projects such as test-drive, whose meson.build returns it in its dependency for consumers that do not link the library directly.)

  • link_language: 'fortran' gives the C client the same effect as LINKER_LANGUAGE Fortran in CMake.

  • Dependencies are one-liners: dependency('openmp', language: 'fortran'), dependency('mpi', language: 'fortran'), dependency('hdf5', language: 'fortran'), dependency('lapack'). Meson tries pkg-config first and then the compiler wrappers (mpifort, mpif90, mpif77) for MPI. The tests that check a dependency really delivers what the program needs use fc.compiles(), the Meson counterpart of check_fortran_source_compiles.

  • MPI with MPICH needs a current Meson. With Meson 1.7.0 the MPI dependency is "found" but its flags are empty (it asks the wrapper for --showme:compile, an OpenMPI option; the MPICH wrapper passes it on to gfortran, which rejects it), so the fc.compiles('use mpi_f08\nend', dependencies: mpi) check says NO and the demo is skipped. With 1.12.1 the same file compiles the check, builds heatflow-mpi and passes the mpi test. The guard in the file turns the older behaviour into a skipped demo instead of a build failure.

  • Coarrays. The documentation states that dependency('coarray') enables the coarray feature and that GCC uses OpenCoarrays when present. In this container, where OpenCoarrays 2.10.3 is installed, dependency('coarray') was found through its CMake package in both Meson releases but compiled coarray_halo.f90 without any coarray option, so the build stopped with Fatal Error: Coarrays disabled at (1), use '-fcoarray=' to enable. Meson 1.12.1 also warned Unknown generator expression '$<:-fcoarray=lib>' while reading the CMake package: the option is in the package, but Meson cannot evaluate the expression that carries it. The project file instead uses GCC 16’s own library: fc.find_library('caf_shmem') and fortran_args: ['-fcoarray=lib'], and runs the demo with GFORTRAN_NUM_IMAGES=4, which both releases built and passed. See Coarrays, Images and Synchronization.

  • Tests are test() calls; meson test runs them in parallel unless is_parallel: false (the MPI test). test-drive arrives as a subproject through the wrap file, whose [provide] section names the dependency variable testdrive_dep in its meson.build. default_options: ['default_library=static'] keeps a static subproject out of the installation.

  • Installing with meson install: libraries, the program, the header and the generated heatflow.pc (through the pkgconfig module, whose subdirs: 'heatflow' puts -I${includedir}/heatflow in Cflags) are installed by Meson, and the module files by scripts/install_mods.py, registered with meson.add_install_script. A consumer then compiles the module users with plain pkg-config:

    gfortran consumer.f90 $(pkg-config --cflags --libs heatflow) -Wl,-rpath,<prefix>/lib -o consumer
    ./consumer
    max temperature =  100.0

Meson with LLVM Flang

FC=flang-23 meson setup build -Dfortran_std=none -Dmpi=disabled -Dcoarray=disabled configures with Meson 1.7.0 and 1.12.1 (the compiler is reported as llvm-flang 23.1.4). Meson 1.12.1 built everything and the app, c-client and grid tests passed. Meson 1.7.0 failed to link heatflow-c: it appended -lFortranRuntime -lFortranDecimal, the runtime library names of older Flang releases, and the linker stopped with cannot find -lFortranRuntime. OpenMP was not found for Flang by Meson 1.12.1 (Run-time dependency OpenMP for fortran found: NO (tried system)) although the same Flang compiles and links the OpenMP demo through CMake’s FindOpenMP; a Flang user of Meson who needs OpenMP has to pass -fopenmp through fortran_args and link_args explicitly. The -Wall warning level is accepted by Meson but Flang reports argument unused during compilation: '-Wall'.

Recompilation cascades

A change to a module’s source recompiles that module. Whether it also recompiles everything that uses it depends on what the build system does with the module file the compiler writes, and this is the part of the topic where the tools differ, so it is measured on the real project.

Both compilers have a feature that makes the optimisation possible: they do not rewrite a module file whose contents have not changed. Compiling heatflow_kinds.f90 again after appending only a comment left the .mod file untouched, timestamp and size included, with gfortran 16.2 and with Flang 23. A build system that compares timestamps (Make) or output stamps (Ninja) can then conclude that the dependants need no work. The experiment edits the heatflow sources and shows which files each tool recompiles (measured with the final Makefile, CMake and Meson files above, the program main.f90 included; the executables of the demos are left out):

Tool Comment added to heatflow_solver.f90 Comment added to heatflow_jacobi.f90 (a submodule) Public name added to heatflow_kinds

Make, prerequisites are .mod files (this page)

heatflow_solver only

heatflow_jacobi only

9 library files and main: every unit that uses kinds directly, and the units behind them whose module file changed; not heatflow_boundary or heatflow_capi

Make, prerequisites are .o files (the usual hand-written style)

heatflow_solver, heatflow_jacobi, heatflow_ftcs and main

heatflow_jacobi only

10 library files (all but heatflow_boundary) and main, because heatflow_capi now follows heatflow_kernels whatever its module file says

CMake with Ninja (3.31.6 and 4.4.4)

heatflow_solver only

heatflow_jacobi only

9 library files and main

CMake with Makefiles (3.31.6 and 4.4.4)

heatflow_solver only

heatflow_jacobi only

9 library files and main

Meson 1.12.1 with Ninja

heatflow_solver and main

heatflow_jacobi only

9 library files and main

Meson 1.7.0 with Ninja

heatflow_solver only

heatflow_jacobi only

9 library files, but not main

Reading the table:

  • A submodule edit is cheap everywhere, because nothing includes a submodule’s file in its dependency graph. That is the point of splitting a module into interface and implementation (see the build-order figure on Modules and Submodules).

  • The hand-written .o dependency is the classic cascade. With "this object depends on the object of every module it uses" a comment in heatflow_solver.f90 recompiles the module and its submodules and the program, for no reason, because the module file did not change. The page’s Makefile avoids this by depending on the module file: the compiler leaves an unchanged .mod alone and Make does not consider the dependants out of date. That is the same effect that CMake and Meson give without being asked.

  • A real interface change cascades correctly. Adding a public name to heatflow_kinds changes its module file, so every unit that uses it recompiles; units whose module file does not change as a result do not (with module-file prerequisites the chain stops at heatflow_kernels, so heatflow_capi is left alone; heatflow_boundary never used kinds).

  • Meson 1.12.1 was more conservative than the others for main.f90 after a comment-only edit of heatflow_solver.f90. Meson 1.7.0 did not recompile main.f90 after the change to heatflow_kinds (measured, not explained; if you depend on it, use a current Meson and run a clean build in CI).

  • Compile times. On a project of eleven files none of this matters; on a project of thousands of files with a base module such as kinds used by everything it decides whether a one-line edit costs a second or an hour. Keep the base modules small and stable, put implementation in submodules, and prefer use name, only: so the dependency is visible in the source.

The three tools compared

Aspect GNU Make CMake Meson

Module dependency order

you generate or write it

scanned (dyndep with Ninja)

scanned (dyndep with Ninja)

Module directory

-J or -module-dir in the rule

Fortran_MODULE_DIRECTORY

private directory per target, automatic

Standard selection

compiler flag

compiler flag (no CMAKE_Fortran_STANDARD)

fortran_std up to f2018

Source form and preprocessing per file

compiler flags

Fortran_FORMAT, Fortran_PREPROCESS

fortran_args per target

Feature probes

none

CheckFortranSourceCompiles, CheckFortranSourceRuns

fc.compiles(), fc.links()

Libraries (OpenMP, MPI, BLAS and LAPACK, HDF5)

flags by hand

FindOpenMP, FindMPI, FindLAPACK, FindHDF5

dependency() with language: 'fortran'

Tests

your rules

CTest

meson test

Installing module files

your rule

install(DIRECTORY ...)

install script

Mixed Fortran and C

you link with the Fortran driver

LINKER_LANGUAGE, FortranCInterface

link_language

Strength

no dependencies, transparent

the most widespread, the best library finders

short files, fast configure, subprojects

Weak point

you maintain the dependency rules

long files, FetchContent details

Fortran dependencies depend on the Meson release; no f2023

None of the three is a Fortran package manager. For a project that only needs Fortran sources and Fortran dependencies, Fortran Package Manager (fpm) needs no build file at all and can also generate compile_commands.json; CMake and Meson are the right choice when the project mixes languages, needs system libraries beyond what fpm’s metapackages cover, or must fit into a larger build.

Where the books are outdated

Topic Coverage on this page

Build tools

The reference book treats separate compilation almost only through make and recompilation cascades. Today most Fortran projects are built with fpm, CMake or Meson, and the page shows that all three avoid the cascade a naive .o-based Makefile creates.

Module files

Module file names, locations and formats are processor dependent (C.10.2); the page shows the real differences between GNU Fortran 16 and Flang 23, which are not interchangeable.

Fortran standard in Meson

Meson’s fortran_std stops at f2018; Fortran 2023 is not selectable through it.

Compilers

The compiler named flang is LLVM Flang (formerly flang-new); older build files that expect -lFortranRuntime or classic Flang options need updating.

Common problems

Symptom Cause and remedy

Cannot open module file 'x.mod' for reading

The module is compiled later than its user, or the module directory is not on -I. Fix the dependency or add -I.

make: f77: No such file or directory

FC has Make’s predefined default. Test $(origin FC) instead of using ?=.

The program links but does not start, libx.so: cannot open shared object file

The loader cannot find the shared library. Add an rpath or install into a searched directory.

File 'x.mod' ... is not a GNU Fortran module file

The module file was written by another compiler. Do not share build directories between compilers.

Error writing x.mod: No such file or directory

The -J or -module-dir directory does not exist yet.

undefined reference to __gfortran_... when linking a C program

The Fortran runtime is missing. Link with the Fortran driver (LINKER_LANGUAGE Fortran, link_language: 'fortran').

A dependency found by Meson or CMake but the module is not found

The dependency delivers libraries but not the module include path, or its module files belong to another compiler. Test with fc.compiles() or check_fortran_source_compiles.

Every file recompiles after a trivial edit

The build depends on object files instead of module files, or the timestamps are changed by a tool that rewrites module files.