STM32 Cross Development Containers
Build, flash and debug CubeMX STM32 projects inside containers, from VS Code.
Your machine needs Docker and, on Windows, usbipd-win. The compiler, OpenOCD and
GDB live in the image — nothing is installed on the host, and a project builds
the same way on every machine that opens it.
The problem it removes
A CubeMX project carries a Makefile with an absolute path to whoever generated
it, a linker script, and an assumption that the right arm-none-eabi-gcc is
somewhere on PATH. Getting a colleague to a working breakpoint usually costs an
afternoon, and the result differs from yours in ways nobody notices until the
firmware does.
Declare the project once in stm32cdc.json, and the tasks, launch
configuration, IntelliSense settings and dev container are generated from it.
Your own files are never edited — CubeMX rewrites its Makefile, linker script and
sources whenever it regenerates, so everything the tool needs arrives from
outside. Regenerating from the .ioc stays a no-op.
Requirements
|
|
|
| Docker |
Desktop on Windows, or the engine on Linux |
build and debug |
| usbipd-win |
Windows only |
debug only |
| STM32CubeMX |
on whoever creates or regenerates the project |
not needed to build |
| VS Code 1.85+ |
with the Dev Containers extension |
build and debug |
Docker and VS Code are needed by everyone. The other two are not: usbipd-win
matters only once you want to flash or debug, and CubeMX only on the machine the
project is generated on. A colleague who clones the repository and builds it
needs neither.
Docker must use the WSL 2 backend on Windows. Not a preference: usbipd
attaches the probe into the WSL 2 virtual machine, and the WSL 2 backend runs
the Docker engine in that same machine, which is the only reason /dev/bus/usb
inside a container has anything in it. With the Hyper-V backend the engine sits
in a separate VM, and the probe never reaches it — the build still works, the
debugger never finds an adapter. Nothing else about Docker is special: no
registry is required, since the image recipe ships inside the extension.
usbipd-win is what forwards the probe, and it is worth knowing how it
behaves before it surprises you.
winget install --exact dorssel.usbipd-win
Binding is per USB port, not per device, and the first use of each port
raises a UAC prompt — the extension asks for the elevation itself. Attachment is
lost on every replug, and whenever a Windows-side tool such as STM32CubeProgrammer
claims the device. It must be in place before the container starts: the
container takes a snapshot of the USB tree, so a probe attached afterwards is
invisible to it until it is rebuilt. Attach debug probe and Diagnose environment report all of this rather than leaving you to infer it. On Linux
none of it applies — the probe is already on /dev/bus/usb, and the container
mounts it directly.
STM32CubeMX is what makes a project in the first place, and this extension
deliberately generates nothing that CubeMX owns. Either toolchain in Project
Manager → Toolchain / IDE works, Makefile or CMake, detected rather than
configured. What it must leave behind is an .ioc (the device is read from
it), the build file for the mode you chose, a linker script and a startup file;
Diagnose environment names any of these that is missing and says which
generation mode produces it. Only the person regenerating from the .ioc needs
CubeMX installed — everyone else builds from what is already in the repository.
Dev Containers is declared as a dependency of this extension, so installing
this one installs it too. It is what turns the generated devcontainer.json
into a running container; without it that file is read by nothing.
macOS is not supported: Docker Desktop there has no USB passthrough into
containers, so debugging cannot work.
Getting started
- Get the images — run
STM32CDC: Build images from source. The recipe
ships inside this extension, so no registry is involved. It downloads a
Debian base, its packages and the Arm toolchain; a few minutes, once.
- Open a CubeMX project and run
STM32CDC: Initialize project. It reads
the device from the .ioc and the target from the build files, asks only for
the probe and the image tags, and offers to fetch a matching SVD.
- Plug in the probe and run
STM32CDC: Attach debug probe. On Windows the
first use of each USB port raises a UAC prompt — binding needs it.
- Open the project in its dev container, then
Ctrl+Shift+B to build and
F5 to debug.
When something misbehaves, run STM32CDC: Diagnose environment before anything
else. It checks the two dozen things that actually go wrong with this stack and
names the command that fixes each one.
Prefer pulling to building? Set stm32cdc.imageRegistry to where the images
are published and run STM32CDC: Pull images. Pulling is faster and gives
everyone a byte-identical image; building is the path that works with no
registry at all.
What it supports
- Both CubeMX project shapes — Makefile and CMake, detected rather than
configured.
- Arm GNU Toolchain 14.3, pinned by version and SHA256.
- ST-Link, CMSIS-DAP and J-Link, with the probe chosen explicitly when
several are connected, remembered per workspace, and by device rather than by
USB port — so the choice survives a replug.
- Peripheral registers, with the SVD matched to your device and fetched on
request; CubeMX does not ship one.
Commands
| Command |
|
Initialize project |
Writes stm32cdc.json for a CubeMX project |
Project settings |
A form over that manifest |
Init / fix project configuration |
Regenerates the VS Code and container files |
Build project · Clean build output |
Through the generated tasks |
Attach · Detach · Select debug probe |
usbipd, including the elevation prompt |
Add peripheral register view (SVD) |
Fetches and installs the right SVD |
Open project in its dev container |
Creates the container, checking the probe first |
Pull images · Build images from source |
Either way of getting them |
Clean up containers and volumes left behind |
Stopped dev containers accumulate one per project, and each retired image leaves its extension volume behind |
Diagnose environment |
Start here when something is wrong |
All of them are also in the activity bar, grouped by what you are doing, with
the state on screen rather than behind a command: which project is active, which
build driver it uses, whether an SVD is configured, whether the probe is
forwarded right now.
Settings
| Setting |
|
stm32cdc.imageRegistry |
Where released images live, e.g. ghcr.io/acme; enables pulling instead of building |
stm32cdc.toolchainTag |
Toolchain part of the image tag; the version part comes from the extension |
stm32cdc.probes |
vid:pid values treated as debug probes — CMSIS-DAP ids differ per vendor, so add yours |
stm32cdc.svdRepository · stm32cdc.svdRef |
Where SVDs come from, pinned to a commit |
stm32cdc.wslDistro |
Distribution used for the usbipd attach |
stm32cdc.showStatusBar · stm32cdc.refreshIntervalSeconds |
Status bar and how often probe state is polled |
stm32cdc.json has a JSON schema registered for that filename, so completion,
hover documentation and error checking work without a network round trip.
Unknown fields are rejected — the point is to catch a misspelled key, which
otherwise fails silently by being ignored.
Known limitations
- One window reload the first time a dev container is created. VS Code
installs the debug extensions after the extension host has started, and
devcontainer.json has no way to ask for anything else.
- An extension can be installed in the container and still disabled, because
enablement lives in your client profile, keyed by extension id, and no API can
change it. Use Enable (Workspace), which scopes the change to that folder.
- amd64 only — the Dockerfile uses the x86_64 toolchain tarball.
- Locally built images are not byte-identical across machines: only the
toolchain is pinned by checksum, the Debian packages float. Pull published
images when that matters.
How it works, briefly
The extension runs on the host, always — usbipd is a Windows program and the
Docker CLI talks to the local daemon, and neither is reachable from inside a dev
container. It builds nothing itself: builds go through the generated VS Code
task, debugging through Cortex-Debug, and configuration through a generator that
lives in the builder image.
That last point is what keeps one build path rather than two. A task runs
wherever the window is — on the host for a local folder, inside the container for
a remote one — so the same command works in both without knowing which it is in.
Licence
MIT.