CMake on SMT32 | Episode 8: build with Docker
Tags: embedded, cpp, cmake, docker
Published: 2024-03-18 10:57:35

In this episode, we will use Docker to set up and manage our build environment.

The embedded world doesn't always embrace the latest software development techniques. I acknowledged this in the previous [episode about unit tests](https://dev.to/pgradot/cmake-on-smt32-episode-7-unit-tests-13gj). Four years ago, when I started this series, I was totally unaware of Docker for instance. Since then, I have used it for non-embedded projects and I think it could have solved some issues I encountered in the past. I would like to share my experience and demonstrate that it can perfectly fit in an MCU project.

> In fact, every I will say here applies to hosted C / C++ projects too!

## Dependencies to build our project

Let's take a moment to look back and review all the tools required on our machine to build the project presented in this series:

- `cmake`: obviously, this is the quintessence of this series.
- `make`: in the first episode, we chose to use `-G "MinGW Makefiles" ` as our generator. We may have chosen another generator, for instance `-G Ninja`, and have another dependency (`ninja`, namely).
- `arm-none-eabi-gcc`: this is the toolchain to compile our embedded code.
- `gcc`: we need another toolchain for the unit tests to run on the host computer.
- `git`: CMake's `FetchContent` module needs Git to fetch Catch2 from GitHub.

The list may seem relatively small for the moment, but for sure it will grow in the future. We will probably want to:

- write scripts with `python` and need `pip` to install packages.
- debug the units tests with `gdb`.
- analyze our code `clang-tidy` and format it with `clang-format`.

Each developer on the project needs to install everything on their machine. Each time the team decides to upgrade a tool to a newer version or to use a new tool, everyone must update their setup. Not to mention the CI environment (if you have maintained a Jenkins machine once in your life, you know what I mean).

Should I say that everyone may have a different version of everything on their machine? Things could turn into a nightmare.

This is where Docker comes into play to helps tackle this complexity. The purpose is to create a Docker image with everything the team needs to work. Instead of having to install many applications, we just need Docker (and obviously a VCS client, probably Git, to clone the central repository).

> This article is not meant to be crash course to Docker. If you don't have the basic knowledge, you should probably read a tutorial. This may be optional though, as you will probably get the concepts and the process I will describe anyway.

## Dockerfile

We create a `Dockerfile` (yes, that the name of the file) next to our `CMakeLists.txt`, at the root of our project. We use the latest image of Ubuntu as the base image, and we install the packages we need. Here is the content of the file:

```dockerfile
FROM ubuntu:latest

RUN apt-get update && apt-get install -y \
                        build-essential \
                        cmake \
                        gcc-arm-none-eabi \
                        gdb \
                        git
```

The [`build-essential` package](https://packages.ubuntu.com/jammy/build-essential) includes `gcc` and `make`. Note that I have included `gdb`, which isn't yet mandatory, but CLion asks for it (and we will see that this Docker image can be used seamlessly within CLion).

And... that's it! We have everything we need! We will see later how we can improve this file by using explicit versions. For the moment, let's try to build our firmware and our unit tests!

## Build the Docker image

This `Dockerfile` describes our image, and we have to build it now. We just need to execute:

```bash
docker build -t cmake-on-stm32 .
```

`-t` is short for `--tag`. It gives the image a name (in fact, [a tag](https://docs.docker.com/engine/reference/commandline/image_build/#tag)), and this will make our life easier in the next commands.

If [we list the images](https://docs.docker.com/engine/reference/commandline/image_ls/), we see our image and its base image:

```bash
$ docker images
REPOSITORY        TAG       IMAGE ID       CREATED          SIZE
cmake-on-stm32    latest    1ed7f1257600   48 minutes ago   3.12GB
ubuntu            latest    e34e831650c1   5 weeks ago      77.9MB
```

The image can now be used to run containers, which are the actual runners for this Dockerized build environment.

## Using the container from the terminal

We have two options:

1. Get a shell inside the container and work inside the container.
2. Ask the container to run commands and work from the host.

### Get a shell inside the container

For the first option, we just have to run the following command to get a shell inside the container:

```bash
docker run --rm -it -v $(pwd)/:/nucleo/ -w /nucleo/ cmake-on-stm32
```

Let's break down the options:

- `--rm` requires Docker to [remove the container](https://docs.docker.com/engine/reference/commandline/container_run/#rm) when it exits.
- `-it` is short for `--interactive` + `--tty`. This is the option that takes us straight inside the container.
- `-v` to [mount the current directory](https://docs.docker.com/engine/reference/commandline/container_run/#volume) as `/nucleo/` in the container. Indeed, the container is isolated from the host. If we want to share directories between the host and the container, volumes are the way to go.
- `-w` is optional, it just sets the [working directory](https://docs.docker.com/engine/reference/commandline/container_run/#workdir) so that the shell starts in a handy location.

NOTE: if you always use the same working directory, you can set it directly in the `Dockerfile` with the `WORKDIR` [instruction](https://docs.docker.com/reference/dockerfile/#workdir).

To demonstrate the usage:

![prompts](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3padekesov8g3dwmig98.png)

Notice how the prompt changes and how the `arm-none-eabi-gcc` is found or not. `exit` exits the shell and hence the container.

Now, we can just follow the process that has been described throughout this series: generate the project, build the firmware, build the tests, run the tests, etc. The commands are the same, we just execute them inside the container.

### Execute commands from the host

We can remove the `-it` option and add more arguments to the command. The container will execute them. Here is an example with `ls` and `arm-none-eabi-gcc --version`:

![commands from host](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nb49sg9z5cq600d7vjq3.png)

I tend to prefer the first technique, because the commands can get very long with this second technique. Furthermore, the host can't auto-complete the commands because it doesn't know what the container supports. Nevertheless, I guess in some cases, mixing both techniques can be interesting. It's up to you to define how you want to interact with the container.

## Simplify the command-line with Docker Compose

The above commands may seem a bit "heavy", mostly because of the options to mount the volume and to set the working directory. It's likely that more options will be added, making the commands even longer.

We can take advantage of [Docker Compose](https://docs.docker.com/compose/) to simplify things a little. `compose` is a subcommand of `docker`, there is no new software to install on the host machine. We can write a [simple `docker-compose.yml` file](https://docs.docker.com/compose/compose-file/) to describe what we want:

```yaml
services:
  cmake-on-stm32:
    build: .
    volumes:
      - .:/nucleo/
    working_dir: /nucleo
```

We can now use [the `docker compose run` command]( https://docs.docker.com/reference/cli/docker/compose/run/) (instead of just `docker run`) and omit the options that are described in `docker-compose.yml`:

![docker compose run](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c3guqos541622kfhxdjl.png)

Note that [the `-it` option is not required to use the container interactively](https://stackoverflow.com/a/36265910) with `docker compose`.

## Using the container from CLion

In [episode 2](https://dev.to/pgradot/cmake-on-stm32-episode-2-build-with-clion-2lae), we learned how to build our CMake project for STM32 with [CLion by Jetbrains](https://www.jetbrains.com/clion/). I have good news: CLion supports [Docker toolchains](https://www.jetbrains.com/help/clion/clion-toolchains-in-docker.html) out-of-the-box! I tried and it worked perfectly in minutes:

- I added a new Docker toolchain that uses `cmake-on-stm32:latest` as its image.
- I created 2 profiles to build the project:
  1. For the units tests: I used all default values except for the toolchain where I selected my Docker toolchain.
  2. For the embedded code: same except that I added `-DCMAKE_TOOLCHAIN_FILE=arm-none-eabi-gcc.cmake` in the `CMake options` field.

And voilà! We can then work within CLion without noticing that Docker is involved.

## Improve user management

There is a little catch with the commands above: inside the container, the user is `root`. Consequence: all files and directories created from the container are owned by `root`. For instance when building in `build-docker`:

![files listing with root as owners](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uwdbsyxz5oij3am6zzvh.png)

Most of the time, this is not a real issue, except when we want to [delete them](https://dille.name/blog/2018/07/16/handling-file-permissions-when-writing-to-volumes-from-docker-containers/) because we don't have the appropriate permissions. On Linux, it means we will need `sudo` to perform the deletion.

To get around this issue, we can add the `--user` option to specify the user and group we want when calling `docker run`. For instance on Linux, `--user $(id -u):$(id -g)` will use our user and group. We will then be the owner of the generated files and directories:

![files listing with pierre as owner](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mvuvbmvon45hrij0bx5g.png)

The user and group can also be set in Docker Compose. Read ["How to set user and group in Docker Compose" by Giovanni De Mizio](https://blog.giovannidemizio.eu/2021/05/24/how-to-set-user-and-group-in-docker-compose/) to learn how to do this.

Note that CLion does this in background, so the ownership of the files is correct without any further options.

## Pin versions of dependencies

In our `Dockerfile`, we have simply chosen the latest Ubuntu version as the base image (with `FROM ubuntu:latest`) and have blissfully installed the packages. This is cool when testing things but there is one major drawback: we have absolutely no idea what versions of the tools we will get!

### Pin the base image

As of February 29th 2024 (as I'm writing these lines), [Docker Hub](https://hub.docker.com/_/ubuntu) says that the `ubuntu:latest` is [Jammy Jellyfish](https://releases.ubuntu.com/jammy/) (aka 22.04, the current LTS). In April 2024 Noble Numbat (aka 24.04, the next LTS release) will be out and will probably become the new `latest` version. It will come (almost for sure) with new versions of the tools. Who knows if our code will still compile under Noble Numbat? We really don't want our project to suddenly stop building because a new release of Ubuntu is out.

The obvious first counter-measure is to choose a version of Ubuntu explicitly. We just have to replace `ubuntu:latest` with `ubuntu:jammy` or any other version. Pinning the base image will also (normally) pin the versions of the tools because each release of Ubuntu is quite conservative about the versions of the programs in the packages (Debian is even more). [Ubuntu Packages Search](https://packages.ubuntu.com/) website tells us the content of the packages we can install with `apt-get`. Thanks to this knowledge, we can decide which Ubuntu version is good for us, if we want to upgrade to the next version or not, etc. For instance with `gcc`:

| Ubuntu          | `gcc`                                          | `gcc-arm-none-eabi`                                          |
|-----------------|------------------------------------------------|--------------------------------------------------------------|
| Jammy Jellyfish | [11.2](https://packages.ubuntu.com/jammy/gcc)  | [10.3](https://packages.ubuntu.com/jammy/gcc-arm-none-eabi)  |
| Lunar Lobster   | [12.2](https://packages.ubuntu.com/lunar/gcc)  | [12.2](https://packages.ubuntu.com/lunar/gcc-arm-none-eabi)  |
| Mantic Minotaur | [13.2](https://packages.ubuntu.com/mantic/gcc) | [12.2](https://packages.ubuntu.com/mantic/gcc-arm-none-eabi) |

### Select more specific packages

On Mantic Minotaur (23.10, the latest version of Ubuntu as I write these lines), the `gcc-arm-none-eabi` package installs GCC 12.2 (for ARM), while the `gcc` package installs GCC 13.2 (for x86-64). We would like our hosted and embedded toolchains to use the same version GCC (at least the same major version). There is a solution: the [`gcc-12` packages](https://packages.ubuntu.com/mantic/gcc-12) installs GCC 12.3. That is a better match with the embedded toolchain. Note that there are packages for [gcc-9](https://packages.ubuntu.com/mantic/gcc-9) up to [gcc-13](https://packages.ubuntu.com/mantic/gcc-13) on Mantic Minotaur.

A better `Dockerfile` would probably be:

```dockerfile
FROM ubuntu:mantic

RUN apt-get update && apt-get install -y \
                        make \
                        cmake \
                        gcc-12 \
                        gcc-arm-none-eabi \
                        gdb \
                        git

RUN ln -s -f gcc-12 /usr/bin/gcc
```

I should probably explain the link creation. The [`cmake` package](https://packages.ubuntu.com/mantic/cmake) has `gcc` in its list of recommended packages. Because we haven't used the option `--no-install-recommends`, it will install GCC 13 and create a link `/usr/bin/gcc` that points to `/usr/bin/gcc-13` (which is itself a link). The last line of the `Dockerfile` is here to overwrite this link. Oh! by the way: the `gcc-12` package won't create the link anyway, so we really need this line. 

### Select exact versions of the packages

As I said, each release of Ubuntu is quite conservative. It's very unlikely that the version of GCC in the `gcc-arm-none-eabi` in the repositories will change. Unlikely doesn't mean impossible (it could be a patch version). In case [we _really_ want to be sure to stick to an exact version](https://askubuntu.com/questions/92019/how-to-install-specific-ubuntu-deb-packages-with-exact-version), we can add the version of the package in the `apt-get` command and use `gcc-arm-none-eabi=15:12.2.rel1-1` instead of just `gcc-arm-none-eabi` (with Mantic Minotaur). The version string can be found in the title of [package's page](https://packages.ubuntu.com/mantic/gcc-arm-none-eabi) for each release of Ubuntu.

## Manual installations

It's possible that the Ubuntu packages won't provide with the versions we want. We can them fallback to manual installations. Let's say to we want the latest release of CMake (which is [3.29.0-rc2](https://github.com/Kitware/CMake/releases/tag/v3.29.0-rc2) at the moment), we can script the installation from GitHub in the `Dockerfile`.

Here is our new `Dockerfile`:

```dockerfile
FROM ubuntu:mantic

RUN apt-get update && apt-get install -y \
                        make \
                        gcc-12 \
                        gcc-arm-none-eabi=15:12.2.rel1-1 \
                        gdb \
                        git \
                        wget

RUN ln -s -f gcc-12 /usr/bin/gcc

RUN wget https://github.com/Kitware/CMake/releases/download/v3.29.0-rc2/cmake-3.29.0-rc2-linux-x86_64.sh
RUN chmod u+x cmake-3.29.0-rc2-linux-x86_64.sh
RUN ./cmake-3.29.0-rc2-linux-x86_64.sh --skip-license --include-subdir
ENV PATH="${PATH}:/cmake-3.29.0-rc2-linux-x86_64/bin"
```

We update the `PATH` [environment variable](https://stackoverflow.com/questions/27093612/in-a-dockerfile-how-to-update-path-environment-variable) so that `cmake` (and its associated binaries, such as `ctest`) is available from the path.

## Assessing our solution

In my opinion, this Docker-centered approach has a lot of benefits:

- It's much easier to set our computer when we join a new project. Install Git (and SVN or whatever we need), install Docker, clone the repository, and we're good to build!
- Everyone in the team has the same environment (including Jenkins or whatever CI service we use), assuming that everyone rebuilds the Docker image regularly.
- It is very easy to try a new version of a compiler (or any other tool) without smashing our current environment. We create new branch in our VCS, we update the `Dockerfile` to fetch the new compiler and we can work. We can then seamlessly switch back to our original branch and have our "normal" compiler back.
- On the same computer, we can work on several projects that use different versions of the same tool, without conflicts (eg: project A uses GCC 12 while project B uses GCC 13) thanks to the isolation that Docker offers.
- It's easier to rebuild old versions of the project. When we check out an old commit, we can rebuild a Docker image to get the tools that the project required back then, and then rebuild the project with it.

Of course, there is no magic in real life and every solution comes with some downsides: 

- We now have yet another tool in our environment. We have to learn it, we will run into Docker-specific issues, we have to maintain Docker code.
- It may be difficult to integrate some tools in the image if their installations aren't scriptable.
- We can't force people to regularly rebuild their local Docker image, so there may still be inconsistency among the team's machines.
- We never know if our `docker build` command will work tomorrow. We rely on the Docker registry to download the Ubuntu images, on the Ubuntu repository to download the compilers and so on. It's not really a daily issue for the HEAD of an active repository. However, we have no guarantee that a given `Dockerfile` we will still build in X months. That's why I have written "it's _easier_ to rebuild old versions of the project", instead of "you can rebuild any old versions of the project".

In this article, I have showed one way to use Docker. The repository contains a `Dockerfile` to create the build environment from scratch, making the repository somehow able to build itself. This is a nice approach because the repository is self-sufficient.

We can proceed differently to mitigate the last two downsides. For instance, we can manage the creations of the images separately from the project(s) that will use them.

## Treat Docker images as a separate project

Creating build environments can be considered as a separate project.

On the one hand, we create images with revisions. Instead of always tagging the images as "latest" with `docker build -t cmake-on-stm32 .`, we use explicit version numbers with `docker build -t cmake-on-stm32:0.1 .`, `docker build -t cmake-on-stm32:0.2 .`, etc. Each version of the image corresponds to a set of tools at known versions. If we need a new version of CMake (for instance), we update the `Dockerfile` and release image `0.3`.

On the other hand, we fetch the exact Docker image we need in our STM32-based project. We can create build scripts to wrap the commands shown in this article to force people to use the correct Docker image at any revision of the repository.

Sharing the Docker images can be manual, using [`docker image save`](https://docs.docker.com/reference/cli/docker/image/save/) + [`docker image load`](https://docs.docker.com/reference/cli/docker/image/load/) + a shared drive. The images can also be pushed to the DockerHub or self-hosted registry with [`docker image push`](https://docs.docker.com/reference/cli/docker/image/push/).

## Conclusion

In this episode, we have seen how we can leverage Docker to seamlessly set up and manage our build environment. Instead of having to install several applications on our computer, we now only need Docker to build the project. We have also seen there are several ways to include Docker in the process. It's up to you to find how you want to use it in your project. I really wished I could go back in time and try Docker on some of my previous projects! Tell me in the comments how you use/would like to use Docker for your projects!