Skip to content

Instantly share code, notes, and snippets.

@neuroticflux
Last active December 18, 2020 15:19
Show Gist options
  • Save neuroticflux/53fecdfc2da35b8b381865b02d9617c6 to your computer and use it in GitHub Desktop.
Save neuroticflux/53fecdfc2da35b8b381865b02d9617c6 to your computer and use it in GitHub Desktop.

Getting started developing/debugging on embedded STM32 ARM microcontrollers with Visual Studio Code on Windows

Straight to the point

If you're only interested in the setup process, skip to Step 1 below and ignore my rantings.

Background

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...

Enter VSCode

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.

Things I tried that didn't work

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.

Obligatory caveats

  • 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...

Step 1: Install required software

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.

Step 2: Generating boilerplate code

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.

Step 3: Setting up a VSCode Workspace

Start up Visual Studio Code. You'll need to install the following two extensions:

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
}

Step 4: Uploading and debugging

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"
            ]
        }
    ]
}

Step 5: Build Configurations and IntelliSense

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.

Step 6: Testing it out

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!

Additional notes

  • 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 in launch.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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment