So far, we’ve been using Arch Linux images as the base to create our development images with the ARM GNU compiler and OpenOCD. The reason for this choice is that it's quite easy to install all the tools we need, thanks to the excellent community behind Arch Linux. However, there is a problem: the resulting images are quite large. Just take a look at our test image—it's 3.7GB!

[diego@DESKTOP-VD3GS75 test]$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
testimg      latest    9c5452c97456   5 hours ago   3.17GB
archlinux    base      0783ec448814   6 days ago    451MB

Instead of using Arch Linux, we’re going to try Ubuntu, a well-known distribution that’s familiar to many. Additionally, its Docker image is quite lightweight. If you're not familiar with Linux, you should understand that there are several ways to install programs. The most common method is using the distribution’s package manager. For example, Arch uses pacman, Ubuntu uses apt, Fedora uses yum, and so on.

💡
Some of you might say that Alpine Linux is even lighter, but the issue is that this distribution doesn't come with glibc, and it can be a pain to install some of the tools!
# Fetch a new image from archlinux
FROM ubuntu

#install build tools from our stm32g0 microcontroller and openocd to flash our device
RUN apt-get update && apt-get -y install make openocd wget xz-utils

#create and change directory to app
WORKDIR /app

# Create a volume using the app directory
VOLUME /app

There’s a problem here: we can’t install the arm-none-eabi toolchain using apt, because it's not available in the official repositories. But don’t worry —on Linux, we can manually install packages. We can download the ARM GNU toolchain from its official website and place it in any folder for later use. To do this, we can use wget to download the toolchain from ARM's website, then decompress the files and add the path to our PATH environment variable.

Additionally, we need to install some extra libraries to run the toolchain. These can also be downloaded using wget, and then we can install the .deb packages manually using dpkg.

# Fetch a new image from archlinux
FROM ubuntu

#install build tools from our stm32g0 microcontroller and openocd to flash our device
RUN apt-get update && apt-get -y install make openocd wget xz-utils

# libtinfo5 and libncurses5 are needed to run our arm toolchain, download from the archive repo and install
RUN wget http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses/libtinfo5_6.4-2_amd64.deb && \
    wget http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses/libncurses5_6.4-2_amd64.deb && \
    wget http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses/libncursesw5_6.4-2_amd64.deb && \
    dpkg -i ./libtinfo5_6.4-2_amd64.deb && dpkg -i ./libncurses5_6.4-2_amd64.deb && dpkg -i ./libncursesw5_6.4-2_amd64.deb && \
    rm libtinfo5_6.4-2_amd64.deb libncursesw5_6.4-2_amd64.deb libncurses5_6.4-2_amd64.deb

# Download the ARM GNU toolchain using wget, decompress, and set its route to PATH variable
RUN wget https://developer.arm.com/-/media/Files/downloads/gnu/13.3.rel1/binrel/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi.tar.xz && \
    tar -xvf arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi.tar.xz -C /home/ubuntu && \
    rm arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi.tar.xz && \
    echo 'export PATH=/home/ubuntu/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi/bin:$PATH' >> ~/.bashrc

#create and change directory to app
WORKDIR /app

# Create a volume using the app directory
VOLUME /app

After the previous part we now have a new image to build

$ docker build -t testmin .

Take a look and compare the size of the new image with the previous one built with Arch—it's almost three times smaller!

[diego@DESKTOP-VD3GS75 test]$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
testmin      latest    9993aa2ce494   11 minutes ago   1.16GB
testimg      latest    9c5452c97456   26 hours ago     3.17GB
archlinux    base      0783ec448814   7 days ago       451MB
ubuntu       latest    59ab366372d5   3 weeks ago      78.1MB

Of course, you can run both images to compile and debug, just like we did with the previous images based on Arch. I’ll leave that to you. What I wanted to show is that sometimes it won’t be possible to rely on the distro’s package manager to install all the packages needed for your development environment. The way to install those extra packages can vary significantly—it doesn't depend on Docker but more on the underlying Linux system. So, some prior knowledge of Linux package management is necessary.

Here’s an example: when you have a package on your local machine and need to copy it into your Docker image for installation. We’ll copy the .deb files—libtinfo5_6.4-2_amd64.deb, libncursesw5_6.4-2_amd64.deb, and libncurses5_6.4-2_amd64.deb—from our local ~/Downloads directory to the Docker container’s home directory. Once copied, we can install them using dpkg.

# copy the deb packahes from your computer to docker image
COPY ~/Downloads/*_amd64.deb /home

# libtinfo5 and libncurses5 are needed to run our arm toolchain, download from the archive repo and install
RUN dpkg -i /home/libtinfo5_6.4-2_amd64.deb && dpkg -i /home/libncurses5_6.4-2_amd64.deb && dpkg -i /home/libncursesw5_6.4-2_amd64.deb && \
    rm /home/libtinfo5_6.4-2_amd64.deb /home/libncursesw5_6.4-2_amd64.deb /home/libncurses5_6.4-2_amd64.deb

Building the image needed to start developing can be challenging in some cases, especially in embedded systems where many tools are not readily available or require special setups. However, once it’s done, it becomes quite easy to share the image/container with other developers.

Using Multiple Images

So far, we've been using a single image to create containers for different purposes. However, this is not the most common approach with Docker. Continuing like this could result in a very complex Dockerfile and large, heavy images (though this will depend on the tools we use, of course). Instead, let's create three separate images for three different containers: one for OpenOCD, one for building our software, and one for debugging our application.

OpenOCD Image

For the OpenOCD image, we will use the lightweight Alpine Linux distribution. This image will be dedicated to running OpenOCD only, and the debug server should start as soon as the container is launched. It should remain active, waiting for connections. This image does not need to share the application directory.

Create a new file called openocd.dockerfile. Our container will be named openocd_server, and this is important for later steps.

# Fetch a new image from archlinux
FROM alpine

# install build tools from our stm32g0 microcontroller and openocd to flash our device
RUN apk add --no-cache openocd

# run openocd as soon the contanier start and bind to the same container IP address 
# using its name
ENTRYPOINT [ "openocd", "-f", "board/st_nucleo_g0.cfg", "-c", "bindto open_server" ]

Build Image

The second image will use Ubuntu and include the necessary tools for building our software every time we modify our code. This image will share our working directory from the host machine with a directory called app inside the container. Let’s name this Dockerfile build.dockerfile.

# Fetch a new image from archlinux
FROM ubuntu

#install build tools from our stm32g0 microcontroller and openocd to flash our device
RUN apt-get update && apt-get -y install make wget xz-utils

# libtinfo5 and libncurses5 are needed to run our arm toolchain, download from the archive repo and install
RUN wget http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses/libtinfo5_6.4-2_amd64.deb && \
    wget http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses/libncurses5_6.4-2_amd64.deb && \
    wget http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses/libncursesw5_6.4-2_amd64.deb && \
    dpkg -i ./libtinfo5_6.4-2_amd64.deb && dpkg -i ./libncurses5_6.4-2_amd64.deb && dpkg -i ./libncursesw5_6.4-2_amd64.deb && \
    rm libtinfo5_6.4-2_amd64.deb libncursesw5_6.4-2_amd64.deb libncurses5_6.4-2_amd64.deb

# Download the ARM GNU toolchain using wget, decompress, and set its route to PATH variable
RUN wget https://developer.arm.com/-/media/Files/downloads/gnu/13.3.rel1/binrel/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi.tar.xz && \
    tar -xvf arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi.tar.xz -C /home/ubuntu && \
    rm arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi.tar.xz && \
    echo 'export PATH=/home/ubuntu/arm-gnu-toolchain-13.3.rel1-x86_64-arm-none-eabi/bin:$PATH' >> ~/.bashrc

#create and change directory to app
WORKDIR /app

# Create a volume using the app directory
VOLUME /app

Debugger Image

For the third image, let's go with Arch Linux—because why not? The only tool we need to install is GDB for our ARM microcontrollers. Just like we did with the OpenOCD image, we will configure the container to start the debugger as soon as it's launched. Name this Dockerfile debug.dockerfile.

# Fetch a new image from archlinux
FROM archlinux:base

#install build tools for our stm32g0 microcontroller, and openocd to flash our device
RUN pacman -Sy --noconfirm arm-none-eabi-gdb

#create and change directory to app
WORKDIR /app/project

# Create a volume using the app directory
VOLUME /app/project

# run openocd as soon the contanier start and bind to the same container IP address 
ENTRYPOINT [ "arm-none-eabi-gdb" ]

Time to build our three images, we are putting all of the three docker files in the same directory and to build each of them we need to specify the dockerfile using the flag -f <filename.dockerfile>, just like this:

$ docker build -f openocd.dockerfile -t openocd .
$ docker build -f build.dockerfile -t buildm .
$ docker build -f debug.dockerfile -t debug .

This is going to generate three new images we can list with the images command, look how light weight is the one with openocd

$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
debug        latest    2d822743bdd2   4 seconds ago        641MB
buildm       latest    731d92bb65a3   41 seconds ago       1.15GB
openocd      latest    db4268670415   About a minute ago   12.8MB
archlinux    base      ad78b172bfc7   2 days ago           452MB
ubuntu       latest    59ab366372d5   3 weeks ago          78.1MB
alpine       latest    91ef0af61f39   8 weeks ago          7.79MB

Time to run each of them, starting with the one with openocd our debug server, just notice we use the flag -d on this one, because the container will run in the background, we are not going to see it, but is there connected to our board, trust me.

$ docker run -d --rm --device=/dev/bus/usb:/dev/bus/usb -p 3333:3333 --network mynet --name open_server openocd

For the second one, notice we are not connecting this container to mynet network because there is no necessary (but maybe is a good practice to put all of them in the same network, i don’t know). Build your project once the container is running

$ docker run -it --rm -v "$(pwd)":/app buildm
# cd project
# make

I’m assuming you clone the template project as usual and your working directory looks like this, I’m also assuming you will run all of them from this directory

$ tree -L 1
.
├── buildm.dockerfile
├── debug.dockerfile
├── open.dockerfile
└── project

2 directories, 3 files

Finally, for the third container, which will run our debugger, it needs to be connected to the same network as the first container (OpenOCD). This is because the debugger must connect to OpenOCD using port 3333. Additionally, we need to run it in interactive mode.

$ docker run -it --rm -v "$(pwd)":/app --network mynet debug

At this point, we have three containers running, each from a different image. We use the second container to build our project with make, while the first container is running OpenOCD, with a connection open on port 3333, waiting for GDB to connect.

$ docker ps                                              
CONTAINER ID   IMAGE     COMMAND                  CREATED              STATUS              PORTS     NAMES
f95b4cc44b85   debug     "arm-none-eabi-gdb"      16 seconds ago       Up 16 seconds                 zealous_newton
511f1c8b08c6   buildm    "/bin/bash"              About a minute ago   Up About a minute             relaxed_raman
929aed677d64   open      "openocd -f board/st…"   About a minute ago   Up About a minute             open_server

On container debug (the third one) lets load our program we build with the second one, just input the following instruction target extended-remote open_server:3333 to connect with openocd

$ docker run -it --rm -v "$(pwd)":/app --network mynet debug
...
(gdb) target extended-remote open_server:3333
Remote debugging using open_server:3333
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x080002f0 in ?? ()

Yeah! We’re connected to our board. Now, simply indicate the ELF binary to load with the file command, flash the memory using load, and then apply a reset with mon reset halt. After that, the sky's the limit—start debugging!

(gdb) file project/Build/temp.elf
A program is being debugged already.
Are you sure you want to change the file? (y or n) y
Reading symbols from project/Build/temp.elf...
(gdb) load
Loading section .isr_vector, size 0xbc lma 0x8000000
Loading section .text, size 0x8a8 lma 0x80000bc
Loading section .init_array, size 0x4 lma 0x8000964
Loading section .fini_array, size 0x4 lma 0x8000968
Loading section .data, size 0xc lma 0x800096c
Start address 0x080002f0, load size 2424
Transfer rate: 7 KB/sec, 484 bytes/write.
(gdb) mon reset halt
Unable to match requested speed 2000 kHz, using 1800 kHz
Unable to match requested speed 2000 kHz, using 1800 kHz
[stm32g0x.cpu] halted due to debug-request, current mode: Thread 
xPSR: 0xf1000000 pc: 0x080002f0 msp: 0x20024000
(gdb) 

If you change your code and rebuild it, you can reflash the board by using the load command and then applying another reset with mon reset halt.

You might be wondering, Why didn’t we use the make debug target from the Makefile like in the previous post? Well, if we did that, GDB would automatically run the commands in the .gdbinit file, assuming that OpenOCD is already running. But I didn’t want to assume that for this example. There are many ways to configure the debugger’s startup using GDB scripting, but I’ll leave that to you as an exercise. This is primarily meant to teach you about containers, not GDB.

Updating the Debugger

Why do we need up to three containers? Well, in reality, you may not need all of them—or maybe you will. It all depends on how your team decides to structure the workflow. This approach offers a lot of flexibility, but at the cost of maintaining multiple images and containers. Sure, maybe this example doesn’t require all this complexity, but the goal is to teach you as much as possible about Docker, and allow you to decide how to implement your own workflow.

Now, let's modify your Dockerfile to include Python, pipx, and gdbgui, a web-based visual frontend for the GDB debugger.

# Fetch a new image from archlinux
FROM archlinux:base

#install build tools from our stm32g0 microcontroller and openocd to flash our device
RUN pacman -Sy --noconfirm python python-pipx arm-none-eabi-gdb

#install gdbgui
RUN pipx install gdbgui

#create and change directory to app
WORKDIR /app

# Create a volume using the app directory
VOLUME /app

# run gdbgui as soon the contanier starts 
ENTRYPOINT [ "/root/.local/bin/gdbgui", "--gdb-cmd=/usr/bin/arm-none-eabi-gdb", "-r" ]

Build again with the same name and run the container as usual to see now there is a web server running in the container, take a look at the ip address and port, and expose the port 5000

$ docker build -f debug.dockerfile -t debug .
...
$ docker run -it --rm -v "$(pwd)":/app --network mynet -p 5000:5000 debug          
Warning: authentication is recommended when serving on a publicly accessible IP address. See gdbgui --help.
View gdbgui at http://172.18.0.3:5000
View gdbgui dashboard at http://172.18.0.3:5000/dashboard
exit gdbgui by pressing CTRL+C
 * Serving Flask app 'gdbgui.server.app'
 * Debug mode: off

Open you browser and type the IP address and the port, this will display a page with gdbgui running, in my case is http://172.17.0.3:5000

To run our program we need to connect to openocd, and basically input the same instructions as before but this time in the gdbgui terminal, (bottom left corner of the screen), I leave here the list of instructions.

(gdb) target extended-remote open_server:3333
...
(gdb) file project/Build/temp.elf
...
(gdb) load
...
(gdb) mon reset halt
...
(gdb) break main
...
(gdb) continue
Continuing.

Breakpoint 1, main () at app/main.c:27
27          HAL_Init( );

For Windows and WSL users, you’ll need to use the IP address assigned to your WSL instance. To find the address, simply run the command ip addr show.

$  [diego@DESKTOP-VD3GS75 ~]$ ip addr show
...
...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:15:5d:04:a4:15 brd ff:ff:ff:ff:ff:ff
    inet 172.26.220.130/20 brd 172.26.223.255 scope global eth0
...
...

On your windows explorer use the the address to display gdbgui, in my case is http://172.26.220.130:5000