Some of you may be using STM32 microcontrollers—they’re everywhere. Many Nucleo and Discovery boards are affordable and easy to get, and I bet some of you already own one. Using them with Zephyr is fairly straightforward, and if you followed the previous post, you’re pretty much ready to go. Still, this guide is dedicated just for you.

💡
I’ll assume you’ve read the previous post. If not, I recommend checking it out first—I intentionally skipped some explanations because the process is very similar to what we did with the Nordic nRF54L15 and JLink. I also encourage you to follow our series on Docker for additional context

Pull our docker container with zephyr, make a new directory and west init with using our template in Github

$ docker pull modularmx/zephyros:latest
$ mkdir myProject
$ cd myProject
$ docker run -it --rm -w /home/user/workspace --mount type=bind,src="$(pwd)",dst=/home/user/workspace modularmx/zephyros:latest
$ west init -m https://github.com/ModularMX/zephyr-template.git

Modify the manifest file changing hal_nordic for hal_stm32

name-allowlist:
  # placehere all the packages you want to import
  - cmsis
  - hal_stm32

west update and then type west boards to list all the stm32 supported boards, you can filtered using grep, for instance in my case I’m using an stm32h5

$ west update
...
$ west boards | grep "stm32h5"
weact_stm32h5_core
stm32h573i_dk 

Choose the board of your preference and modify the CMakeLists.txt, in my case I’m using a stm32h573dk that comes with a stlink and for that reason I’m going to use openocd to flash and/or debug

# set enviroemnt variables like the board nad the debug server
set(BOARD stm32h573i_dk) 
set(BOARD_FLASH_RUNNER openocd)

write some code in your main.c file, like the one below just for testing purposes

/* Include libraries */
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

/* Delay of 1000 ms */
#define SLEEP_TIME_MS 1000

/* Get the DeviceTree alias for the "led0" alias */
#define LED0_NODE DT_ALIAS( led0 )

/* Get pin specification (device, pin, flags) for LED0 from the Devicetree */
const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET( LED0_NODE, gpios );

int main( void )
{
    /* Configure led0 as output*/
    gpio_pin_configure_dt( &led0, GPIO_OUTPUT );

    while(1)
    {
        /* Toggle LED led0 */
        gpio_pin_toggle_dt( &led0 );
        /* Sleep for 1000ms (delay) */
        k_msleep( SLEEP_TIME_MS );
    }
    return 0;
}

build as usual to test everything is working as expected, and from here code anything you like

$ west build -p always app
...
-- Zephyr version: 4.1.0 (/home/user/workspace/deps/zephyr), build: v4.1.0
[141/141] Linking C executable zephyr/zephyr.elf
Memory region         Used Size  Region Size  %age Used
           FLASH:       40946 B         2 MB      1.95%
             RAM:        4376 B       256 KB      1.67%
           SRAM1:          0 GB       256 KB      0.00%
           SRAM2:          0 GB        64 KB      0.00%
           SRAM3:          0 GB       320 KB      0.00%
          EXTMEM:          0 GB        64 MB      0.00%
        IDT_LIST:          0 GB        32 KB      0.00%
Generating files from /home/user/workspace/build/zephyr/zephyr.elf for board: stm32h573i_dk

west flash with OpenOCD

The Zephyr container comes with an OpenOCD version bundled from the Zephyr SDK. We can try to use it like this, but it will fail miserably. Why? Because the OpenOCD version included is really outdated. If you’re working with an older board like a Nucleo F0, it probably won’t be a problem. But for newer boards, it definitely will

$ west flash
-- west flash: rebuilding
ninja: no work to do.
-- west flash: using runner openocd
-- runners.openocd: Flashing file: /home/user/workspace/build/zephyr/zephyr.hex
Open On-Chip Debugger 0.11.0+dev-00728-gb6f95a16c-dirty (2024-10-20-01:26)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
/home/user/workspace/deps/zephyr/boards/st/stm32h573i_dk/support/openocd.cfg:3: Error: Can't find target/stm32h5x.cfg
in procedure 'script' 
at file "embedded:startup.tcl", line 26
at file "/home/user/workspace/deps/zephyr/boards/st/stm32h573i_dk/support/openocd.cfg", line 3
FATAL ERROR: command exited with status 1: /opt/zephyr-sdk-0.17.0/sysroots/x86_64-pokysdk-linux/usr/bin/openocd 
-s /home/user/workspace/deps/zephyr/boards/st/stm32h573i_dk/support 
-s /opt/zephyr-sdk-0.17.0/sysroots/x86_64-pokysdk-linux/usr/share/openocd/scripts 
-f /home/user/workspace/deps/zephyr/boards/st/stm32h573i_dk/support/openocd.cfg 
-c 'gdb_report_data_abort enable' '-c init' '-c targets' -c 'reset init' 
-c 'flash write_image erase /home/user/workspace/build/zephyr/zephyr.hex' -c 'reset run' -c shutdown

The solution is to build OpenOCD from source. In fact, ST provides its own fork that already includes support for their latest boards. We’ll handle this by creating a new Dockerfile. The version we’re going to build will be dedicated solely to STM32 devices that use the ST-Link interface.

# Fetch the modular image for Zephyr OS as baseline 
FROM modularmx/zephyros:latest

# switch to "root" user
USER root

# install all the necessary dependencies
RUN apt-get update && apt-get install -qqy wget sudo git libusb-1.0-0-dev \
	&& apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Download and install the OpenOCD stm32 flavor software package 
RUN git clone --recursive https://github.com/STMicroelectronics/OpenOCD.git \
    && cd OpenOCD \
    && ./bootstrap \
    && ./configure --enable-stlink \
    && make \
    && make install \
    && cd .. \
    && rm -rf OpenOCD

# set sudo priviledge to user (terrible idea, i know)
RUN usermod -aG root user

# siwtchback to "user" user
USER user

Build the container with a proper name (don’t forget this has to be done outside the previous running container)

$ docker build -t west_openocd .

Run the container with the shared directory and access to our USB devices

$ docker run --rm -it -w /home/user/workspace --mount type=bind,src="$(pwd)",dst=/home/user/workspace --device=/dev/bus/usb:/dev/bus/usb west_openocd

before flashing we need to indicate west we are using a different OpenOCD version installed on a different location inside our container, we can do this using adding teh following lines in the CMakeLists.txt file, build again.

set(OPENOCD /usr/local/bin/openocd) 
set(OPENOCD_DEFAULT_PATH /usr/local/openocd/share/openocd/scripts)

Inside your new container type west flash, and enjoy your led achievement

$ west flash
-- west flash: rebuilding
ninja: no work to do.
-- west flash: using runner openocd
-- runners.openocd: Flashing file: /home/user/workspace/build/zephyr/zephyr.hex
Open On-Chip Debugger 0.12.0-00033-g0de861e21 (2025-08-21-23:16) [https://github.com/STMicroelectronics/OpenOCD]
Licensed under GNU GPL v2
...
shutdown command invoked
user@bed3aae5e989:~/workspace$ 

Debugging with VSCode

There’s no way to use Ozone with OpenOCD (although some STM32 boards can be reprogrammed with a J-Link). Instead, we’ll use VS Code to debug our code. There are other alternatives, but we won’t cover them here. To make things easier, we’ll use Dev Containers. Create a new file called compose.yml in the root directory of your project.

services:
  zephyr:           # container name
    image: west_openocd  # image with zephyr and openocd
    working_dir: /home/user/workspace
    volumes:           # mount directory
      - type: bind
        source: ./
        target: /home/user/workspace
    devices:                # device mapping to usb ports
      - /dev/bus/usb:/dev/bus/usb
    command:        # keep the container running
      sleep infinity

Also create the corresponding devcontainer file in the same directory

{
    // Name of the dev container
    "name": "devtools",     
    // compose file to run containers     
    "dockerComposeFile": "compose.yml",
    // indicate the container to run from the compose file
    "service": "zephyr",
    // indicate the working directory inside the container
    "workspaceFolder": "/home/user/workspace",
    // set bash as the default shell    
    "postStartCommand": "/bin/bash",

    // set some usefull vs code configuration only exsiting in our contianer
    "customizations": {
        "vscode": {
            // Add some extensions
            "extensions": [
                "ms-vscode.cpptools",
                "trond-snekvik.gnu-mapfiles",
                "twxs.cmake",
                "marus25.cortex-debug"
            ],
            // Set a fancy themes
            "settings": {
                "workbench.colorTheme": "One Dark Pro Night Flat"
            }
        }
    }
}

Of course, we’ll also create a launch.json file with the following configuration. Just modify the device and configFiles options to match the STM32 board or chip of your choice. And remember—you can always check out our post about debugging with VS Code for more details on this configuration

{
    "version": "1.12.1",
    "configurations": [
        {
            "type": "cortex-debug",
            "request": "launch",
            "name": "Debug (OpenOCD)",
            "servertype": "openocd",
            "interface": "swd",
            "cwd": "${workspaceRoot}",
            "runToEntryPoint": "main",
            "executable": "${workspaceRoot}/build/zephyr/zephyr.elf",
            "device": "STM32H573I",
            "configFiles": [
                "board/st_nucleo_h5.cfg"
            ],
            "armToolchainPath": "/opt/zephyr-sdk/arm-zephyr-eabi/bin",
            "toolchainPrefix": "arm-zephyr-eabi"
        }
    ]
}

Run your devcontainer and launch your debug session as usual using the plugin cortex-debug, easy and simple

Using a separate container

However, it’s much better to reuse our Modular-MX OpenOCD container, which is built specifically for STM32. We can orchestrate it separately with Compose and integrate it with VS Code—just like we did earlier with J-Link in the previous post

services:
  server:      # container name 
    image: modularmx/openocd:latest
    ports:          # port mapping
      - 3333:3333
    devices:        # device mapping to usb ports
      - /dev/bus/usb:/dev/bus/usb
    networks:
      static_net:         # use network static_net
        ipv4_address: 172.25.0.2  # assing ip address to container
    command: openocd -f board/st_nucleo_h5.cfg -c "bindto 172.26.0.2"
    
  zephyr:      # container name 
    image: modularmx/zephyros:latest
    working_dir: /home/user/workspace
    volumes:        # volume mapping
      - type: bind
        source: ./
        target: /home/user/workspace
    depends_on:
      - server
    networks:
      static_net:         # use network static_net
        ipv4_address: 172.25.0.3  # assing ip address to container
    command:                # keep the container running
      sleep infinity
      
networks:
  static_net:          # network name to be used by containers
    driver: bridge    # network driver
    ipam:     
      config:         # network configuration
        - subnet: 172.25.0.0/16 # network subnet
          gateway: 172.25.0.1   # network gateway

In the launch.json file just set the servertype to external and write the IP address of the OpenOCD container assigned in the compose file (this last part is really important)

{
    "version": "1.12.1",
    "configurations": [
        {
            "type": "cortex-debug",
            "request": "launch",
            "name": "Debug (OpenOCD)",
            "servertype": "external",
            "gdbTarget": "172.25.0.2:3333",
            "interface": "swd",
            "cwd": "${workspaceRoot}",
            "runToEntryPoint": "main",
            "executable": "${workspaceRoot}/build/zephyr/zephyr.elf",
            "svdFile": "${workspaceRoot}/deps/modules/hal/nordic/nrfx/mdk/nrf54l15_application.svd",
            "armToolchainPath": "/opt/zephyr-sdk/arm-zephyr-eabi/bin",
            "toolchainPrefix": "arm-zephyr-eabi"
        }
    ]
}

Launch the devcontainer and the debug session as usual (notice the .devcontainer.json file remains the same) and that’s it, now you have a full functional set-up for your stm32 favorite boards. If you want to add a serial port support you can read the previous post, the process is pretty much the same