Project Generation for Cross-Platform Projects in C/C++

When working on a new cross-platform project, one has many choices to make. When you select a compiled programming language such as C/C++, you have to pick the appropriate toolchain for each target platforms and their supported architecture. Then comes a crucial decision: what will your development environment be?

This is the first in a series of posts that will dive into the development environment of a cross-platform project. Through this series, we will look at:

  • Project generation, which allows to define source and library dependencies and produces a project setup that can be compiled and linked into our executable or library;
  • Integrated Developement Environement (IDE), which allows to edit source code and run it.
  • Debugging environment, which is required to have the IDE properly debug all your code and external libraries you may use.

Project Generation

In a common single-platform product, creating a project might involve simply opening up a fitting IDE and creating a new project, following a little step-by-step wizard guide. It will ask you about what type of project you want to create (console or graphical application, static or dynamic library) and add as dependencies the essential libraries that make creating those types of products automatically, for the toolchain you specified.

However, not all projects are created equal, with some widely used platform-specific IDEs only being able to create a project targetting its own platform. If you’re lucky, your IDE may offer support for building cross-platform applications through a cross-compiler, that is a compiler that targets a different host system such as Visual Studio using WSL).

However, as soon as a platform cannot be targeted by this project setup, you are stuck: you would need to recreate the project using the other platform’s IDE.

The typical solution is to use a tool I personally think is essential even in platform-specific products: a project generator. This tool turns platform-agnostic configuration files into platform-specific projects (Visual Studio, Xcode, makefile*, etc.) that can then be used to build the product.

*I’m aware makefile is in principle platform-agnostic, but let’s just say it feels at home more on Linux systems.)

Project Generator Options

There are many project generators available today, and I have personally worked with CMake and Premake the most in the context of C/C++ projects. While they both get the job done, CMake tends to get complicated quickly and harder to extend due to the design of its configuration file format, while premake uses lua for configuration, but is definitely used less. Premake also lacks the built-in support for automatically searching for dependencies, which CMake accomplishes by having libraries shipping a CMake config file package, a strategy similar to pkgconfig in the Linux ecosystem.

A recent addition to this list that got my attention is Meson. While it is nowhere near as mature as the aforementioned options, it offers a good compromise of advantages:

  • It has Python-inspired syntax that borrows from the flexibility of Premake;
  • It supports dependency lookup using different systems, including CMake, pkgconfig and macOS frameworks on that platform;

The default build backend for Meson is ninja, which is very fast and cross-platform.

A Meson Project

Let’s create a Meson project for a typical application. Create a new folder, and create a new UTF-8 text file called meson.build. Here is the content:

# 1) Project definition
project(
    'xp_cpp_project'
    'cpp',
    version : '0.1a',
    default_options : [
        'c_std=c11',
        'cpp_std=c++17'
    ]
)

# 2) Source code definition
incdir = include_directories('include')
app_sources = [
    'src/main.cpp'
]

# 3) Dependency definition
rapidjsondep = dependency('RapidJSON')

# 4) Executable application definition
executable(
    'xp_cpp_project',
    app_sources,
    include_directories : incdir,
    dependencies: [rapidjsondep]

We manage to do a lot in a short snippet of code:

  1. We define the project xp_cpp_project at version 0.1a, and specify that it is a C++ project using the C11 and C++17 standards.
  2. We specify that the headers reside inside a folder named include and the list of source files to compile as a list app_sources. Those folders are located relative to the folder containing meson.build.
  3. We specify a depedency to some library, here RapidJSON. It will be looked-up automatically using the known methods for the platform.
  4. Finally, we specify we want to build an executable for xp_cpp_project using the sources contained in app_sources. We specify the include directory and dependencies, and leave Meson to figure out how to specify those in the project it will generate.

Create the folder hierarchy as we have specified (include and src directories, with some hello world main.cpp file inside src). After installing Meson, which is hosted on PyPi, simply run:

/home/me/project> meson build-debug

This will create a directory build-debug next to meson.build using the default backend (ninja). To build, simply invoke ninja:

/home/me/project> ninja -C build-debug

And you’re done: the executable is built as build-debug/xp_cpp_project!

A Complete Setup

Release Configuration

You may have observed that we used build-debug as a target name for the build configuration: what if you want to have a release setup?

Each configuration yields a different folder for all its compiled and contextual files to go in, so we need to tell Meson we want a release build. However, there is nothing to change in meson.build!

Simply add --buildtype release to the meson command:

/home/me/project> meson build-release --buildtype release

From there, ninja -C build-release will build your release target, nothing else to do.

Toolchain on Unix

If you want to specify a different compiler, the process is similar to how makefiles are typically written: specify the environment variables CC and CXX to the compiler path (or executable name if it is present in PATH):

/home/me/project> CC=clang CXX=clang++ meson build-debug

Configure Script

You may see that running meson build-debug a second time will yield:

/home/me/project> meson build-debug
Directory already configured.

Just run your build command (e.g. ninja) and Meson will regenerate as necessary.
If ninja fails, run "ninja reconfigure" or "meson --reconfigure"
to force Meson to regenerate.

As the message says: there is no need to re-run this command as ninja will do the reconfigure for you. However, if you are using a different backend or build using the generated projects directly, you can do it manually with the --reconfigure flag.

I like to have a configure script that yields proper project configuration for a given platform. On UNIX systems, I have come up with the following:

#!/bin/sh
echo "===== Configuring Debug configuration ====="
meson build-debug --reconfigure || meson build-debug

echo "===== Configuring Release configuration ====="
meson build-release --reconfigure --buildtype release || meson build-release --buildtype release

This will generate both the Debug and Release configurations by trying to run a reconfigure first, and it if fails (on a freshly cleaned setup), go ahead and create the configuration. This is useful to ensure that running configure forces a configuration no matter what in special cases.

Takeaway

Project Generation allows to avoid having to maintain multiple platform-specific projects and ensures you can target different toolchains on any platform using a unified configuration definition. While CMake is probably the most popular option, Meson comes as a good alternative to avoid some of the unintuitive scripting of CMake by using a more general syntax akin to what Premake does with Lua. Its support for multiple languages, platform and toolchain backends with automatic dependency lookup gives it an edge over its competitors.

In spite of the tools you decide fit your needs, overlooking the advantages of a good project generator is something that will add a cost down the line to cross-platform applications, and likely at a time where there will be a lot of configuration to migrate in order to support an additional platform. This debt can be entirely avoided at the early stages of a project.

Next on Beyond the Code…

… we will dive into how to setup IDEs in order to unify the development environment while targeting different platforms.

Comments

Popular posts from this blog

Gak: Be more efficient with a Git repository

Zig's Self-Hosted x86 Backend Requires a Fork of LLDB for debugging