Zephyr from Zero on the ST Nucleo-F401RE: Watching the Scheduler Work
Photo by Donald Wu / Unsplash

Zephyr from Zero on the ST Nucleo-F401RE: Watching the Scheduler Work

A complete beginner's path onto the Zephyr RTOS using the low-cost ST Nucleo-F401RE, from installing the toolchain to a four-thread demo whose scheduling you can watch on a logic analyser.

Watching the Scheduler Work

This article is part of a series on RTOS fundamentals with the ST Nucleo-F401RE:
- Zephyr from zero: the toolchain, the board, and the scheduler on a scope – this article
- The same demo in async Rust with Embassy (coming soon)
- One firmware, three boards: Zephyr devicetree portability (coming soon)

Every embedded tutorial starts with a blinking LED, and there is a problem with that: blinky teaches you the build system, but it teaches you nothing about the thing you actually installed. Zephyr is a real-time operating system. Its heart is a pre-emptive scheduler that decides, tens of thousands of times per second, which of your threads deserves the CPU. In a blinky, that complex machinery is invisible.

This guide makes it visible. We will set up the Zephyr toolchain, and then build a small application with four threads, each wired to its own GPIO "probe" pin. Connect a logic analyser (even a cheap €10 clone will do) and you can watch the scheduler at work: two equal-priority threads being round-robined in neat 10 ms slices, a higher-priority thread punching holes in their timelines, and a button press pre-empting everything within microseconds. No prior Zephyr experience is assumed.

Figure 1. The Nucleo-F401RE, board MB1136 rev C. Worth orienting yourself before you start, because everything this article asks you to use is visible here. The ST-LINK occupies the third, to the left of the perforated line it can be snapped off along, with its USB Mini-B socket on the far left. B2 RESET is the black button and B1 USER the blue one. The Arduino header along the top edge carries the silkscreened D0 to D15 labels, and the probe pins used later are D2 to D5 at its right-hand end; CN7 and CN10 along the bottom and top are the morpho headers that expose every remaining pin, including PC13 for the button.
💡
This guide is a snapshot, current as of mid-2026 It was written and build-tested against Zephyr 4.4.99 (the main branch shortly after 4.4) with the Zephyr SDK 1.0.1 and CMake 4.3, on a board running ST-LINK firmware V2J48M35 (see the upgrade note before the first flash). Zephyr moves quickly and its commands and Kconfig symbols evolve between releases; if something below does not match what you see, check the official Getting Started Guide for your version first. The board target used throughout is nucleo_f401re.

Why this board?

The ST Nucleo-F401RE has been around for over a decade, and that is precisely why it is a perfect first Zephyr board. It costs around €15, it is stocked everywhere, thousands of university labs already have them, and, most importantly, it has a debugger built in. The upper section of the board is a complete ST-LINK/V2-1 probe, which gives you four tools through one USB cable:

  • a programmer, so flashing needs no extra hardware;
  • a debugger, so breakpoints and single-stepping work out of the box with west debug;
  • a virtual COM port, which Zephyr uses for its console and shell; and
  • a mass-storage bootloader: the board appears as a USB drive called NOD_F401RE, and you can program it by dragging a .bin file onto that drive. (Much of the internet writes that label as NODE_F401RE. On my board it is NOD_F401RE, without the E, and it stayed that way across a seven-year ST-LINK firmware upgrade, so this is not a question of an old board showing an old name. Go by what actually appears in Explorer rather than by what any guide says, this one included.)
⚠️
Check what cable that is before you order the board, because it might not be one you own. The ST-LINK's USB socket on this board (CN1) is a Mini-B. Not Micro-B, and certainly not USB-C. Mini-B disappeared from consumer electronics around 2012, so unless you have been in this hobby a long time there is a real chance the €15 board arrives and sits on the desk for a day. The usual places one turns up are old digital cameras, external hard drive enclosures, and PS3 controllers; otherwise they cost a couple of euro, and a Mini-B is worth having in the drawer anyway because a good deal of older lab equipment still uses it.

Do not generalise this to "Nucleo boards use Mini-B", because the family has moved on. The recent Nucleo-64 boards built around the STLINK-V3EC debugger use USB-C, and some Nucleo variants use Micro-B. The connector tracks the debugger generation rather than the board size, so look at the photograph on the listing, or at the board itself.
Feature Detail
MCU STM32F401RE, Arm Cortex-M4F at 84 MHz
Memory 512 KB flash, 96 KB SRAM
Debug On-board ST-LINK/V2-1 (program, debug, serial, drag-and-drop)
USB connector Mini-B (see the note above)
User I/O One green LED (LD2), one blue user button (B1)
Headers Arduino R3 shield headers plus ST morpho headers (every pin)
Radios None
Zephyr target nucleo_f401re

That "Radios: none" row is not a gap for a first board. Wireless stacks are where embedded projects get complicated (my XIAO nRF52840 series spends most of its time there). Here there is no network to commission and no border router to configure: it is the kernel and some wires, which is the right place to learn what an RTOS actually does.

How it compares

If you are choosing a first Zephyr board, or wondering whether the one in your drawer will do, this is the landscape as of mid-2026 (prices are approximate):

Board Price MCU Debugging out of the box Radio Zephyr target
Nucleo-F401RE €18 Cortex-M4F, 84 MHz Full: on-board ST-LINK None nucleo_f401re
XIAO nRF52840 €15 Cortex-M4F, 64 MHz None (UF2 drag-and-drop only) BLE, 802.15.4 xiao_ble
Arduino Nano 33 BLE €24 Cortex-M4F, 64 MHz None (bootloader only) BLE arduino_nano_33_ble
Raspberry Pi Pico 2 €10 Dual Cortex-M33, 150 MHz None (UF2; or a second Pico as a probe) None (W variant: Wi-Fi) rpi_pico2
ESP32-C3 devkit €6 RISC-V, 160 MHz Partial (on-chip USB-JTAG) Wi-Fi, BLE esp32c3_devkitm

Every board in that table is well supported in mainline Zephyr, and the beauty of the devicetree system (below) is that the code in this article runs on all of them with only a pin-mapping overlay changed, which is exactly what a future article in this series demonstrates. But for learning, the Nucleo's built-in debugger is the difference between "add a printk and reflash" and "set a breakpoint and look". The others make you buy and wire an external probe before you can single-step; the Nucleo gives you that on day one.

💡
Already own one of the others? Stay with it. Everything in this article except the flashing step works identically; the overlay section below shows exactly what to change. The XIAO and Pico flash by drag-and-drop UF2, as covered in the XIAO series.

What is Zephyr?

Zephyr is a full real-time operating system maintained under the Linux Foundation: a pre-emptive scheduler, a device driver model, networking, filesystems, and a build system, developed as one large open-source project and used heavily in industry. Four ideas define how it works, and this article exercises all of them:

  • Pre-emptive threads. Your application is a set of kernel threads with priorities. The scheduler can stop a lower-priority thread mid-instruction to run a higher-priority one that has just become ready. This is the core of the article.
  • Devicetree. Hardware is described in .dts files, separately from your C code. Your program asks for "the LED with alias led0" rather than "pin PA5", which is how one application builds unchanged across hundreds of boards.
  • Kconfig. Features are switched on and off with CONFIG_ symbols in a prj.conf file, exactly like configuring the Linux kernel. Want a command shell? That is one line, not a library to vendor in.
  • west. Zephyr's meta-tool, which fetches the source tree and wraps building, flashing, and debugging. You will type west build constantly.

The cost of all this is a steep first day: the install is measured in gigabytes and the terminology is unfamiliar. The rest of this guide walks that first day slowly, because after it, the second day is much easier.

Setting up the toolchain

This section assumes a Windows machine, since that is what I use for this series; the official Getting Started Guide has the exact equivalents for Linux and macOS, and the steps have the same shape (on Ubuntu, the prerequisites come from apt, and everything from west init onwards is identical).

1. Install the prerequisites

Zephyr needs Python 3.10 or newer, Git, CMake, Ninja, the devicetree compiler dtc, and gperf. Chocolatey is the smoothest route on Windows because it is what the Zephyr documentation assumes, and it carries every one of those packages. Run this from an elevated PowerShell (right-click, "Run as Administrator"):

choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
choco install ninja gperf python git dtc-msys2 wget 7zip

Two of those deserve a a brief discussion. gperf generates perfect hash tables during the build; it is easy to leave out because nothing mentions it until a build fails somewhere deep and unhelpfully. And the ADD_CMAKE_TO_PATH=System argument on CMake is described in the next box.

⚠️
New tools do not appear in old terminals, and CMake has a sharper version of this problem. Windows only refreshes the PATH environment variable when a terminal starts, so after installing, close and reopen PowerShell. CMake's installer goes further: left to its own devices it registers itself for the current user's interactive shells only. The symptom is baffling, because cmake works perfectly when you type it and is "not recognised" the moment a build script runs. ADD_CMAKE_TO_PATH=System above is the fix; if you have already installed CMake without it, either reinstall or prepend the install directory (typically C:\Program Files\CMake\bin) to PATH inside the script itself. This one cost me an hour on Windows.
💡
Coming from my XIAO nRF52840 series? Two commands have changed. That series used pip install -r zephyr\scripts\requirements.txt for the Python dependencies and installed the SDK to an explicit directory. Current Zephyr replaces the first with west packages pip --install and the second with a bare west sdk install, both used below. The prerequisites in this section are otherwise identical, so an existing workspace needs nothing done to it.

2. Create the workspace

Zephyr lives in a workspace: one directory holding the Zephyr source, its module repositories, and your applications side by side. Create it with a Python virtual environment at the top, so the Python tooling stays isolated from the rest of your machine:

mkdir C:\zephyr
cd C:\zephyr
python -m venv zephyrproject\.venv
zephyrproject\.venv\Scripts\Activate.ps1
pip install west

Your prompt gains a (.venv) prefix. You will activate this venv at the start of every Zephyr session (the Activate.ps1 line alone), and every command from here on assumes it is active.

Now let west fetch Zephyr and its modules. This downloads several gigabytes and takes a while; make coffee:

west init zephyrproject
cd zephyrproject
west update
west zephyr-export
west packages pip --install

Know the three levels, because the middle catches people out. When west update finishes you have a directory tree that looks like this, and the distinction between its levels is the source of "the file is not where the guide says it is":

C:\zephyr\                     <- just a container
├── zephyr-sdk\                <- the SDK: cross-compilers, OpenOCD. No samples.
└── zephyrproject\             <- THE WORKSPACE. Your apps live here.
    ├── .venv\
    ├── bootloader\            <- MCUboot
    ├── modules\               <- HALs, crypto, filesystems, OpenThread...
    ├── tools\
    └── zephyr\                <- THE ZEPHYR SOURCE TREE
        ├── boards\            <- every supported board, including nucleo_f401re
        ├── drivers\
        ├── dts\
        ├── samples\           <- the samples are HERE
        └── subsys\

Two traps in that tree. The SDK is not the source: zephyr-sdk holds toolchains and host tools, and contains no samples, no boards, and nothing you will ever cd into. And the workspace is not the source either: zephyrproject is the outer directory holding Zephyr alongside its modules and your own applications, so samples is one level further down, in zephyrproject\zephyr\samples. Your own projects, by contrast, go directly in zephyrproject, as siblings of zephyr. That is the arrangement that makes west build able to see both at once.

Every new terminal needs two lines before it can build. The virtual environment and the working directory both reset when you close the window:

C:\zephyr\zephyrproject\.venv\Scripts\Activate.ps1
cd C:\zephyr\zephyrproject

west: command not found almost always means the first line was skipped, and a build that cannot find its source almost always means the second was. Every command from here on assumes you are in C:\zephyr\zephyrproject with (.venv) on your prompt.

3. Install the Zephyr SDK

The SDK is the collection of cross-compilers (one per CPU architecture) plus the host tools Zephyr needs, including OpenOCD for flashing and debugging ST boards. One command fetches and registers it:

west sdk install

4. Verify with blinky

Before writing any code of our own, prove the whole chain works using a sample from the Zephyr tree. Run this from the workspace root, C:\zephyr\zephyrproject, because the path to the sample is relative to wherever you are standing:

cd C:\zephyr\zephyrproject
west build -b nucleo_f401re zephyr\samples\basic\blinky -d build-blinky --pristine
💡
What --pristine is for, and when you actually need it. It deletes the build directory and starts again from nothing. It is not required here, on a directory that does not yet exist, but it costs a few seconds and it heads off the most common early problems, so I use it whenever I am proving something works.

The reason it matters is that a build directory is not just object files. It also remembers which board you targeted, which application you pointed at, and the absolute path of everything involved. Point the same build directory at a different combination and CMake will either complain in language that has nothing obviously to do with what you changed, or, worse, quietly rebuild the thing you had before. You need a pristine build when:you switch boards in an existing build directory, say from nucleo_f401re to xiao_ble;
- you switch applications, for example reusing a directory that last built blinky to build the scheduler demo below;
- the workspace has moved or been renamed, which produces the giveaway The current CMakeCache.txt directory ... is different than the directory ... where CMakeCache.txt was created;
- the SDK or toolchain has been upgraded under an existing build.

You do not need it for ordinary work. Editing src/main.c, prj.conf, or an overlay is detected and handled, and forcing a pristine build every time just makes your edit-compile loop slow for no benefit.

Two habits make the whole problem mostly disappear. The first is the one this article already follows: give every project its own -d directory, so build-blinky and build-nucleo never collide and there is nothing stale to inherit. The second is to let west decide for you, once: west config build.pristine auto

With that set, west compares what you have asked for against what the build directory remembers and cleans it only when the two disagree. It is the setting I would have wanted on my first day.

If west reports that it cannot find the source directory, you are almost certainly one level out: from C:\zephyr the path would need to be zephyrproject\zephyr\samples\basic\blinky, and from inside the source tree itself it is just samples\basic\blinky. Check with dir zephyr\samples\basic\blinky, which should list a CMakeLists.txt, a prj.conf, and a src directory.

If the build ends with a memory-usage table rather than an error, your toolchain is complete:

-- Zephyr version: 4.4.99 (C:/zephyr/zephyrproject/zephyr), build: v4.4.0-7043-g777ab585520e
[157/157] Linking C executable zephyr\zephyr.elf
Memory region         Used Size  Region Size  %age Used
           FLASH:       17296 B       512 KB      3.30%
             RAM:        4480 B        96 KB      4.56%
           SRAM0:           0 B        96 KB      0.00%
        IDT_LIST:           0 B        32 KB      0.00%

Hold on to that 17 KB, because it is the baseline the rest of this article is measured against: a complete Zephyr kernel, the GPIO driver stack, and blinky itself, in 3.3% of the flash. The two empty regions are normal. SRAM0 is the same physical memory as RAM described a second way, and IDT_LIST is a build-time scratch area for interrupt table generation that never reaches the device.

Now plug the Nucleo in over USB and flash it:

⚠️
Check the age of the ST-LINK's own firmware first, especially on a board that has been in a drawer. The debugger at the top of the Nucleo runs firmware of its own, quite separate from anything you flash onto the STM32, and on a board of this vintage it can be many years out of date. Open the NOD_F401RE drive and read DETAILS.TXT. Mine said this: Version: 0221
Build: Jan 7 2019 18:14:15

Read the build date, not the version number. On old firmware that Version field is an mbed interface number that tells you nothing about the ST-LINK: this board's real firmware turned out to be V2J33M25, which 0221 does not resemble in any way. The January 2019 build date is the part that means something, and it made the debugger seven years old. (The same 0221 also turns up inside MBED.HTM, where it is part of a static board identifier and does not change when you upgrade, so do not read that as a version either.)

Stale ST-LINK firmware is a well-known source of exactly the failures that are hardest to attribute: drag-and-drop programming that reports success and does not take, connections that drop under a debugger, and newer host tools that refuse to talk to it at all. None of those announce themselves as a firmware problem, and all of them will send you looking at your code instead.

The upgrade takes two minutes. Install STM32CubeProgrammer, choose ST-LINK in the connection dropdown at the top right, and press Firmware upgrade in the ST-LINK configuration panel. ST also ship the same utility standalone as STSW-LINK007, which is the STLinkUpgrade window you end up in either way: press Open in update mode, then Upgrade. You do not need to connect to the target MCU first, whatever other guides tell you, because the tool talks to the debugger rather than through it. Close STM32CubeIDE, any OpenOCD session, and any serial terminal on the board beforehand, because only one program at a time can hold the ST-LINK.

Leave the "Change Type" checkbox alone. It is how you would switch the debugger between firmware variants, and the one you want is the default already reported: STM32 Debug+Mass storage+VCP. All three parts earn their place here. Debug is west flash and west debug, mass storage is the drag-and-drop fallback below, and VCP is the console and shell you will spend the second half of this article inside. A variant without mass storage is a reasonable thing to want on a production bench, where an unexpected removable drive is a nuisance, but on this board it would quietly delete two of the four tools it was chosen for.
Figure 2. The entry point, in STM32CubeProgrammer's ST-LINK configuration panel: Firmware version V2J33M25 and the Firmware upgrade button beneath it. Note the header still reads "Not connected" and the target voltage is already showing 3.26 V. The tool reads and updates the debugger without ever attaching to the STM32, which is why there is no Connect step in the instructions above.
Figure 3. STLinkUpgrade after pressing Open in update mode. The current type is STM32 Debug+Mass storage+VCP and the pending update keeps it. The M in V2J33M25 is the mass-storage variant, which is what "Change Type" would let you abandon, so leave it unchecked.
Figure 4. Seven years of catching up, in about two minutes: V2J33M25 to V2J48M35, same type, "Upgrade successful". Afterwards DETAILS.TXT on the mass-storage drive reports that version string directly instead of the uninformative 0221, so on a freshly updated board the drive is a reliable place to check.
west flash -d build-blinky

On this board that produces:

-- west flash: using runner stm32cubeprogrammer
      -------------------------------------------------------------------
                       STM32CubeProgrammer v2.23.0
      -------------------------------------------------------------------

ST-LINK SN  : 0668FF495051727187064745
ST-LINK FW  : V2J48M35
Board       : NUCLEO-F401RE
Voltage     : 3.26V
SWD freq    : 4000 KHz
Connect mode: Under Reset
Reset mode  : Hardware reset
Device ID   : 0x433
Device name : STM32F401xD/E
NVM size    : 512 KBytes
Device CPU  : Cortex-M4

Opening and parsing file: zephyr.hex
  Size          : 16.89 KB
  Address       : 0x08000000

Erasing internal memory sectors [0 1]
Download in Progress:
██████████████████████████████████████████████████ 100%

File download complete
Time elapsed during download operation: 00:00:00.692

The green LED LD2 should now blink once per second. Note the board identifying itself and reporting the firmware version from the upgrade above, which is a solid confirmation that everything upstream is wired correctly.

⚠️
west flash does not use OpenOCD on this board, and that matters if you skipped the firmware upgrade. Read that first line again: using runner stm32cubeprogrammer. Zephyr calls its flashing back-ends runners, and each board declares which ones it supports and in what order. ST's boards/st/nucleo_f401re/board.cmake lists three, with a comment:
# keep first
include(${ZEPHYR_BASE}/boards/common/stm32cubeprogrammer.board.cmake)
include(${ZEPHYR_BASE}/boards/common/openocd-stm32.board.cmake)
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)

First in the list is the default, so a bare west flash reaches for STM32CubeProgrammer, and here is the catch: that tool is not part of the Zephyr SDK. It is a separate ST download. Everything in the toolchain section above, Chocolatey and west sdk install included, leaves you without it. Follow this guide to the letter, skip the firmware-upgrade note, and your very first west flash fails on a missing tool rather than on anything you did wrong. I only avoided that by accident: STM32CubeProgrammer was already installed, because upgrading the ST-LINK firmware an hour earlier required it. Those two steps look unrelated and are not.

You have two ways out:
- Install STM32CubeProgrammer, which you want anyway for the firmware upgrade, and change nothing else.
- Ask for OpenOCD explicitly with west flash -r openocd. OpenOCD does ship in the SDK, at zephyr-sdk\hosttools\openocd, so this route needs no additional download at all.

Run west flash -d build-blinky --context at any time to see the runners available for your board and which one is the default. It is the fastest way to answer "what is this command actually about to do?"

If west flash gives you any trouble at all, remember the zero-software fallback. The board appears as a USB drive named NOD_F401RE, and copying the binary onto it programs the chip just the same:

copy .\build-blinky\zephyr\zephyr.bin I:
Figure 5. Proof that the drag-and-drop actually programmed something. This capture was taken later in the article's life, on a board running the four-thread scheduler demo, and it shows the moment blinky replaces it: the leftover uart:~$ prompt at the top belongs to the old firmware, then the board reboots and starts reporting LED state: instead. Blinky has no shell, so the prompt never comes back. Setting up this serial terminal is covered in Build, flash, connect; you do not need it yet.

Note the extension. The runner above flashed zephyr.hex, because STM32CubeProgrammer prefers hex and reads the load address out of the file. The mass-storage route needs the .bin, because a raw binary carries no address information and the bootloader simply assumes the start of flash.

💡
The file you just copied will vanish, and that is success rather than failure. Watch the drive as the copy completes and you will see the file appear for a moment, the board reset itself, and the file disappear, leaving the drive exactly as it was. Nothing has gone wrong. That drive is not storage: there is no filesystem behind it holding your file. The ST-LINK presents a FAT volume that exists only to catch a write, and when one arrives it programs those bytes into the STM32's flash, resets the target, and re-presents an empty volume. The file was never saved anywhere, it was consumed.

This raises the obvious question of how you would know if it had failed, given that a successful program and a rejected one both end with your file gone. The answer is that this style of bootloader reports errors by writing a file back at you: if the programming fails, a FAIL.TXT appears on the drive with a one-line reason inside it. So the check after a drag-and-drop is not "is my file still there", which is always no, but "is there a FAIL.TXT", which should also be no. A drive holding nothing but DETAILS.TXT and MBED.HTM is a clean result.

One warning about testing this, because it is easy to fool yourself, and I nearly did. If you drag blinky onto a board that is already running blinky, a working programmer and a completely dead one produce an identical blinking LED. The test proves nothing, and it feels like it proved everything.

To make it mean something, put something visibly different on the board first and then watch it be replaced. That is what the capture above does: the board was running the four-thread demo from the rest of this article, complete with its interactive shell, and after the copy it is running blinky and printing LED state: with no shell at all. There is no way to fake that. A dead programmer leaves the shell exactly where it was. When you test whether a thing works, ask what a broken version would have looked like. If the answer is "the same", you have not run a test yet.

The project: a scheduler you can see

Blinky proven, here is the full project. We are going to use real GPIOs with external monitoring to ensure that everything runs exactly as expected. There are four threads, four GPIO pins on the Arduino header, with one probe wire each:

Probe Header pin MCU pin Thread Priority Behaviour
A D2 PA10 busy_a 5 Toggles flat out inside a 30 ms window, every 100 ms
B D3 PB3 busy_b 5 Identical twin of busy_a
C D4 PB5 ticker 3 A 1 ms pulse every 25 ms
D D5 PB4 responder 1 A burst of 5 pulses when you press the blue button

Ground the analyser to any GND pin on the header and clip channels onto D2 to D5. If you have a fifth channel spare, put it on PC13 (the button, reachable on the morpho header): it costs little effort now and we will use it to measure interrupt latency later.

💡
1 MHz is plenty for everything this article asks you to see, because what you are reading is the envelope of activity (i.e., is this thread running or not) rather than individual edges. The busy workers toggle far faster than 1 MHz can represent, so if you zoom all the way in, the waveform aliases into ragged noise. That is the sampling, not the firmware. Judge the blocks, not the edges. The one measurement that does need a fast capture is the button latency at the end, which is a tens-of-microseconds figure. Take that one as a separate, triggered capture at the highest rate your analyser offers. At 1 MHz a 10 µs interval is ten samples, so your resolution would be 10% of the number you are trying to report.

Before the code, the three scheduling ideas the demo exists to demonstrate. If get these, you know more about RTOS behaviour than many:

  1. Priorities: lower number wins. A Zephyr thread's priority is a small integer, and numerically lower means more urgent, so our priority-1 responder outranks every other thread we write. (Negative priorities exist too: those threads are cooperative, meaning they cannot be pre-empted at all. Our four application threads stay in the non-negative, pre-emptible range, though as the shell will shortly reveal, the kernel has already put a cooperative thread in your system without asking.)
  2. Equal priorities share via timeslicing. busy_a and busy_b both have priority 5 and both want the CPU at the same instant. With CONFIG_TIMESLICE_SIZE=10, the kernel gives each a 10 ms slice in turn. On the analyser this appears as alternating 10 ms blocks of activity on D2 and D3, which is round-robin scheduling drawn for you using silicon.
  3. Interrupts hand work to threads. The button triggers an interrupt handler, and interrupt handlers must be short: no sleeping, no printing, no real work. The pattern, used constantly in real firmware, is for the handler to give a semaphore and return; a dedicated thread blocked on that semaphore wakes and does the work at whatever priority it deserves. Here that thread is the highest-priority one in the system, so the burst on D5 begins within microseconds of the press, regardless of what the busy threads were doing.
💡
What a semaphore is, briefly. A counter with a queue attached. k_sem_give() adds one to the count, k_sem_take() removes one, and a thread that tries to take from a count of zero is put to sleep until somebody gives. That is exactly why it suits this job: the handler only has to increment a number, which is quick and safe to do inside an interrupt, and the waiting thread does the slow part afterwards at its own priority. Ours is declared K_SEM_DEFINE(button_sem, 0, 1), so it starts empty and counts no higher than one, which means presses arriving while the responder is still working collapse into a single wake-up rather than queueing up.

The project: in simulation

MODELLED FROM THE ARTICLE'S PARAMETERS D2busy_a p5 D3busy_b p5 D4ticker p3 D5responder p1 01020 30405060 ms busy_a keeps the CPU for 20 ms across the ticker's notch, because a pre-empted thread is handed a fresh timeslice

The devicetree overlay

The board's devicetree already names the LED (led0, which is LD2 on PA5) and the button (sw0, B1 on PC13). Our four probe pins are our own invention, so we declare them in an application overlay, a fragment merged over the board's devicetree at build time. The conventional home for application-specific properties that no driver claims is the zephyr,user node.

Create the project as a sibling of the samples, with this shape:

een-nucleo-rtos
│   CMakeLists.txt
│   prj.conf
├───boards
│       nucleo_f401re.overlay
└───src
        main.c

boards/nucleo_f401re.overlay:

/*
 * Four "probe" pins for a logic analyser, on the Arduino header where they
 * are easy to reach. The zephyr,user node is the conventional home for
 * application-specific devicetree properties that no driver claims.
 *
 *   probe-a  D2  PA10   busy worker A (priority 5)
 *   probe-b  D3  PB3    busy worker B (priority 5)
 *   probe-c  D4  PB5    periodic ticker (priority 3)
 *   probe-d  D5  PB4    button responder (priority 1)
 */
/ {
	zephyr,user {
		probe-a-gpios = <&gpioa 10 GPIO_ACTIVE_HIGH>;
		probe-b-gpios = <&gpiob 3 GPIO_ACTIVE_HIGH>;
		probe-c-gpios = <&gpiob 5 GPIO_ACTIVE_HIGH>;
		probe-d-gpios = <&gpiob 4 GPIO_ACTIVE_HIGH>;
	};
};

Because the overlay lives in a boards/ directory and is named after the board target, west picks it up automatically. To port this project to another board you would add, say, boards/xiao_ble.overlay with that board's four spare pins, and touch nothing else.

💡
Look at the board's own .dts file and you will find that LD2's pin, PA5, is also SPI1's clock and Arduino pin D13, a sharing arrangement inherited from the original Arduino Uno layout. Your code neither knows nor cares: it asks for led0 and the devicetree resolves the rest. When we build for a board whose LED is wired active-low (the XIAO, for instance), the GPIO_ACTIVE_LOW flag in its devicetree inverts the electrical level automatically, and the same C code still just says "LED on".

The configuration

prj.conf:

# --- Hardware we touch ---
CONFIG_GPIO=y               # the LED, the button, and the four probe pins

# --- Make the scheduler's behaviour observable ---
# Threads of EQUAL preemptive priority share the CPU in round-robin slices.
# 10 ms slices are long enough to see as alternating blocks on a logic
# analyser, short enough that the demo still feels instant.
CONFIG_TIMESLICING=y
CONFIG_TIMESLICE_SIZE=10
# The MOST URGENT priority that gets sliced. 0 means "slice every
# preemptible thread"; raise it to protect urgent threads from being
# interrupted by an equal-priority peer.
CONFIG_TIMESLICE_PRIORITY=0

# --- Introspection: a shell over the ST-LINK virtual COM port ---
# `kernel thread list` prints every thread with its priority, state and
# stack use; `kernel uptime` and `kernel version` also come from KERNEL_SHELL.
CONFIG_SHELL=y
CONFIG_KERNEL_SHELL=y
CONFIG_THREAD_NAME=y
CONFIG_THREAD_MONITOR=y
CONFIG_THREAD_STACK_INFO=y
# Adds a CPU-usage column to `kernel thread list`, which lets us check the
# analyser's picture against the kernel's own accounting.
CONFIG_THREAD_RUNTIME_STATS=y

Three things deserve a comment:

  1. CONFIG_TIMESLICE_SIZE is the important entry: set it to 0 later and watch what happens to the two busy threads (spoiler in the experiments section). Its neighbour CONFIG_TIMESLICE_PRIORITY is less obvious and worth knowing about, because it is the escape hatch. It names the most urgent priority level that timeslicing applies to, so 0 means every preemptible thread is fair game. Set it to 5 and threads at priorities 0 to 4 are exempt: they run until they block, even against an equal-priority peer. That is what you want for a thread doing something that must not be chopped in half.
  2. CONFIG_THREAD_RUNTIME_STATS costs a little RAM and buys a CPU-usage percentage per thread in the shell. It matters here because it gives you a second, independent measurement of the same behaviour the analyser shows: the traces tell you when each thread ran, and the shell tells you how much of the CPU each one got in total. When two instruments that work in completely different ways agree, you have understood something.
  3. Finally, the shell block costs about 20 KB of flash and gives you a live command line into the kernel over the same USB cable, which for a learner is worth ten textbooks.

The code

src/main.c, complete. If the brace placement looks unfamiliar, Zephyr follows the Linux kernel style (braces on the same line for control flow, on their own line for functions, tabs of eight); I keep to it so the code reads like the Zephyr tree you will spend time in.

// src/main.c
// "The scheduler on a scope": four threads whose behaviour you can watch
// on a logic analyser, one probe pin each. The pin mapping lives in the
// per-board overlay under boards/ -- this file is board-agnostic.
//
//   probe-a  busy_a     priority 5   toggles as fast as it can
//   probe-b  busy_b     priority 5   identical twin of busy_a
//   probe-c  ticker     priority 3   1 ms pulse every 25 ms
//   probe-d  responder  priority 1   pulse burst on a button press
//   led0                             toggled by the responder
//
// What the traces show:
//   * A and B have EQUAL priority, so with CONFIG_TIMESLICE_SIZE=10 the
//     kernel round-robins them: the analyser shows alternating 10 ms
//     blocks of activity on D2 and D3. Each thread therefore gets only
//     about HALF the CPU inside the wall-clock window it asked for --
//     see busy_worker() below, and the "What the analyser shows" section.
//   * The ticker has HIGHER priority (lower number), so its 1 ms pulse
//     punches a 1 ms gap into whichever busy thread was running.
//   * The button gives a semaphore from an interrupt handler; the
//     responder is the highest-priority thread we define, so its burst
//     pre-empts the others almost instantly after the press.

#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/sys/printk.h>
#include <zephyr/version.h>

// --- Devicetree: the board gives us the LED and button by alias; the four
// --- probe pins come from the zephyr,user node in our overlay.
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios);
static const struct gpio_dt_spec probe_a =
	GPIO_DT_SPEC_GET(DT_PATH(zephyr_user), probe_a_gpios);
static const struct gpio_dt_spec probe_b =
	GPIO_DT_SPEC_GET(DT_PATH(zephyr_user), probe_b_gpios);
static const struct gpio_dt_spec probe_c =
	GPIO_DT_SPEC_GET(DT_PATH(zephyr_user), probe_c_gpios);
static const struct gpio_dt_spec probe_d =
	GPIO_DT_SPEC_GET(DT_PATH(zephyr_user), probe_d_gpios);

// --- The busy workers: equal priority, so they demonstrate timeslicing.
// Each wakes at the same absolute instant (a multiple of PERIOD_MS), spends
// a BUSY_MS WINDOW toggling its probe pin flat out, then sleeps until the
// next period. Because both are ready at the same tick and neither ever
// blocks while working, only the scheduler's round-robin timeslice decides
// who runs -- which is exactly what we want to see.
#define PERIOD_MS      100
#define BUSY_MS        30
#define TOGGLE_BATCH   64

// Threads must not touch their probe pins until main() has configured them
// as outputs. Right now main's priority (0) happens to beat theirs, so it
// finishes first anyway -- but relying on that is exactly the kind of
// accident that breaks when someone changes a priority. The start delay
// makes the ordering explicit. Absolute-time sleeps mean the delay costs
// nothing: both workers still re-align on the same 100 ms boundary.
#define START_DELAY_MS 50

static void busy_worker(void *p1, void *p2, void *p3)
{
	const struct gpio_dt_spec *probe = p1;
	int64_t next_wake = 0;

	while (true) {
		k_sleep(K_TIMEOUT_ABS_MS(next_wake));

		// NOTE this is a WALL-CLOCK deadline, and deliberately so.
		// The 30 ms keeps elapsing while the *other* worker holds the
		// CPU, so each thread ends up with roughly half of it. That
		// difference between elapsed time and CPU time is the whole
		// lesson; see "What the analyser shows".
		int64_t deadline = k_uptime_get() + BUSY_MS;

		while (k_uptime_get() < deadline) {
			// Batch the toggles. k_uptime_get() costs far more than
			// gpio_pin_toggle_dt(), so checking the clock on every
			// edge would make the pin an order of magnitude slower
			// than the "flat out" this is meant to represent.
			for (int i = 0; i < TOGGLE_BATCH; i++) {
				gpio_pin_toggle_dt(probe);
			}
		}
		next_wake += PERIOD_MS;
	}
}

K_THREAD_DEFINE(busy_a, 1024, busy_worker, (void *)&probe_a, NULL, NULL, 5, 0,
		START_DELAY_MS);
K_THREAD_DEFINE(busy_b, 1024, busy_worker, (void *)&probe_b, NULL, NULL, 5, 0,
		START_DELAY_MS);

// --- The ticker: higher priority than the busy workers. k_busy_wait()
// spins without yielding, so the 1 ms pulse is 1 ms of stolen CPU -- the
// gap it leaves in the busy workers' waveforms is pre-emption made visible.
static void ticker_fn(void *p1, void *p2, void *p3)
{
	while (true) {
		k_sleep(K_MSEC(24));
		gpio_pin_set_dt(&probe_c, 1);
		k_busy_wait(1000);
		gpio_pin_set_dt(&probe_c, 0);
	}
}

K_THREAD_DEFINE(ticker, 1024, ticker_fn, NULL, NULL, NULL, 3, 0,
		START_DELAY_MS);

// --- The responder: the classic interrupt-to-thread pattern. The button's
// interrupt handler must be short (no sleeping, no slow work), so it only
// gives a semaphore. The responder thread, blocked on that semaphore, does
// the actual work at the highest priority in the application.
static K_SEM_DEFINE(button_sem, 0, 1);

static void button_pressed(const struct device *dev, struct gpio_callback *cb,
			   uint32_t pins)
{
	static int64_t last_ms;
	int64_t now = k_uptime_get();

	// Crude debounce: ignore edges within 200 ms of the last accepted one.
	if (now - last_ms > 200) {
		last_ms = now;
		k_sem_give(&button_sem);
	}
}

static void responder_fn(void *p1, void *p2, void *p3)
{
	while (true) {
		k_sem_take(&button_sem, K_FOREVER);
		gpio_pin_toggle_dt(&led);
		for (int i = 0; i < 5; i++) {
			gpio_pin_set_dt(&probe_d, 1);
			k_busy_wait(500);
			gpio_pin_set_dt(&probe_d, 0);
			k_busy_wait(500);
		}
		printk("button: burst sent, LED toggled\n");
	}
}

K_THREAD_DEFINE(responder, 1024, responder_fn, NULL, NULL, NULL, 1, 0, 0);

// --- main() configures the pins and the button interrupt, prints a banner,
// and then RETURNS. That is fine: main is just another thread in Zephyr,
// and the four K_THREAD_DEFINE threads above keep running without it.
static struct gpio_callback button_cb;

static int configure_output(const struct gpio_dt_spec *spec, const char *name)
{
	if (!gpio_is_ready_dt(spec)) {
		printk("error: %s not ready\n", name);
		return -ENODEV;
	}
	return gpio_pin_configure_dt(spec, GPIO_OUTPUT_INACTIVE);
}

int main(void)
{
	printk("=== scheduler-on-a-scope: Zephyr %s on %s ===\n",
	       KERNEL_VERSION_STRING, CONFIG_BOARD);

	configure_output(&led, "led0");
	configure_output(&probe_a, "probe-a");
	configure_output(&probe_b, "probe-b");
	configure_output(&probe_c, "probe-c");
	configure_output(&probe_d, "probe-d");

	if (!gpio_is_ready_dt(&button)) {
		printk("error: button not ready\n");
		return 0;
	}
	gpio_pin_configure_dt(&button, GPIO_INPUT);
	gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE);
	gpio_init_callback(&button_cb, button_pressed, BIT(button.pin));
	gpio_add_callback(button.port, &button_cb);

	printk("probes A-D and the button are mapped in boards/%s.overlay\n",
	       CONFIG_BOARD);
	printk("press the button; try `kernel thread list` in the shell\n");

	return 0;
}

And the two-line CMakeLists.txt that turns the directory into a Zephyr application:

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(nucleo_rtos_scope LANGUAGES C)

target_sources(app PRIVATE src/main.c)

A few things in the listing need a second look:

  • K_THREAD_DEFINE creates threads at compile time. No main() involvement: the kernel readies all four before main() even runs. The arguments are name, stack size in bytes, entry function, three void * parameters, priority, options, and start delay. Passing &probe_a as a parameter is how the two busy workers share one function.
  • k_sleep(K_TIMEOUT_ABS_MS(...)) sleeps until an absolute time, not for a duration. Both busy workers therefore wake on exactly the same tick, every 100 ms, which is what forces the scheduler to arbitrate between them. Relative sleeps would slowly drift apart and the round-robin pattern would degrade.
  • k_busy_wait() versus k_sleep() is the difference between hogging the CPU and giving it back. The busy workers and the ticker use k_busy_wait (or a spin loop) deliberately, because we want them to compete for the CPU. In production firmware you sleep; in this article, hogging is important for the demonstration.
  • main() returns and nothing stops. In Zephyr, main is simply a thread the kernel starts for you; when it returns, its stack is reclaimed and the application continues. Beginners from the Arduino world, where leaving loop() is unthinkable might find this strange.
⚠️
A startup race you should know about, because it is invisible when it works. Put those first and last bullets together and something falls out: the kernel makes four threads runnable before main() executes, and main() is the function that configures the probe pins those threads immediately start toggling.

The demo works anyway, and the reason is worth discussing. main() is a thread too, running at CONFIG_MAIN_THREAD_PRIORITY, which defaults to 0, which is more urgent than the ticker at 3 and the workers at 5. So main() runs to completion first, and by the time any worker sees the CPU, its pin is an output. The ordering that keeps this program correct is a priority accident, not a guarantee.

That is why the START_DELAY_MS argument is there. It has no cost (the absolute-time sleep re-aligns both workers on the next 100 ms boundary regardless) and it turns an accident into a statement. So, statically defined threads can run before your initialisation does, which means you either order them explicitly or do the initialisation somewhere the kernel guarantees runs first, such as a SYS_INIT hook. The failure mode if you get it wrong is a pin that does nothing, which is far harder to find.
💡
State that only one place may touch. Notice that only the interrupt handler writes last_ms, and only the responder thread touches probe_d and the LED. Deciding which thread owns which state is the discipline that keeps multithreaded firmware sane, and it is worth practising even in a demo this small. The moment two threads write the same variable, you need the kernel's synchronisation tools (the semaphore here is the first of them) rather than hope.

Build, flash, connect

west build -b nucleo_f401re een-nucleo-rtos -d build-nucleo

The build ends with the memory report, and it is worth noting:

Memory region         Used Size  Region Size  %age Used
           FLASH:       48716 B       512 KB      9.29%
             RAM:       13824 B        96 KB     14.06%
           SRAM0:           0 B        96 KB      0.00%
        IDT_LIST:           0 B        32 KB      0.00%

An entire operating system (scheduler, driver model, GPIO and UART drivers, an interactive shell) plus our application, in 48 KB of flash and 14 KB of RAM. Set that beside blinky's 17,296 bytes from earlier and the arithmetic is informative: four threads, a semaphore, a button interrupt and five GPIOs account for almost none of the difference. Practically the whole of that extra 31 KB is the shell.

💡
ninja: no work to do. and no memory report is not a failure. Run west build twice in a row and the second run prints that line and nothing else. The report you are reading here is produced by the link step, so when nothing needs relinking there is no report to print. It means your build is already current.

It does become confusing when you expected a rebuild and did not get one, usually after editing a file outside the source tree or pulling new Zephyr commits. Two ways to be certain: west build -d build-nucleo -t rom_report breaks the flash usage down symbol by symbol without needing a rebuild at all, or add --pristine to start from nothing, at the cost of a full recompile. The --pristine discussion earlier in this article covers when you genuinely need the second one. This footprint is why Zephyr scales down to sensors that run for years on coin cells.

For a sense of how much of that is the shell rather than the kernel, compare it with a build I measured on a different board in the vanilla Zephyr article of my UNO Q series. That one carries the same pre-emptive kernel plus a display driver running a 104-pixel scan from a timer interrupt, plus a sensor subsystem, and comes in at 32,188 bytes of flash and 4,944 bytes of RAM, which is considerably smaller than this one despite doing more, because it never asked for a shell. Two real builds, same RTOS, and the difference between them is almost entirely one CONFIG_ line. That is the point of Kconfig in a single comparison: you pay for what you ask for, and nothing else.

Flash it (west flash -d build-nucleo, or drag build-nucleo\zephyr\zephyr.bin onto the NOD_F401RE drive), then open a serial terminal on the ST-LINK's virtual COM port at 115200 baud, 8N1. On Linux that is screen /dev/ttyACM0 115200. On Windows it is PuTTY on the port that appears in Device Manager as "STMicroelectronics STLink Virtual COM Port", and rather than hunting through the list you can ask for it directly:

Get-PnpDevice -Class Ports -Status OK |
    Where-Object InstanceId -like 'USB\VID_0483&PID_374B*' |
    Select-Object FriendlyName
FriendlyName
------------
STMicroelectronics STLink Virtual COM Port (COM15)

0483 is STMicroelectronics and 374B is the ST-LINK/V2-1, so that filter finds the board and ignores everything else plugged in. The number in brackets is what you type into PuTTY, and it will likely not be COM15 on your machine. Expect it to move if you use a different USB socket.

Figure 6. The board in Device Manager under Ports (COM & LPT). COM15 here; yours will differ.

Press reset and you should see the banner, and pressing Enter gives you a uart:~$ prompt.

Figure 7. What a connection that has missed the banner looks like: a bare uart:~$ prompt and nothing above it. The shell is working perfectly. See the box below.
💡
Connected, and the window is empty except for a prompt? Nothing is broken. This is the classic first Zephyr console session. The banner is printed by main() a few microseconds after boot, and the board boots the instant west flash finishes, long before you have PuTTY open. By the time you attach, that text has been and gone: a UART has no scrollback, and nothing is buffered on the board waiting for a reader to appear. Press Enter and the shell draws a prompt, which makes the emptiness above it look like a failure rather than simply the past. Press the black reset button (B2) with the terminal already open and the whole sequence appears:

*** Booting Zephyr OS build v4.4.0-7043-g777ab585520e ***
=== scheduler-on-a-scope: Zephyr 4.4.99 on nucleo_f401re ===
probes A-D and the button are mapped in boards/nucleo_f401re.overlay
press the button; try `kernel thread list` in the shell
uart:~$

The first line is Zephyr's own, from CONFIG_BOOT_BANNER, and it is a useful one to recognise: it tells you the kernel reached the end of its own initialisation, so anything missing after it is your application's fault rather than the system's. The three that follow are the printk calls in main(). Since the reset button is right there, get into the habit of opening the terminal first and resetting second. It is also the reason printk alone is a poor debugging strategy on a board like this, and part of why the shell in the next section is worth its 31 KB: a shell you can interrogate at any moment does not care whether you were watching at boot.
Figure 8. The same session after pressing reset. Note that the boot banner runs on from the leftover uart:~$ prompt at the top: the board has restarted, but your terminal has not, and it simply carries on writing where it left off.

Why the banner says nucleo_f401re while the build output said nucleo_f401re/stm32f401xe. Both are correct, and they are different symbols. Modern Zephyr splits a board into a name and one or more qualifiers identifying the SoC, core or variant being targeted, so this build carries all of:

CONFIG_BOARD="nucleo_f401re"
CONFIG_BOARD_QUALIFIERS="stm32f401xe"
CONFIG_BOARD_TARGET="nucleo_f401re/stm32f401xe"

Our printk uses CONFIG_BOARD, so it prints the short name; the build system reports CONFIG_BOARD_TARGET. On a single-core part like this one the distinction is cosmetic, but it stops being cosmetic on a multi-core SoC, where board/soc/cpucluster is what tells you which processor you just built for. If you ever want the full string in your own output, use CONFIG_BOARD_TARGET.

Figure 9. The Nucleo-F401RE headers, from ST's own documentation. Magenta is the Arduino R3 connector, blue the ST morpho. Everything this article needs is on here: the four probe pins are D2 to D5 at the bottom of CN9, and the user button is PC13, twelve rows down CN7 at pin 23. Note that PC13 appears only on the morpho side, which is why the button needs a lead to the inner header rather than a clip on the Arduino row with the others.

The pins this article uses, verified against boards/st/nucleo_f401re/arduino_r3_connector.dtsi and st_morpho_connector.dtsi in the Zephyr tree rather than against the pinout diagram:

Signal Header Pin STM32 Used for
probe A Arduino CN9 D2 PA10 busy_a, priority 5
probe B Arduino CN9 D3 PB3 busy_b, priority 5
probe C Arduino CN9 D4 PB5 ticker, priority 3
probe D Arduino CN9 D5 PB4 responder, priority 1
button Morpho CN7 pin 23 PC13 B1, for the latency measurement
ground either any GND analyser reference, not optional

The button is the one that needs finding. B1 is not brought out to the Arduino header at all, so its pin is only reachable on the left-hand morpho header, and it is active low with a pull-up: a press is a falling edge. You do not need it for the round-robin captures below, only for the interrupt latency measurement at the end.

What the analyser shows

With probes on D2 to D5 and a capture spanning a few hundred milliseconds, you should see the following. Every figure and every number in this section was measured on the board described above; none of it is simulated or estimated.

Figure 10. The whole rig, which is less than it looks. A Digilent Analog Discovery on the left, its flywire harness landing on the Arduino digital header for D2 to D5, one ground lead, and a longer lead reaching round to PC13 on the morpho header for the button. The board's only other connection is the Mini-B cable carrying power, programming, debug and console together. Note the two red LEDs: LD1 reporting ST-LINK traffic and LD3 the power rail. The green LD2 is dark, because in this firmware it is toggled only by the button responder, and nobody has pressed anything.
Figure 11. The establishing shot, at 20 ms/div. D2 and D3 are the two equal-priority workers, D4 the ticker, D5 the button responder (idle), PC13 the button itself (unpressed). The workers' dense toggling renders as solid bands, and the cursors span one full cycle: ΔX = 100 ms, 9.9989 Hz, confirming PERIOD_MS. Everything the rest of this section measures is somewhere in this picture.
Figure 12. One timeslice, at 10 ms/div. Cursors bracket a single uninterrupted D2 block: ΔX = 10.047 ms, which is CONFIG_TIMESLICE_SIZE=10 measured to within half a percent. The D2: 1757↑ 1758↓ readout is the edge count within the cursors, and it is used later to work out the notch width without having to measure it directly.
Figure 13. The silence, with D3 selected (green) and reading 0↑ 0↓ across ΔX = 54.68 ms. Confirming zero edges matters: the active span ends only when both workers stop, so a gap that is quiet on one channel is not yet a gap. Subtracting from the 100 ms period gives an active span of ≈45 ms, not the 40 ms the model below predicts.
Figure 14. The measurement that overturns the model, at 5 ms/div. Cursors bracket a single D2 block that the ticker interrupted, visible as the notch aligned with D4's pulse: ΔX = 19.97 ms. A thread that merely lost the ticker's 1 ms and kept the rest of its slice would give 11 ms. Instead the block is twice a timeslice long, because being pre-empted handed the worker a brand-new slice.

The round-robin. Every 100 ms, D2 and D3 come alive, and they never overlap: A toggles for 10 ms while B is silent, then they swap. That alternation is CONFIG_TIMESLICE_SIZE=10 operating on two equal-priority threads, and zooming in on a boundary lets you measure the slice width yourself.

Four measurements from that capture, all taken with WaveForms cursors rather than by eye:

Measurement Value
Period, wake to wake 100.0 ms (10.000 Hz)
One timeslice 10.047 ms
Active span, first edge to last ≈ 45 ms
Silence ≈ 55 ms

The period confirms PERIOD_MS, and the slice confirms CONFIG_TIMESLICE_SIZE=10 to within half a percent. The interesting one is the span, because a naive reading of the code expects something else entirely.

Elapsed time is not CPU time, and this trace is the proof. The busy worker sets its deadline with k_uptime_get() + BUSY_MS. That is a wall-clock deadline, and wall-clock time keeps passing while the other thread is running. Each worker asked for 30 ms of work, so the naive expectation is 60 ms of activity per period. Trace it through instead, ignoring the ticker for a moment:

Time What happens
t=0 Both wake. A is picked, and sets its deadline at 30.
t=10 Slice expires. B runs, and sets its deadline at 40.
t=20 Back to A, which checks 20 < 30 and carries on.
t=30 A's check fails. A stops after two slices, 20 ms of CPU. B runs.
t=40 B's check fails and stops too.

Each thread asked for a 30 ms window and got roughly 20 ms of CPU inside it, because it was sharing. This is the single most common misconception about multithreaded systems, and it is why a "30 ms task" can silently miss a 30 ms deadline the moment a second thread appears at the same priority. On a single core, asking for time and receiving it are different things, and the difference is whatever the scheduler decides.

Confirm it from the kernel rather than from the traces. kernel thread list reports busy_a at 16% and busy_b at 23%, summing to 39% of the CPU. Two threads getting 20 ms each out of every 100 ms would be 40%. The wall-clock argument holds, measured by a mechanism that knows nothing about your analyser.

⚠️
But the span is 45 ms, not the 40 ms that table predicts, and the reason is worth more than the tidy version. The table above has an omission: it leaves out the ticker. Every 25 ms the priority-3 thread pre-empts whichever worker is running, and here is the part that is easy to miss:/* zephyr/kernel/include/kswap.h */
z_time_slice_reset(new_thread);

Zephyr restarts the timeslice every time a thread is switched in, not only when a slice runs to completion. So a worker interrupted 3 ms into its slice does not resume with 7 ms remaining. It resumes with a fresh 10 ms. The ticker fires about twice inside the active window, and each visit stretches the pattern, which is how 40 ms becomes 45.

That also explains the lopsided CPU split, which is not measurement noise: 16% and 23% came back identical across a reflash and a reboot. B's wall-clock deadline is set later than A's, because B does not start until A's first slice ends, and every ticker pre-emption pushes B's finish out further still. B always finishes last, and it is paid for the privilege.

You can measure the reset happening. Compare two blocks from the same capture. An undisturbed one runs for 10.047 ms, one timeslice. A block with a ticker pulse inside it runs for 19.97 ms. If the worker had merely lost the ticker's 1 ms and kept the rest of its slice, that second block would have measured 11 ms. Instead the ticker arrived about 9.5 ms in, and the worker came back with a full slice in hand: 9.5 + 1 + 10 comes to 20.5 ms, against 19.97 measured. Doubling a thread's run because something more urgent briefly interrupted it is not intuitive, and it is not in any diagram of round-robin scheduling you will find.

The honest summary is that the clean five-row table is a good model and a wrong prediction. It gets the mechanism right and the number wrong, and you only find out which by measuring. That is the entire argument for putting probes on a board rather than reasoning about a scheduler in your head.

The pre-emption notches. D4 carries a clean 1 ms pulse roughly every 25 ms. Whenever one lands inside the busy window, the running busy thread's toggling stops for exactly the width of that pulse and resumes afterwards. The priority-3 ticker does not ask permission: the kernel suspends the priority-5 thread mid-loop, runs the ticker, and hands the CPU back. That silence on D2 or D3, shaped exactly like the pulse on D4, is pre-emption.

You can check the equality without placing a single cursor on the notch, which is worth doing because a notch that narrow is hard to bracket accurately. Use the analyser's edge counter instead. The uninterrupted 10.047 ms block carried 1757 rising edges, so the worker toggles at 174.9 edges per millisecond. The interrupted 19.97 ms block carried 3322. Divide:

3322 ÷ 174.9  = 19.00 ms   <- time actually spent toggling
19.97 − 19.00 =  0.97 ms   <- time spent stopped

0.97 ms of silence, against the k_busy_wait(1000) in ticker_fn. The notch is the ticker, to within 3%.

That the toggle rate comes out the same in both blocks, 174.9 and 175.1 edges per millisecond, is worth noticing too. It says the worker's inner loop runs at a constant rate whether or not it is being interrupted, which is what you would hope, and it means neither measurement is being distorted by the 1 MHz sampling. Counting edges is often a better instrument than measuring times: the count is exact, where a cursor is only as good as your zoom level. If you would rather not take the arithmetic on trust, the direct version is a capture at 10 MHz and 200 µs/div triggered on D4's rising edge, where the pulse and the notch can both be bracketed with cursors.

How fast is "flat out", actually? That question has been hanging over the last two paragraphs, because all of it rests on an edge count taken at 1 MHz, and a signal faster than half the sample rate would produce a plausible-looking count that was quietly wrong. It is also the sort of thing worth knowing for its own sake. Change one instrument setting and you can see individual toggles: 100 MHz, 4 µs/div, triggered on D2 rising. The workers are busy 45 ms in every 100, so the trigger fires almost at once.

Figure 15. The busy worker's inner loop, at 100 MHz and 4 µs/div, which is 2,500 times finer than the round-robin captures. Cursors span six full cycles: ΔX = 33.86 µs, so one cycle takes 5.64 µs.

Two useful numbers fall out. First, the earlier 1 MHz counts are vindicated: 1757 rising edges in 10.047 ms works out at 5.72 µs per cycle, against 5.64 µs measured directly here. Agreement to 1.4% means the sampling was resolving the signal properly, and the notch arithmetic above is sound.

Second, and more surprising, one cycle is two calls to gpio_pin_toggle_dt(), so a single GPIO toggle costs about 2.8 µs. On an 84 MHz Cortex-M4 that is roughly 240 clock cycles to flip one pin. "Flat out" turns out to mean about 88 kHz, not the megahertz you might picture, and the reason is the road the call takes: gpio_pin_toggle_dt() resolves to gpio_pin_toggle(), which calls gpio_port_toggle_bits(), which dispatches through a function pointer into the STM32 driver, which finally does a read-modify-write on the port register. None of it is inlined, and at 84 MHz the flash needs wait states to keep up with the instruction fetch.

That is not a criticism of Zephyr, it is the price of the driver model that lets this same file build for three different boards. But it is worth carrying forward, because it is exactly the kind of cost that stays invisible until you put a probe on it, and it becomes relevant again in the button measurement below.

The button. Press B1 and D5 emits five clean 0.5 ms pulses while LD2 toggles, and it does so even if you press during the busy window, because priority 1 beats the workers and the ticker alike. This is the measurement worth taking properly, and it needs a different capture from everything above: leave Record mode, set the rate to 50 MHz or more, trigger on the falling edge of PC13, and use about 8 µs/div. The whole event is over in 30 microseconds, so a capture built for a 400 ms window cannot see it.

Figure 16. Interrupt to thread, at 8 µs/div and 50 MHz: a different instrument setting from every figure above, because the entire event lasts less time than one pixel of the round-robin captures. PC13 falls at the trigger, D5 rises 26.72 µs later, and D3 stops toggling in between as the priority-5 worker is pre-empted by the priority-1 responder. The D5: 0↑ 0↓ readout confirms the cursors bracket the interval cleanly, with the first output edge exactly at X2.

Then measure from PC13's falling edge to D5's first rising edge, over ten or so presses. Eleven of mine:

Press to first output
Minimum 23.38 µs
Median 25.52 µs
Maximum 29.56 µs
Mean 26.08 µs

Report the maximum, not the average. On a real-time system the typical case is a comfort and the worst case is the specification, and the entire reason for choosing a pre-emptive kernel is to put a bound on that number rather than to make the common case quick.

💡
Two things this figure is not, and both are more interesting than the figure.

It is not pure scheduler latency. Look at responder_fn: before the loop that drives D5, it calls gpio_pin_toggle_dt(&led). So the interval you just measured contains the interrupt entry, the callback, the semaphore, the context switch, and an entire LED write that has nothing to do with the handover. It is a full end-to-end number, "press to first visible output", but it is not the kernel's number. If you want the clean figure, move the LED toggle below the burst and measure again.

We can put a size on that, though, because the previous section measured a GPIO toggle at 2.8 µs. So the LED accounts for roughly 11% of the 26 µs, and the interrupt-to-thread path itself is around 23 µs, or some 1,900 clock cycles from an electrical edge on a pin to application code running in a different thread. That covers hardware interrupt entry, Zephyr's ISR wrapper, the GPIO driver walking its callback list, k_sem_give(), the scheduler deciding a priority-1 thread now outranks the priority-5 one it interrupted, and a full context switch through PendSV. Put like that, 23 µs starts to look less like overhead and more reasonable.

It is not noise. Sort the eleven readings and they do not scatter, they cluster: four near 23.5 µs, three at exactly 25.52 µs, and four near 29 µs. Values repeating to the nanosecond mean the path is deterministic and something discrete is selecting between a small number of routes. The likeliest candidate is what the processor was doing when you pressed: waking the idle thread is a different journey from pre-empting a busy worker mid-loop.

The silence is not nothing. For roughly 55 ms of every 100, no probe moves at all. That is the idle thread, and what it executes is a WFI instruction that stops the core until the next interrupt. You are looking at the processor asleep. That flat region is where all battery-powered design happens: the sleepy sensor work in my XIAO series is, in essence, the art of making it as wide as possible.

Poking the kernel with the shell

The serial prompt is a real shell into the running kernel. Three commands to try immediately:

⚠️
These commands were renamed, and older guides (including earlier drafts of this one) will send you astray. kernel thread grew a set of subcommands, so the plural kernel threads and the standalone kernel stacks no longer exist. Type either and the shell quietly prints the kernel help instead of an error, which is a confusing way to be told you are wrong: it looks as though the command ran and produced the wrong thing. The current spellings are kernel thread list and kernel thread stacks. If a command ever answers you with its own help text, that is the shell's way of saying the syntax did not match, and the listed subcommands are the answer.
uart:~$ kernel thread list
uart:~$ kernel thread stacks
uart:~$ device list

kernel thread list prints every thread in the system with its priority, state, stack usage and share of the CPU. Here is the real thing, abridged only in whitespace:

uart:~$ kernel thread list
Scheduler: 153 since last call
Threads:
 0x200001b0 ticker
        options: 0x0, priority: 3 timeout: 251
        state: sleeping, entry: 0x8000619
        Total execution cycles: 217477890 (4 %)
        stack size 1024, unused 848, usage 176 / 1024 (17 %)
0x200000e8 responder
        options: 0x0, priority: 1 timeout: 0
        state: pending, entry: 0x8000671
        Total execution cycles: 1172 (0 %)
        stack size 1024, unused 824, usage 200 / 1024 (19 %)
0x20000278 busy_b
        options: 0x0, priority: 5 timeout: 0
        state: sleeping, entry: 0x8006b2b
        Total execution cycles: 1275705975 (23 %)
        stack size 1024, unused 824, usage 200 / 1024 (19 %)
0x20000340 busy_a
        options: 0x0, priority: 5 timeout: 222
        state: sleeping, entry: 0x8006b2b
        Total execution cycles: 902170327 (16 %)
        stack size 1024, unused 824, usage 200 / 1024 (19 %)
*0x20000418 shell_uart
        options: 0x0, priority: 14 timeout: 0
        state: queued, entry: 0x8002651
        Total execution cycles: 1534033 (0 %)
        stack size 2048, unused 992, usage 1056 / 2048 (51 %)
0x200005b0 idle
        options: 0x1, priority: 15 timeout: 0
        state: , entry: 0x8008e95
        Total execution cycles: 2964390341 (55 %)
        stack size 320, unused 256, usage 64 / 320 (20 %)
Figure 17. kernel thread list on a freshly reset board. Six threads, our four plus shell_uart and idle, with priority, state, cumulative CPU share and stack high-water mark for each. Note responder at 1,172 cycles: it has been alive since boot and has done essentially nothing, because nobody has pressed the button.

Everything the article has claimed so far is in that listing. busy_a and busy_b sit at priority 5 sharing one entry point, the ticker at 3, the responder at 1 and pending, which is what a thread blocked on k_sem_take(K_FOREVER) looks like from the outside. The asterisk marks the thread doing the printing, which is why it is on shell_uart.

Four things in that output repay a careful read.

The CPU percentages settle the argument from the last section, and they are not the numbers you might expect. With CONFIG_THREAD_RUNTIME_STATS enabled each thread reports its share of total CPU time, so this is a completely independent measurement of the same behaviour the analyser draws. Take the ticker first, because it is the clean one: 4%, and it runs a 1 ms busy-wait every 25 ms, which is 1/25 exactly. The kernel's accounting is trustworthy.

Now the busy workers: 16% and 23%. Neither is the 20% predicted, but add them and you get 39%, against a prediction of 40%, and that is the number that means something. (Those four figures, 4 / 16 / 23 / 55, came back identical on a second run after a reflash and a reboot, so they are the system's steady state rather than a lucky sample.) Two threads each getting 20 ms of CPU out of every 100 ms is 40% of the machine, and that is what the kernel reports. The split between them wanders because these are cumulative counters sampled at an arbitrary instant, and one of them had wrapped. idle then takes 55%, against the 57% left over after 39 and 4. The 55 ms of silence on the analyser and the idle thread's 55% are the same fact, measured twice by unrelated mechanisms, and neither knew about the other.

That is the whole point of taking two measurements. Had the workers come out at 30% each, the wall-clock-versus-CPU-time argument would be wrong.

There are threads in your system that you did not write, and they are not where you would guess. shell_uart at priority 14 and idle at 15 both appeared without being asked for. Note what that means: everything you wrote outranks everything the system added, which is why experiment 5 below can starve the shell simply by working harder. Worth knowing the range you are playing in: this build has CONFIG_NUM_PREEMPT_PRIORITIES=15 and CONFIG_NUM_COOP_PRIORITIES=16, so the pre-emptible band runs 0 to 15 and the cooperative one from -1 down to -16. Our four threads occupy the crowded end of a much larger space.

main is missing, and that is the proof of something from earlier. The listing has six threads and none of them is main. Its stack was reclaimed the moment it returned, exactly as the code comment promised, and the four K_THREAD_DEFINE threads carried on without it. On the Arduino model, where leaving loop() is unthinkable, this takes some getting used to.

kernel thread stacks replaces folklore with numbers. It reports the high-water mark of every stack: the deepest each has actually gone since boot. Run it after pressing the button a few times, so the responder's printk path is included in the worst case.

uart:~$ kernel thread stacks
0x200001b0 ticker     (real size 1024):  unused  848  usage  176 / 1024 (17 %)
0x200000e8 responder  (real size 1024):  unused  784  usage  240 / 1024 (23 %)
0x20000278 busy_b     (real size 1024):  unused  824  usage  200 / 1024 (19 %)
0x20000340 busy_a     (real size 1024):  unused  824  usage  200 / 1024 (19 %)
0x20000418 shell_uart (real size 2048):  unused  984  usage 1064 / 2048 (51 %)
0x200005b0 idle       (real size  320):  unused  256  usage   64 /  320 (20 %)
0x20002600 IRQ 00     (real size 2048):  unused 1792  usage  256 / 2048 (12 %)

Those 1024 figures in the K_THREAD_DEFINE lines were a guess, and the measurement says the guess was generous by a factor of four. The deepest of our four threads is the responder at 240 bytes. Halving all four to 512 would still leave more than twice the observed headroom and hand back 2 KB of RAM.

Two entries you did not create are worth a look. idle is given 320 bytes and uses 64, because a thread whose entire job is WFI needs almost nothing. And IRQ 00 is not a thread at all: it is the interrupt stack, the separate 2 KB region every ISR runs on, CONFIG_ISR_STACK_SIZE. Its 256-byte high-water mark is the deepest your interrupt handlers have nested. Blowing that stack is a particularly baffling class of crash, because the fault appears in whatever thread happened to be interrupted rather than in the handler that caused it, so it is worth knowing the number exists and where to read it.

Finally, device list shows the driver model from the other side.

Figure 18. kernel thread stacks followed by device list. This session had the button pressed a few times, which is why responder shows 240 bytes here against 200 in the listing above. The last row of the stacks output, IRQ 00, is not a thread at all but the shared interrupt stack.

Every device the devicetree instantiated appears with its node label and its initialisation state, and all of them here report READY. Reading down the list you can see the machine assemble itself: rcc the clock controller, then gpioa through gpioh, then exti for the interrupt lines the button needs, then usart1 and usart2. That is the payoff of the devicetree section: you wrote DT_ALIAS(led0) and zephyr,user, and this is the set of real driver instances the build turned that into. When a gpio_is_ready_dt() check fails, this is the first place to look, because a device that is not READY never got past its init function.

Experiments

The firmware is a laboratory; these five experiments each need a one-line change, a rebuild, and a fresh capture. Predict the trace before you look:

  1. Kill the timeslice. Set CONFIG_TIMESLICE_SIZE=0. Equal-priority threads now run until they block, so whichever busy worker starts first keeps the CPU for its whole 30 ms while the other waits, and the alternating blocks collapse into two solid ones. Note the second-order effect, which is the more interesting half: without sharing, each worker now gets the full 30 ms of CPU it asked for, so the total activity per period grows from 40 ms to 60 ms even though nothing about the workload changed. Round-robin is a configuration choice.
  2. Break the tie. Change busy_b's priority from 5 to 6. B now runs only in the time A leaves behind: A's block sits at the start of every period, B's after it, regardless of the timeslice setting. Priorities dominate; timeslicing only arbitrates within a priority.
  3. Demote the ticker. Change the ticker's priority from 3 to 10. Its pulses stay perfectly regular during the quiet 40 ms but arrive late (or pile up) during the busy window, because it now has to wait for the busy threads to finish. This is priority inversion's relation, starvation, and you can measure the jitter directly.
  4. Go cooperative. Change a busy worker's priority from 5 to -1 in its K_THREAD_DEFINE. Cooperative threads cannot be pre-empted at all, so press the button during its busy window and watch the responder's burst wait its turn. Watch D4 as well, because the ticker is blocked by exactly the same rule and its metronome pulses will stall too, which is the more dramatic half of the trace. Two characters changed, and your system's worst-case latency went from microseconds to tens of milliseconds: this is why cooperative priorities are reserved for short, critical sections, and why the sysworkq you met in the shell runs at -1 but does almost nothing.
  5. Starve the shell. Raise BUSY_MS to 95. The busy threads now consume nearly everything, and the shell (a low-priority thread) turns really slow while the button response stays instant. You have just reproduced the classic failure mode of a real product: "the device works but the console is dead", diagnosed in one capture.

Where this leaves you

You installed a complete RTOS toolchain, built and flashed a real multithreaded application, and, more importantly, you watched the scheduler make its decisions: round-robin slices, pre-emption, and interrupt-to-thread handover are now things you have measured rather than read about. The four-file project shape (CMakeLists.txt, prj.conf, an overlay, src/) is the same shape as every larger Zephyr project, including the networked ones later in this series.

In a future article, the same board and the same four-probe demo get rebuilt in async Rust with Embassy, no RTOS underneath, and the analyser gets to referee the comparison: what does cooperative async/await scheduling look like on the same wires, and what do you give up without pre-emption? After that, we take this exact project and run it, unchanged except for overlays, on two more boards.