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.
What each tool is for
Section titled “What each tool is for”Here is the whole kit. Every tool earns its place; you will use all of them before the course is over.
| Tool | One-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-gcc | The 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. |
make | Rebuilds only what changed, driven by a Makefile. |
cmake | Generates build files for larger projects and handles toolchain selection cleanly. |
openocd | Talks to the debug probe over USB and exposes the chip to gdb as a network server; also flashes firmware. |
| ST-Link driver | Lets the operating system talk to the ST-Link probe built into the Discovery board (needed on Windows only). |
| Renode | Simulates the whole STM32F4 Discovery board so you can run and debug firmware without hardware. |
| QEMU | A second simulator with a simpler STM32F405 model, fine for UART-only examples. |
| VS Code + C/C++ + Cortex-Debug | An editor with code navigation and a graphical front end for gdb, registers, and peripherals. |
| Git | Version 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.
Installing on Windows
Section titled “Installing on Windows”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.
Host compiler
Section titled “Host compiler”Windows has no system C compiler. The cleanest option is GCC from MSYS2:
winget install -e --id MSYS2.MSYS2Then open the “MSYS2 UCRT64” shell from the Start menu and run:
pacman -S --needed mingw-w64-ucrt-x86_64-gccAdd 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.
Cross toolchain, build tools, debug tools
Section titled “Cross toolchain, build tools, debug tools”winget install -e --id Arm.GnuArmEmbeddedToolchainwinget install -e --id Kitware.CMakewinget install -e --id ezwinports.makewinget install -e --id Git.Gitwinget install -e --id Microsoft.VisualStudioCodeOpenOCD and QEMU are easiest through Chocolatey:
choco install openocdchoco install qemuIf 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).
Renode and the ST-Link driver
Section titled “Renode and the ST-Link driver”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.
Installing on macOS
Section titled “Installing on macOS”Install Apple’s command line tools first. They provide clang (also available as gcc, which is clang in disguise), make, and git:
xcode-select --installThen install Homebrew from its website if you do not have it, and:
brew install --cask gcc-arm-embeddedbrew install openocd cmake qemubrew install --cask visual-studio-codeUse 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.
Installing on Debian and Ubuntu
Section titled “Installing on Debian and Ubuntu”One apt line installs almost everything:
sudo apt updatesudo apt install build-essential gcc-arm-none-eabi libnewlib-arm-none-eabi \ binutils-arm-none-eabi gdb-multiarch openocd cmake qemu-system-arm gitbuild-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:
sudo apt install ./renode_*_amd64.debVS 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:
sudo usermod -aG plugdev "$USER"sudo udevadm control --reload-rulesLog out and back in for the group change to take effect, then plug the board in again.
Verifying each install
Section titled “Verifying each install”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.
| Command | Expected 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 --version | arm-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 --version | GNU 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 --version | GNU objdump (Arm GNU Toolchain 13.3.Rel1 (Build arm-13.24)) 2.42.0.20240614 |
make --version | GNU Make 4.3 (macOS ships GNU Make 3.81, which is fine) |
cmake --version | cmake version 3.28.3 |
openocd --version | Open On-Chip Debugger 0.12.0 |
qemu-system-arm --version | QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1) |
renode --version | Renode v1.15.3 followed by a build identifier |
code --version | A bare version number such as 1.95.3 |
git --version | git 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.
Checking the probe
Section titled “Checking the probe”With the Discovery board plugged in, run:
openocd -f board/stm32f4discovery.cfgboard/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.0Licensed under GNU GPL v2Info : clock speed 2000 kHzInfo : STLINK V2J45S0 (API v2) VID:PID 0483:3748Info : Target voltage: 2.88Info : [stm32f4x.cpu] Cortex-M4 r0p1 processor detectedInfo : [stm32f4x.cpu] target has 6 breakpoints, 4 watchpointsInfo : starting gdb server for stm32f4x.cpu on 3333Info : Listening on port 3333 for gdb connectionsThe 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).
VS Code setup
Section titled “VS Code setup”Install the two extensions from the command line:
code --install-extension ms-vscode.cpptoolscode --install-extension marus25.cortex-debugThe 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:
{ "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, flashexecutableonto 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 startopenocd -f board/stm32f4discovery.cfgitself. 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 producebuild/firmware.elfstarting in Part 2."runToEntryPoint": "main"sets a temporary breakpoint atmainso 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:
{ "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 as a learning tool
Section titled “Compiler Explorer as a learning tool”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:
- Choose language C.
- In the compiler drop-down, choose an “ARM GCC” compiler whose name includes “none” (the bare-metal
arm-none-eabibuild). - 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 lrTwo 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.
Your folder layout
Section titled “Your folder layout”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 *.mapKeeping 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.
A first host program
Section titled “A first host program”Create host/01-hello/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:
gcc -std=c11 -Wall -Wextra -Wpedantic -o hello hello.c./helloOn 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 = 42sizeof(void *) = 8 bytesThe 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.
A first cross compile
Section titled “A first cross compile”Now check that the cross compiler works. Create target/00-check/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:
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 \ -O2 -g -Wall -Wextra -c scale.c -o scale.oarm-none-eabi-objdump -d scale.oscale.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 lrRead 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:
| Flag | Meaning |
|---|---|
-mcpu=cortex-m4 | Generate code for the Cortex-M4 core and schedule instructions for it. |
-mthumb | Use the Thumb instruction set, the only one Cortex-M can execute. |
-mfloat-abi=hard | Use the FPU and pass float arguments in FPU registers. |
-mfpu=fpv4-sp-d16 | The specific FPU in the M4F: single precision, 16 double-word registers. |
-O2 | Optimise for speed without code-size-increasing extremes. |
-g | Include debug information so gdb can map addresses to source lines. |
-c | Compile and assemble only; do not link. |
Why it cannot link yet
Section titled “Why it cannot link yet”Try to build a complete program from this file:
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb scale.c -o scale.elfThe 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 statusThe 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:
- A linker script that says where flash and RAM are on this chip (
0x08000000and0x20000000on the STM32F407) and which sections go where. - 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.
- A
mainand, if you use parts of the C library that need the OS, small system call stubs such as_exitand_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.
Checking the simulators
Section titled “Checking the simulators”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:
qemu-system-arm -M helpThe list should include a line starting with netduinoplus2, the STM32F405-based machine used for UART examples in this course.
Common mistakes
Section titled “Common mistakes”- Installing
arm-linux-gnueabihf-gcc(or the Ubuntu packagegcc-arm-linux-gnueabihf) instead ofarm-none-eabi-gcc: it targets Linux and is the wrong compiler; install thenone-eabione. - Installing the Homebrew
arm-none-eabi-gccformula on macOS and getting link errors about missing headers or libc: use thegcc-arm-embeddedcask. - Opening an old terminal after installing: the
PATHchange 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
-mthumbor-mcpuon 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.gitignoreon day one.
Exercises
Section titled “Exercises”- Run every command in the verification table and record the version you got for each in a
TOOLS.mdfile in your course repository. A complete answer has a version for every tool, withgdb-multiarchsubstituted forarm-none-eabi-gdbon Linux if that is what you installed. - Modify
hello.cto also printsizeof(int),sizeof(long), andsizeof(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 whylongdiffers. - Recompile
scale.cfor the target with-O0instead of-O2and disassemble it. Count the instructions inscaleat each level. At-O0you should see the argument stored to the stack and reloaded, and the function will be several times longer. - Run
arm-none-eabi-size scale.oandarm-none-eabi-nm scale.o. Explain what thetextcolumn means and whynmshowsT scale. (You will learn these tools fully in Hello, C, but try to interpret them now.) - 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 between0x08000000and0x080FFFFF, inside flash. Without a board: do the same in Renode once you reach Blinky From Nothing, and note the answer here.
Check yourself
Section titled “Check yourself”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?
-c stops after compiling and assembling, so no linker script, startup code, or probe is involved. It only proves the compiler and assembler for the target work.
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?
On the PC, the OS loads the program and the toolchain ships matching startup files. For a microcontroller you must describe where flash and RAM are and provide the vector table yourself.
Question 3:On Ubuntu, arm-none-eabi-gdb is "command not found" after installing gdb-multiarch. What should you do?
Ubuntu ships one gdb with many architectures enabled under the name gdb-multiarch. It works with OpenOCD and Cortex-Debug once you point the extension at it.
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?
Multiplying by a power of two is a shift, and Arm's data-processing instructions can shift their second operand at no extra cost, so one instruction does both.
Question 5:hello.c prints sizeof(void *) = 8 bytes on your computer. What will the same expression be on the STM32F407?
Pointer size is set by the target, not by C. The Cortex-M4 has 32-bit addresses, so pointers are 4 bytes, and sizeof is a compile-time constant.
Key takeaways
Section titled “Key takeaways”- You need two compilers: a host
gccorclangfor learning and testing, andarm-none-eabi-gccfor the target.nonemeans 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
--versionand check the probe withopenocd -f board/stm32f4discovery.cfgbefore debugging anything else. -cproves 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 -mthumbis the quickest way to see what C becomes on the target. - Keep host and target code in separate trees, keep
.vscodein Git, and keepbuild/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.