Skip to content

Start Here23 min read

Setting Up Your Toolchain

Before you can write firmware you need a set of tools that turn C into machine code for two different computers: the one on your desk and the Cortex-M4 on the board. This lesson installs all of them on Windows, macOS, or Debian/Ubuntu, verifies each with a command and its expected output, sets up VS Code to debug the STM32F4 Discovery, and ends with two first compiles: a program that runs on your computer, and an object file built for the microcontroller that cannot run yet, for reasons you will understand by the end.

Here is the whole kit. Every tool earns its place; you will use all of them before the course is over.

ToolOne-sentence purpose
gcc or clang (host)Compiles C for your own computer so you can learn the language and run unit tests without hardware.
arm-none-eabi-gccThe cross compiler: runs on your computer, produces code for bare-metal Arm cores.
binutils (arm-none-eabi-objdump, nm, size, objcopy, readelf, addr2line)Inspect and convert the files the compiler produces: disassembly, symbols, section sizes, raw binaries.
arm-none-eabi-gdb (or gdb-multiarch)The debugger: sets breakpoints, steps through code, and reads memory on the target through OpenOCD.
makeRebuilds only what changed, driven by a Makefile.
cmakeGenerates build files for larger projects and handles toolchain selection cleanly.
openocdTalks to the debug probe over USB and exposes the chip to gdb as a network server; also flashes firmware.
ST-Link driverLets the operating system talk to the ST-Link probe built into the Discovery board (needed on Windows only).
RenodeSimulates the whole STM32F4 Discovery board so you can run and debug firmware without hardware.
QEMUA second simulator with a simpler STM32F405 model, fine for UART-only examples.
VS Code + C/C++ + Cortex-DebugAn editor with code navigation and a graphical front end for gdb, registers, and peripherals.
GitVersion control for your exercise code. Not strictly a firmware tool, but you should use it from day one.

The name arm-none-eabi is a target triple. arm is the architecture, none means no operating system (bare metal), and eabi is the Embedded Application Binary Interface, the calling and data-layout rules that Arm code follows. A compiler named arm-linux-gnueabihf-gcc targets Arm Linux instead; it is the wrong tool for this course.

Use PowerShell for the install commands. winget ships with Windows 10 and 11. Chocolatey (choco) is an alternative package manager; install it from its website if you want to use the choco lines, and run those from an administrator PowerShell.

Windows has no system C compiler. The cleanest option is GCC from MSYS2:

Terminal window
winget install -e --id MSYS2.MSYS2

Then open the “MSYS2 UCRT64” shell from the Start menu and run:

Terminal window
pacman -S --needed mingw-w64-ucrt-x86_64-gcc

Add C:\msys64\ucrt64\bin to your user PATH (Settings, System, About, Advanced system settings, Environment Variables) so that gcc works from PowerShell and VS Code too.

Terminal window
winget install -e --id Arm.GnuArmEmbeddedToolchain
winget install -e --id Kitware.CMake
winget install -e --id ezwinports.make
winget install -e --id Git.Git
winget install -e --id Microsoft.VisualStudioCode

OpenOCD and QEMU are easiest through Chocolatey:

Terminal window
choco install openocd
choco install qemu

If you prefer Chocolatey for everything, choco install gcc-arm-embedded make cmake installs the toolchain and build tools. MSYS2 also packages all of these (mingw-w64-ucrt-x86_64-arm-none-eabi-gcc, mingw-w64-ucrt-x86_64-openocd, mingw-w64-ucrt-x86_64-qemu) if you would rather keep everything in one place.

When the Arm toolchain installer finishes, tick the option to add it to PATH, or add its bin folder yourself. If a package manager gives you a very old toolchain, download the current “Arm GNU Toolchain” Windows installer from Arm’s developer website instead (see the Resources page).

Download the Renode Windows installer (.msi) from the Renode releases page on GitHub and run it. It adds renode to your PATH.

The ST-Link probe needs ST’s USB driver on Windows, published by ST as package STSW-LINK009. Download it from st.com (a free account is required), unzip it, and run dpinst_amd64.exe. Plug in the Discovery board afterwards; Device Manager should list “STMicroelectronics STLink dongle” or similar under Universal Serial Bus devices.

Open a new PowerShell window after all installs so it picks up the updated PATH.

Install Apple’s command line tools first. They provide clang (also available as gcc, which is clang in disguise), make, and git:

Terminal window
xcode-select --install

Then install Homebrew from its website if you do not have it, and:

Terminal window
brew install --cask gcc-arm-embedded
brew install openocd cmake qemu
brew install --cask visual-studio-code

Use the gcc-arm-embedded cask, which is Arm’s official toolchain including arm-none-eabi-gdb and the newlib C library. Homebrew also has a formula named arm-none-eabi-gcc, but it does not bundle a C library, so programs that use string.h or printf will not link.

Renode for macOS is a .dmg on the Renode GitHub releases page. Drag it into Applications. To get the renode command in a terminal, follow the note in the release (it is a script inside the app bundle that you can symlink into a directory on your PATH).

macOS needs no ST-Link driver: OpenOCD talks to the probe through libusb, which Homebrew installed with it.

One apt line installs almost everything:

Terminal window
sudo apt update
sudo apt install build-essential gcc-arm-none-eabi libnewlib-arm-none-eabi \
binutils-arm-none-eabi gdb-multiarch openocd cmake qemu-system-arm git

build-essential provides the host gcc and make. libnewlib-arm-none-eabi is the C library for the cross compiler. Debian and Ubuntu no longer ship a package named gdb-arm-none-eabi; gdb-multiarch is a single gdb built with support for many architectures, including Arm, and it works for everything in this course. If you prefer Arm’s own build, download the Linux tarball of the Arm GNU Toolchain, extract it to /opt, and add its bin directory to your PATH. Some versions of Arm’s arm-none-eabi-gdb need the old libncursesw5 library; install it with apt if gdb complains about a missing libncursesw.so.5.

Renode publishes a .deb on its GitHub releases page:

Terminal window
sudo apt install ./renode_*_amd64.deb

VS Code: download the .deb from the VS Code website and install it the same way, or use sudo snap install code --classic.

Linux needs no ST-Link driver, but by default only root may open USB devices. The Debian openocd package installs a udev rules file that grants access to members of the plugdev group:

Terminal window
sudo usermod -aG plugdev "$USER"
sudo udevadm control --reload-rules

Log out and back in for the group change to take effect, then plug the board in again.

Run each command in a fresh terminal. Compare the first line of output with the expected line. If a command is “not found”, the tool is either not installed or not on your PATH.

CommandExpected first line (versions vary)
gcc --version (Windows, Linux)gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0 or gcc.exe (Rev2, Built by MSYS2 project) 14.2.0
clang --version (macOS)Apple clang version 16.0.0 (clang-1600.0.26.4)
arm-none-eabi-gcc --versionarm-none-eabi-gcc (Arm GNU Toolchain 13.3.Rel1 (Build arm-13.24)) 13.3.1 20240614 or arm-none-eabi-gcc (15:13.2.rel1-2) 13.2.1 20231009
arm-none-eabi-gdb --versionGNU gdb (Arm GNU Toolchain 13.3.Rel1 (Build arm-13.24)) 14.2.90.20240526-git
gdb-multiarch --version (Linux)GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
arm-none-eabi-objdump --versionGNU objdump (Arm GNU Toolchain 13.3.Rel1 (Build arm-13.24)) 2.42.0.20240614
make --versionGNU Make 4.3 (macOS ships GNU Make 3.81, which is fine)
cmake --versioncmake version 3.28.3
openocd --versionOpen On-Chip Debugger 0.12.0
qemu-system-arm --versionQEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1)
renode --versionRenode v1.15.3 followed by a build identifier
code --versionA bare version number such as 1.95.3
git --versiongit version 2.43.0

Two quirks. OpenOCD prints its version to stderr instead of stdout, which only matters if you pipe it somewhere. And on Linux, arm-none-eabi-gdb will be “not found” if you used gdb-multiarch; that is expected.

With the Discovery board plugged in, run:

Terminal window
openocd -f board/stm32f4discovery.cfg

board/stm32f4discovery.cfg is a configuration file that ships with OpenOCD. It selects the ST-Link interface, the SWD transport, and the STM32F4 target. The output should include lines like these:

Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
Info : clock speed 2000 kHz
Info : STLINK V2J45S0 (API v2) VID:PID 0483:3748
Info : Target voltage: 2.88
Info : [stm32f4x.cpu] Cortex-M4 r0p1 processor detected
Info : [stm32f4x.cpu] target has 6 breakpoints, 4 watchpoints
Info : starting gdb server for stm32f4x.cpu on 3333
Info : Listening on port 3333 for gdb connections

The ST-Link version string differs between board revisions (the newer STM32F407G-DISC1 has an ST-LINK/V2-A with PID 374b). The important lines are “Cortex-M4 r0p1 processor detected” and “Listening on port 3333”. OpenOCD is now waiting for gdb; press Ctrl+C to stop it.

If you see Error: open failed instead, the probe was not found: on Windows check the driver in Device Manager, on Linux check the plugdev group and udev rules, and on every OS try a different USB cable (many cheap cables carry power only).

Install the two extensions from the command line:

Terminal window
code --install-extension ms-vscode.cpptools
code --install-extension marus25.cortex-debug

The C/C++ extension (ms-vscode.cpptools) provides code completion and navigation. Cortex-Debug (marus25.cortex-debug) drives gdb and OpenOCD for you and shows core registers, peripheral registers, and memory.

For the peripheral view, Cortex-Debug needs the chip’s SVD file, an XML description of every peripheral register. ST publishes it in the STM32F4 SVD package on st.com, and it is also included in many open-source repositories. Save it as tools/STM32F407.svd in your course folder. Debugging works without it; you just lose the peripheral register tree.

Create .vscode/launch.json in the course folder:

.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "STM32F4 Discovery: flash and debug",
"type": "cortex-debug",
"request": "launch",
"servertype": "openocd",
"cwd": "${workspaceFolder}",
"executable": "${workspaceFolder}/build/firmware.elf",
"configFiles": ["board/stm32f4discovery.cfg"],
"device": "STM32F407VG",
"interface": "swd",
"svdFile": "${workspaceFolder}/tools/STM32F407.svd",
"runToEntryPoint": "main",
"showDevDebugOutput": "none"
},
{
"name": "STM32F4 Discovery: attach to running target",
"type": "cortex-debug",
"request": "attach",
"servertype": "openocd",
"cwd": "${workspaceFolder}",
"executable": "${workspaceFolder}/build/firmware.elf",
"configFiles": ["board/stm32f4discovery.cfg"],
"device": "STM32F407VG",
"interface": "swd",
"svdFile": "${workspaceFolder}/tools/STM32F407.svd"
}
]
}

What the important fields do:

  • "type": "cortex-debug" hands the session to the Cortex-Debug extension instead of VS Code’s generic debugger.
  • "request": "launch" means: start OpenOCD, connect gdb, flash executable onto the chip, reset, and halt. "attach" connects to a chip that is already running without reflashing or resetting it, which is how you inspect a device that has hung.
  • "servertype": "openocd" and "configFiles" tell Cortex-Debug to start openocd -f board/stm32f4discovery.cfg itself. You do not run OpenOCD by hand when using VS Code.
  • "executable" is the ELF file your build produces. It contains both the machine code to flash and the debug information that maps addresses back to source lines. You will produce build/firmware.elf starting in Part 2.
  • "runToEntryPoint": "main" sets a temporary breakpoint at main so the session stops after startup code has run, instead of at the reset vector.
  • "svdFile" enables the peripheral register view.

Cortex-Debug looks for arm-none-eabi-gdb on your PATH. On Linux with gdb-multiarch, add this to .vscode/settings.json:

.vscode/settings.json
{
"cortex-debug.gdbPath": "gdb-multiarch"
}

If OpenOCD or the toolchain is not on your PATH, the same file accepts "cortex-debug.openocdPath" and "cortex-debug.armToolchainPath" with the full paths.

Compiler Explorer (godbolt.org) compiles code in your browser and shows the assembly side by side with the source, coloured line by line. It is the fastest way to answer “what did the compiler do with this?”, and you will use it in almost every C lesson.

Set it up for this course:

  1. Choose language C.
  2. In the compiler drop-down, choose an “ARM GCC” compiler whose name includes “none” (the bare-metal arm-none-eabi build).
  3. In the compiler options box, enter -O2 -mcpu=cortex-m4 -mthumb.

Type this function:

int add_scaled(int a, int b)
{
return a + 4 * b;
}

The output should be:

add_scaled:
add r0, r0, r1, lsl #2
bx lr

Two instructions. The arguments arrive in registers r0 and r1, the result goes back in r0, and bx lr returns. The multiply by 4 became a left shift by 2 folded into the add, because Arm’s add can shift its second operand for free. Now change -O2 to -O0 and watch the same function grow to a dozen instructions that store everything to the stack and load it back. You will learn to read this output properly in Reading What the Compiler Wrote; for now, notice how much the optimisation level changes what the CPU actually executes.

Create one folder for the whole course and keep it in Git. This layout is used in every later lesson:

firmware-path/
.vscode/
launch.json debug configurations (above)
settings.json tool paths, editor settings
host/ Part 1: C programs that run on your computer
01-hello/
hello.c
target/ Parts 2 and later: firmware for the STM32F407
common/ register header, startup code, linker script
03-blinky/
tools/
STM32F407.svd peripheral description for the debugger
renode/ Renode scripts (.resc)
build/ generated files, never committed
.gitignore contains build/ and *.o *.elf *.bin *.map

Keeping host and target code in separate trees matters because they are compiled by different compilers with different flags. Mixing them is a common source of “why does this not link” confusion.

Create host/01-hello/hello.c:

hello.c
#include <stdint.h>
#include <stdio.h>
int main(void)
{
uint32_t answer = 42u;
printf("Hello from the host.\n");
printf("answer = %u\n", (unsigned)answer);
printf("sizeof(void *) = %zu bytes\n", sizeof(void *));
return 0;
}

Compile it with the warning flags used throughout the course, then run it:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -o hello hello.c
./hello

On Windows, run .\hello.exe in PowerShell. On macOS, gcc runs clang, which is fine; you can also type clang directly.

Hello from the host.
answer = 42
sizeof(void *) = 8 bytes

The last line is the interesting one. Your computer is a 64-bit machine, so a pointer is 8 bytes. On the Cortex-M4 it will be 4 bytes. This is your first hint that “C” does not define one machine; the same source describes different things on different targets, a theme of Types, Sizes, and How Numbers Are Stored.

The flags: -std=c11 selects the C11 standard, -Wall -Wextra turn on the important warnings, and -Wpedantic warns about non-standard extensions. A clean compile means no output at all. Treat every warning as a bug until proven otherwise.

Now check that the cross compiler works. Create target/00-check/scale.c:

scale.c
#include <stdint.h>
uint32_t scale(uint32_t x)
{
return x * 3u + 1u;
}

Compile it for the Cortex-M4 with the course’s target flags, stopping before the link step (-c), and disassemble the result:

Terminal window
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 \
-O2 -g -Wall -Wextra -c scale.c -o scale.o
arm-none-eabi-objdump -d scale.o
scale.o: file format elf32-littlearm
Disassembly of section .text:
00000000 <scale>:
0: eb00 0040 add.w r0, r0, r0, lsl #1
4: 3001 adds r0, #1
6: 4770 bx lr

Read it line by line. elf32-littlearm says this is a 32-bit little-endian Arm object file, not a file for your PC. add.w r0, r0, r0, lsl #1 computes x + (x << 1), which is x * 3, without a multiply instruction. adds r0, #1 adds one. bx lr returns. The middle column is the actual machine code: a mix of 32-bit (eb00 0040) and 16-bit (3001, 4770) instructions, which is the Thumb-2 instruction set. Your exact output may differ slightly between compiler versions.

The flags you just used, in one line each:

FlagMeaning
-mcpu=cortex-m4Generate code for the Cortex-M4 core and schedule instructions for it.
-mthumbUse the Thumb instruction set, the only one Cortex-M can execute.
-mfloat-abi=hardUse the FPU and pass float arguments in FPU registers.
-mfpu=fpv4-sp-d16The specific FPU in the M4F: single precision, 16 double-word registers.
-O2Optimise for speed without code-size-increasing extremes.
-gInclude debug information so gdb can map addresses to source lines.
-cCompile and assemble only; do not link.

Try to build a complete program from this file:

Terminal window
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb scale.c -o scale.elf

The link fails with errors along these lines (trimmed):

.../libc.a(libc_a-exit.o): in function `exit':
exit.c:(.text.exit+0x...): undefined reference to `_exit'
.../crt0.o: in function `_mainCRTStartup':
(.text+0x...): undefined reference to `main'
collect2: error: ld returned 1 exit status

The exact messages vary by toolchain version, but the reason is always the same. On your PC, the operating system loads programs, sets up memory, and provides system calls, and the host compiler links in startup code and a linker script that fit that OS. A bare-metal toolchain cannot know any of that, because it does not know which chip you have. To produce a working firmware image you must supply:

  1. A linker script that says where flash and RAM are on this chip (0x08000000 and 0x20000000 on the STM32F407) and which sections go where.
  2. Startup code with a vector table: the table of addresses the CPU reads at reset to find the initial stack pointer and the first instruction to run.
  3. A main and, if you use parts of the C library that need the OS, small system call stubs such as _exit and _write.

You will write all three from scratch in Blinky From Nothing, and understand each one deeply in Startup Code and the Vector Table and Linker Scripts. For now, the successful -c compile proves the cross compiler and its target support are installed correctly.

Renode starts an interactive monitor window when you run renode. Type quit to exit. You will load the STM32F4 Discovery platform into it in Blinky From Nothing.

QEMU can be checked by asking for its list of machines:

Terminal window
qemu-system-arm -M help

The list should include a line starting with netduinoplus2, the STM32F405-based machine used for UART examples in this course.

  • Installing arm-linux-gnueabihf-gcc (or the Ubuntu package gcc-arm-linux-gnueabihf) instead of arm-none-eabi-gcc: it targets Linux and is the wrong compiler; install the none-eabi one.
  • Installing the Homebrew arm-none-eabi-gcc formula on macOS and getting link errors about missing headers or libc: use the gcc-arm-embedded cask.
  • Opening an old terminal after installing: the PATH change is only visible to new terminals, so open a fresh one.
  • Using a charge-only USB cable: OpenOCD reports open failed; swap the cable before reinstalling drivers.
  • Running OpenOCD by hand and also launching Cortex-Debug: both try to own the probe and port 3333; let VS Code start OpenOCD.
  • Forgetting -mthumb or -mcpu on a cross compile: the code may target a different Arm core than the one on the board; always pass the full target flag set.
  • Committing build/ and binaries to Git: add them to .gitignore on day one.
  1. Run every command in the verification table and record the version you got for each in a TOOLS.md file in your course repository. A complete answer has a version for every tool, with gdb-multiarch substituted for arm-none-eabi-gdb on Linux if that is what you installed.
  2. Modify hello.c to also print sizeof(int), sizeof(long), and sizeof(long long). On 64-bit Linux and macOS you should see 4, 8, 8; on 64-bit Windows you should see 4, 4, 8. Write one sentence explaining why long differs.
  3. Recompile scale.c for the target with -O0 instead of -O2 and disassemble it. Count the instructions in scale at each level. At -O0 you should see the argument stored to the stack and reloaded, and the function will be several times longer.
  4. Run arm-none-eabi-size scale.o and arm-none-eabi-nm scale.o. Explain what the text column means and why nm shows T scale. (You will learn these tools fully in Hello, C, but try to interpret them now.)
  5. With a board: start a Cortex-Debug “attach” session against the factory demo firmware that ships on the Discovery (you will not have source, so set "executable" to any ELF and expect source lines to be wrong). Pause execution and read the program counter in the registers view. Its value should be between 0x08000000 and 0x080FFFFF, inside flash. Without a board: do the same in Renode once you reach Blinky From Nothing, and note the answer here.

Question 1:You run arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -c scale.c and get scale.o with no errors. What does that prove?

Question 2:Why does linking a bare-metal program with no startup code or linker script fail, when the same command works with the host gcc?

Question 3:On Ubuntu, arm-none-eabi-gdb is "command not found" after installing gdb-multiarch. What should you do?

Question 4:In Compiler Explorer with -O2 -mcpu=cortex-m4 -mthumb, return a + 4 * b; becomes add r0, r0, r1, lsl #2. Where is the multiply?

Question 5:hello.c prints sizeof(void *) = 8 bytes on your computer. What will the same expression be on the STM32F407?

  • You need two compilers: a host gcc or clang for learning and testing, and arm-none-eabi-gcc for the target. none means bare metal.
  • OpenOCD connects the probe to gdb over port 3333; Cortex-Debug starts both for you from .vscode/launch.json.
  • Verify every tool with --version and check the probe with openocd -f board/stm32f4discovery.cfg before debugging anything else.
  • -c proves the cross compiler works; a full link needs a linker script, startup code with a vector table, and system call stubs that you supply.
  • Compiler Explorer with -O2 -mcpu=cortex-m4 -mthumb is the quickest way to see what C becomes on the target.
  • Keep host and target code in separate trees, keep .vscode in Git, and keep build/ out of it.

Next: In How a Program Actually Runs on a Microcontroller you will build the mental model of what the CPU does from reset to main and beyond.