Getting started developing/debugging on embedded STM32 ARM microcontrollers with Visual Studio Code on Windows
If you're only interested in the setup process, skip to Step 1 below and ignore my rantings.
I spent a lot of time over the past few years with embedded projects in various IDE's from different silicon companies, all based on Eclipse, and all equally painful and horrible to use. Eclipse is an ancient and stagnant platform, filled with bugs, lacking many features some would consider mandatory in a modern IDE. The process of writing code in Eclipse drains every drop of joy out of the process, like muddy water squeezed out of a wet rag.
I couldn't take it anymore. Something had to change. If I couldn't have code completion, fast intelligent code navigation or even a working visual debugger, why even use an IDE in the first place?
I was ready to dive straight back into the 1990's, figuring I'd get by with using command line tools and a simple text editor. But then...
Visual Studio Code isn't perfect. It's built on Electron, it's non-native and can feel slow and unresponsive, especially with large projects. But it's a whole universe better than any solution based on Eclipse that I've seen, so after having used VSCode for a few months to write native C/C++ code on multiple platforms - and while not thrilled with it, at least it was better than the usual suspects - an attempt to make it work as an embedded development platform felt like it was worth a shot, if only to keep my sanity.
Honestly, I'd have settled for building and debugging using a command line and just using VSCode as a fancy text editor. Anything to be rid of Eclipse.
So I sat down over a weekend, spending many long and arduous hours figuring out the best and cleanest way to get support for STM32 microcontrollers with decent debugging, disassembly and hardware inspection in Visual Studio Code. This document details the process I finally ended up with to set up a working environment which seems to satisfy my needs. So far it's been working well, but YMMV.
There's probably a lot that can be improved. I haven't tested it thoroughly with any larger projects, but it's hopefully a good starting point for people who are ready to take the leap. I welcome any and all additions or improvements to the process and will update this document accordingly.
Some software choices or methods may seem a bit arbitrary, so here's a short breakdown of steps I attempted which ultimately failed, and it led me to the setup I ended up with.
I like to keep every OS install as segregated as possible from other OS'es. A Mac is a Mac, a Windows PC is a Windows PC and so on. So, I initially wanted to run everything natively on Windows without having to install a GNU environment. The first obstacle to this was the need to have make
installed. It's not widely available as a Windows binary from trusted sources, and once you've started installing some GNU tools on Windows, others tend to start creeping in. The final blow came when I realized that the Makefiles generated by the CubeMX software require rm
to do cleanup. I guess it would be possible to just edit the Makefile to use the Windows del
command, but I didn't want to introduce necessary editing steps post-code generation, where my project will fail to build unless I've manually changed some gratuitous line in a file. And just sticking an rm
binary inside my Windows install just to push an already stretched solution a bit further didn't sit right at all.
I then tried using WSL2 running an Ubuntu install, and quickly realized that ARM's GDB package has been deprecated on Ubuntu in favour of gdb-multiarch
. This would be fine, if it wasn't for the fact that the Cortex-Debug extension in VSCode absolutely will not look for any other GDB executable than the ARM-provided one. Weell, that's half true. There is a hacky, hidden config setting which allows you to point to any executable, but it's neither supported nor documented and I'd prefer my environment doesn't mysteriously break when that setting inevitably is removed or changed.
It's quite possible that running all the required GNU tools (except OpenOCD) from within WSL can work, but it likely depends on which distribution is installed, which package manager and sources are available and so on.
After I finally gave in and realized that a GNU environment was a necessary evil, I'd have preferred to also run OpenOCD inside of it, but there are apparently some major hoops to jump through in order to make MinGW64 communicate with hardware USB devices. It seems to be possible, based on my scouring of some posts regarding USB/IP and whatnot, but it did not feel worth the hassle compared to just running OpenOCD natively.
- This was all tested and working on Nov 16th 2020.
- There are alternatives to MSYS2/MinGW64. You can use whichever GNU environment you're most comfortable with, but YMMV. This is what worked best for me. MSYS2 is well maintained and provides recent versions of most or all utilities you'll need for this specific application, so that's what I recommend going with, and it's what you'll need if you want to follow along with the steps outlined below.
Phew! With all that out of the way, without further ado...
- Visual Studio Code
- ST Electronics' CubeMX configuration software
- OpenOCD, the on-chip-debugging GDB server (Get it from gnutoolchains.com - the official site has depressingly outdated builds)
- MinGW64/MSYS2 - GNU environment for Windows
Install the top three software packages first. Put OpenOCD somewhere accessible, you'll need the path to it in a bit.
(Sadly, you can't use the OpenOCD distribution available through MSYS2 because it isn't able to communicate with USB devices on the Windows host from inside the GNU environment. This is why OpenOCD has to be installed and run natively on Windows.)
Next, install MSYS2. Just install the 64-bit version, there's no reason to use 32-bit in 2020 unless you're on ancient hardware or a developer working on MSYS2 itself. Follow the instructions in the above link. Make sure you run the 64-bit version of the MinGW64 shell so you get the correct path variable for 64-bit binaries. If you checked the option to add shortcuts while installing, it should be in your Start Menu, otherwise open a Windows command prompt:
>> C:\msys64\msys2_shell.cmd -mingw64
This should open a MinGW64 Bash shell. Start by getting your MSYS2 install up-to-date with the latest packages:
$ pacman -Syuu
Close the shell and open it again, then run the above command one more time. Repeat these steps until no more updates are available.
When your system is up to date, proceed to installing GNU Make:
$ pacman -S make
This should also install a lot of dependencies including some we'll need later on.
Now it's time to install the ARM GNU Embedded Toolchain:
$ pacman -S mingw64-w64-x86_64-arm-none-eabi-gcc
You'll also need the ARM-specific version of the GDB debugger:
$ pacman -S mingw64-w64-x86_64-arm-none-eabi-gdb
The GDB debugger is built with Python support, but some required files are missing from the ARM binary distribution, so you'll need to install the main GDB package to avoid errors:
$ pacman -S mingw64-w64-x86_64-gdb
Now you have a choice of either using compiledb
or bear
to generate the compile_commands.json
file which VSCode needs to keep IntelliSense and code completion up to date. compiledb
requires Python, which should already be on your system since GDB requires it. bear
on the other hand must be built from source.
I'll be going the Python route. If you're feeling adventurous, you can find the bear
github repo here.
Once built, both options work well and the syntax is more or less the same, so it won't affect how you configure your projects.
We'll need to install pip
first, then we can install the compiledb
script:
$ pacman -S python-pip
$ pip install compiledb
And that's it! You should now be set up to run the complete ARM GNU toolchain in a MinGW64 shell and - more importantly - soon from VSCode.
Create a new project in CubeMX and configure it to match your hardware application. I won't go in depth on how to configure your STM32 project, there are guides for that, but make sure you select the option to only copy necessary files
and set the toolchain to Makefile
.
Select a location where you want your project files to live, and press Generate Code when you're done.
In this folder, you should now have a pair of folders called Core/
and Drivers/
with boilerplate code and the STM32 HAL
and low-level libraries; a Makefile
; a .s
file with ISR vector tables and code for booting the microcontroller; a .ld
file
which contains the linker script and finally the CubeMX project file for making changes to your hardware config.
Start up Visual Studio Code. You'll need to install the following two extensions:
- C/C++ by Microsoft
- Cortex-Debug by marus25
Open your project folder in VSCode. First, we need to override VSCode's integrated shell setting to launch Bash instead of the Windows command prompt or PowerShell. It's a good idea to do this at the workspace level, since you might not want this as your default shell for other, non-embedded projects.
Press F1 and type "save workspace" to save your workspace as a file. Save it in your project folder and open it for editing in VSCode.
We'll override both the interactive shell
and automationShell
entries to run MinGW64 so that we can use the GNU toolchain for
both automated and interactive tasks.
Here we run into another issue: We can't use the preferred msys2_shell.cmd
to open a MinGW64 terminal, because VSCode automatically assumes it's a Windows terminal and adds parameters that will break automated tasks. To get around this, we'll have to call bash.exe directly - VSCode apparently has separate code paths for some specific shell executable filenames (yes, this is profoundly idiotic, but hey). For this to work, we need to provide the shell with the proper environment variables.
While we're here, we'll add paths to the tools Cortex-Debug needs as well.
When you're done editing your .code-workspace
file it should look something like this:
{
"folders": [
{
"path": "."
}
],
"settings": {
"terminal.integrated.automationShell.windows": "C:/msys64/usr/bin/bash.exe",
"terminal.integrated.shell.windows": "C:/msys64/usr/bin/bash.exe",
"terminal.integrated.shellArgs.windows": [
"--login", "-i"
],
"terminal.integrated.env.windows": {
"MSYSTEM": "MINGW64",
"CHERE_INVOKING": "1",
"PATH": "/bin;/usr/bin;/mingw64/bin;/usr/local/bin",
},
"cortex-debug.openocdPath": "C:/Program Files (x86)/openocd/bin/openocd.exe",
"cortex-debug.armToolchainPath": "C:/msys64/mingw64/bin",
"cortex-debug.armToolchainPrefix": "arm-none-eabi"
}
}
The MSYSTEM
and CHERE_INVOKING
variables are to make sure the integrated shell opens as intended inside the project folder and not in your user home.
Open a new integrated terminal (ctrl-`) and you should now be in a MinGW64 shell inside your project folder. Let's try building the project:
$ make all
If there are no errors, you should now have a build/
folder under your project root with a set of object files and binaries.
Next, we'll set up VSCode to give you IntelliSense and code completion etc. Using compiledb
, we can make sure that VSCode knows about changes to the file structure without keeping it updated in two places. Changes to the Makefile will automatically propagate to your IDE environment.
Invoke compiledb
with the make
command as an argument:
$ compiledb -n make
The -n
flag tells compiledb
to not build and just generate the command file.
You should now have a compile_commands.json
in your folder, containing the build commands for every file in your project.
Press F1 and type "reload window". Press enter.
After a few seconds, VSCode should ask if you want to use the compile_commands.json
file to auto-configure IntelliSense. Press Yes.
(If VSCode doesn't ask, just browse around your code for a bit and it should pop up. If it still doesn't, you can generate the C/C++ properties file yourself by pressing F1, typing "edit config" and selecting C/C++: Edit Configurations (JSON)
)
A .vscode
folder should now have been created in your project root. Inside, there should be a file called c_cpp_properties.json
. We'll need to change some of the settings in this file.
To start with, change compilerPath
to "c:/msys64/mingw64/bin/arm-none-eabi-gcc.exe"
, or whatever the path to your ARM GCC compiler is. Next, change intelliSenseMode
to "gcc-arm"
and set the C/C++ standards to your preference. For this project, I'll use "c99"
and "c++11"
but it's really down to your own taste and project requirements.
We can remove the windowsSdkVersion
entry completely, and include paths are provided by compile_commands.json
so we can remove those too.
When you're done, your c_cpp_properties.json
file should look a bit like this:
{
"configurations": [
{
"name": "STM32",
"compilerPath": "c:/msys64/mingw64/bin/arm-none-eabi-gcc.exe",
"cStandard": "c99",
"cppStandard": "c++11",
"intelliSenseMode": "gcc-arm",
"compileCommands": "${workspaceFolder}/compile_commands.json"
}
],
"version": 4
}
We'll use OpenOCD with the Cortex-Debug VSCode extension to upload and debug code running on the STM32 hardware. We've already configured the paths that Cortex-Debug needs to access the compiler and OpenOCD, so next we'll create a launch configuration to start debugging automatically.
Press F1 and type "launch" to create a launch.json
file. Select Cortex Debug
as the launch environment.
Open the launch.json
file, which you should now find inside the .vscode
folder in your workspace root.
Change the executable
entry to point to your executable. Change the servertype
entry to "openocd"
.
OpenOCD will need some configuration files, depending on what type of hardware and debugger/programmer you're using. The configuration files should be located in your OpenOCD install folder, under share/openocd/scripts
. You'll need to provide an interface and a target config. Those are available in their corresponding subfolders.
For my interface, I'm using an ST-Link programmer, so I'll use interface/stlink.cfg
. My target is an STM32F446RE
µC, so I'll choose target/stm32f4x.cfg
.
There are also some compound configs in boards/
if you're using an evaluation board with a built-in programmer like the STM32-Nucleo line, etc.
When you're done, your launch.json should look something like this:
{
"version": "0.2.0",
"configurations": [
{
"name": "Cortex Debug",
"cwd": "${workspaceRoot}",
"executable": "./build/name-of-your-binary.elf",
"request": "launch",
"type": "cortex-debug",
"servertype": "openocd",
"configFiles": [
"interface/stlink.cfg",
"target/stm32f4x.cfg"
]
}
]
}
Finally, we need to create tasks to build the project. Press F1, type "task" and select Configure Default Build Task
.
Press enter again to create the tasks.json
file, select Others
from the list of options.
VSCode will generate a default task for you. Let's change it, and add two more tasks, so that the file looks a bit like this:
{
"version": "2.0.0",
"tasks": [
{
"label": "Make all",
"type": "shell",
"command": "compiledb make all",
"problemMatcher": "$gcc",
"group": "build",
},
{
"label": "Make clean",
"type":"shell",
"command": "make clean",
"problemMatcher":"$gcc",
"group": "build"
},
{
"label": "Rebuild all",
"type":"shell",
"command": "compiledb make all",
"problemMatcher":"$gcc",
"group": "build",
"dependsOn": "Make clean"
}
]
}
Now you can press ctrl-shift-b
to build, rebuild or clean your project.
Running one of the two build tasks will regenerate compile_commands.json
from the Makefile
so that IntelliSense is up to date.
Now, rebuild the project and press F5
to debug. If you've set up everything correctly, the STM32 should boot and automatically halt on the entry point in Reset_Handler
. Now you can step through the code, add watches and breakpoints, inspect the stack and registers just like you'd expect.
If you don't want the µC to halt at Reset_Handler
, add the entry "runToMain": true
in the launch.json
file.
Finally, if you want to automatically build an up-to-date binary of your project before debugging, you can add the following entry to launch.json
:
"preLaunchTask": "Make all"
(or whatever name you picked for your main build task).
Any changes to your code will be included whenever you start debugging, and any changes to your Makefile - such as adding sources or include folders - will be picked up by the IntelliSense engine after the next rebuild.
And that's it! Good luck!
- The
Makefile
generated by CubeMX is very barebones. My recommendation is to use CubeMX to generate boilerplate once, then manually create configurations for debug and release builds etc. It might be a good idea to create your own Makefile in case you need to regenerate from CubeMX and then manually add any changes. (There is an old, unsupported script for generating a CMake project with CubeMX but I deemed it too unstable to be useful at this point.) - To view disassembly, press F1 and type "disassembly", press enter again then type the name of the function you want to view. You can step through disassembled code with the debugger and add breakpoints as needed. Cortex-Debug will automatically show disassembly when it can't locate sources by default. You can change its behaviour by presssing F1 and changing the
Cortex-Debug: Set Forced Disassembly
option. - If you have an SVD file for your µC, you can load it with an
"svdFile"
entry inlaunch.json
to get a full view of peripheral registers. Cortex-Debug provides a number of support extensions for common µC's. Cortex-Debug apparently provides a"device"
config entry which is supposed to pick the correct SVD file automatically, but I wasn't able to get it to work. Saving the file in the workspace folder and loading it with an"svdFile"
entry works well enough. SVD files are available from ARM, and the Cortex-Debug Github repo hosts some for common µC's as well.