Testing and Debugging STM32 Embedded Systems Using QEMU Emulator and Docker
Average
13 min
7.7K
C*GitHub*Virtualization*Programming microcontrollers*IT systems testing*
Tutorial
Recommendations
The article will be useful for embedded system developers who want to automate the
testing process of their projects. A separate block is devoted to debugging gdbin the
QEMU emulator. The logging library is used as an example ( GitHub , Habr )
What will you learn from the article?
1. How to set up a build and emulation system using Docker and QEMU for testing
without physical hardware
2. How to Prepare a Test Project Based on STM32CubeMX and Integrate It with
CI/CD
3. How to Automate Testing of STM32 Projects to Ensure Reproducibility
4. How to debug an application in an emulator using familiar tools
Docker build system and QEMU emulator
Why Docker?
When developing embedded systems, we often encounter the problem of "it doesn't
work for me". This happens because of di erences in:
Compiler versions
Installed libraries
Environment settings
Docker solves these problems by providing an isolated environment with precisely
defined versions of all components.
Structure of Docker containers
To create a full-fledged development environment, three containers are used:
1. Cross-compilation container ( Dockerfi[Link] )
o Contains ARM GCC toolchain downloaded directly from
[Link]
o Provides access to the latest version of arm-none-eabi-gcc
o Includes make and other build tools
2. Container with QEMU ( Dockerfile.qemu_stm32 )
o Builds QEMU from source beckus/qemu_stm32
o QEMU compilation only occurs the first time the container is built
o Emulates microcontroller peripherals
> Important: The version of QEMU used is outdated and only supports old STM32
models. There is a need to update the emulator to support modern STM32
microcontrollers. This could be an interesting project for the development community.
3. Container for running tests ( Dockerfile.run_tests )
o Combines the results of previous containers
o Runs tests and collects results
o Generates test reports
QEMU for STM32
QEMU is a powerful emulator capable of emulating various processor architectures. For
working with STM32, a special version of QEMU with support for STM32 peripherals is
used.
Supported Peripherals
At the time of writing, the following are supported for STM32F103C8:
UART
Timers
GPIO
Interruptions
Flash
Watchdog
ADC, DAC
Real time clock
Basic system functions
> Tip: Despite the limitations of the current version of QEMU, this testing approach
can be adapted to other emulators or newer versions of QEMU as they become
available.
Test project
Generating a project in STM32CubeMX
The project is created using STM32CubeMX, which ensures correct initial configuration
of the microcontroller and peripherals.
STM32CubeMX Clock Configuration
Key project settings:
Clocking: HSE 8 MHz with PLL up to 72 MHz
UART1 for log output
GPIO PC13 for LED indication
System Timers for FreeRTOS
FreeRTOS Configuration Window
> FreeRTOS Configuration Using CMSIS-RTOS2 API
When generating code, it is important to select the correct parameters:
Project settings window
> Copying libraries to a project. Using .c and .h files for peripherals.
Code generation settings window
You need to select Makefile as the build system for Docker integration. Increase the
heap size, as dynamic memory is used in FreeRTOS. This is necessary because 10
threads are created during testing, when checking for bu er locks.
Project structure
The project uses the standard STM32CubeMX structure, which is located in the
directory test_project. Scripts for building and testing are located in the directory test.
The library under test is located in the directory liband is copied to the project during
assembly.
├── lib
│ ├── logging.c
│ ├── logging.h
│ ├── logging_usb.c
│ └── logging_usb.h
├── LICENSE
├── [Link]
└── test
├── [Link]
├── Dockerfile.qemu_stm32
├── Dockerfile.run_tests
├── Dockerfi[Link]
├── start_qemu_gdb_mode.sh
├── test_in_docker.sh
├── [Link]
├── test_project
│ ├── Core
│ ├── Drivers
│ ├── logging_cmsis_rtos2
│ ├── Makefile
│ ├── Middlewares
│ ├── startup_stm32f103xb.s
│ ├── STM32F103C8Tx_FLASH.ld
│ └── test_project.ioc
└── verify_output.py
When building, [Link] the logging library to the test project and starts the
build. MakefileAdditional flags have been added for building the library and tests.
FreeRTOS integration
FreeRTOS is configured via STM32CubeMX with the following parameters:
DefaultTask enabled
Dynamic memory allocation is used
Increased heap size to support multi-threaded test where 10 additional threads
are created. With default FreeRTOS configuration there is only enough memory
to create 2 additional threads
Tracing functions are enabled (more details in the debugging section)
All FreeRTOS user code is located in freertos.c:
// Определение основного потока
osThreadId_t defaultTaskHandle;
const osThreadAttr_t defaultTask_attributes = {
.name = "defaultTask",
.stack_size = 128 * 4,
.priority = (osPriority_t) osPriorityNormal,
};
// Точка входа для тестов
void StartDefaultTask(void *argument)
{
logging_init();
// Задержка для подключения терминала
osDelay(1000);
// Запуск тестов
logging_test();
// ...
}
This stream is used as an entry point for running tests and initializing the logging library.
> Tip: STM32CubeMX allows you to easily modify the project configuration via a
graphical interface with subsequent code regeneration. However, this also in some
sense obliges the developer to always change the configuration via cubemx, so as not
to break the project structure.
Testing the Logging Library
The tests are implemented in [Link] check various modes of operation of the
logging library:
void logging_test()
{
logging_basic_test("basic_test"); // Базовые тесты
osDelay(200); // Ожидание вывода логов
logging_test_pack("pack_of_ten_ten_times"); // Пакетная запись
osDelay(200); // Ожидание вывода логов
logging_test_di erent_levels("di erent_levels"); // Разные уровни логов
osDelay(200); // Ожидание вывода логов
logging_test_fatal("fatal"); // Фатальные ошибки
osDelay(200); // Ожидание вывода логов
logging_interrupt(); // Логи из прерываний
osDelay(200); // Ожидание вывода логов
logging_test_multiple_threads(); // Многопоточный тест
}
> Note: Delays between tests ( osDelay) allow the logging thread to empty its bu ers
and cleanly separate the output of di erent test cases.
Key Test Scenarios
1. Basic testing
2. [INFO ][1s.1]: basic_test_0
3. [INFO ][1s.12]: basic_test_1
[INFO ][1s.22]: basic_test_2
o Checking message formatting
o Timestamps with millisecond precision
o Sequential logging
The logging library uses FreeRTOS kernel ticks to obtain the time. The time is captured
when the logging function is called and stored in a bu er before being transmitted over
the interface, thus reflecting the time of the event, not the time of transmission over the
interface.
4. Logging levels
5. [DEBUG_ALL][2s.112]: di erent_levels_DEBUG_ALL
6. [DEBUG_MIN][2s.112]: di erent_levels_DEBUG_MIN
7. [INFO ][2s.112]: di erent_levels_INFO
8. [WARNING ][2s.112]: di erent_levels_WARNING
[ERROR ][2s.112]: di erent_levels_ERR
9. Special modes
10. # Фатальные ошибки (прямой вывод в UART)
11. fatal_0
12. fatal_1
13.
14. # Логи из прерываний (без буферизации)
[INFO ][2s.512]ISR: LOG_ISR_9
o LOG_FATAL- direct recording to UART, without bu ering
o LOG_ISR- logging from interrupts, without using a bu er
void logging_interrupt()
{
// только последний лог будет напечатан
for (int i = 0; i < LOGS_AT_ONCE; i++) {
LOG_ISR(INFO, "LOG_ISR_%d", i);
}
}
> Important about ISR: Only the last message ( LOG_ISR_9) is visible in the output,
and this is expected behavior. In the interrupt context:
> - Blocking operations cannot be used
> - Bu ering is not applied
> - The logging thread has a lower priority.
> As a result, while the logging thread is processing one message, the next
call LOG_ISRis already overwriting the data. This is a tradeo between correctness of
operation in an ISR and the guarantee of delivery of all messages.
15. Multithreaded test
void logging_test_multiple_threads()
{
for (int i = 0; i < THREADS_AMOUNT; i++) {
threads[i] = osThreadNew(logging_thread, names[i], NULL);
}
}
[INFO ][2s.812]: logging_thread_4_0
[INFO ][2s.812]: logging_thread_4_1
[INFO ][2s.812]: logging_thread_5_1
[INFO ][2s.812]: logging_thread_6_1
o 10 threads write logs simultaneously
o Tests the circular bu er under load
o The logging thread has normal priority
o Test threads block when bu er is full
> Feature: The library uses delayed log output via UART. If there are few logs, they are
output in the background. If the bu er is full, the streams are blocked until space is
freed. The modes LOG_FATALand LOG_ISRprovide instant output.
Verification of output
The correctness is checked by a script verify_output.py. QEMU creates a virtual
terminal, to which the emulated STM32 outputs data. The Python script connects to this
terminal and checks the correctness of the output.
# Пример проверки формата INFO сообщения
pattern = r"\[INFO\s*\]\[(\d+)s\.(\d+)\]: basic_test_(\d+)"
match = [Link](pattern, output)
if match:
seconds = int([Link](1))
milliseconds = int([Link](2))
test_number = int([Link](3))
The script completes successfully if all messages are correct. This section of the code
needs to be modified depending on the requirements of the project being tested. For
example, if the project hangs or there is no full set of messages, the script will hang in
standby mode. This can be fixed by adding timeouts and more complex logic that will
trigger the internal QEMU "shutdown" mechanism when such situations are detected,
for example, when a HardFault or similar errors occur.
Automated testing
Once the test cases are implemented, the next step is to automate their execution. This
is done using a combination of Docker and GitHub Actions.
Docker containerization
Testing is performed in the same three containers described earlier:
services:
tests:
build:
context: .
dockerfile: Dockerfi[Link]
image: tests
qemu_stm32:
build:
context: .
dockerfile: Dockerfile.qemu_stm32
depends_on:
- tests
image: qemu_stm32
run_tests: &run_tests
build:
context: .
dockerfile: Dockerfile.run_tests
depends_on:
- qemu_stm32
- tests
volumes:
- ./logs/:/test/logs
- ./elf/:/elf
profiles:
- test
image: run_tests
Each container plays its own role in the testing process:
1. tests— compiles firmware with tests.
2. qemu_stm32- provides an emulation environment.
3. run_tests— runs tests and verifies results.
Running tests
Automation of launch is implemented through a script [Link]:
#!/bin/bash
# Копирование библиотеки в тестовый проект
cp -r ../lib/ test_project/logging_cmsis_rtos2
# Запуск контейнеров
docker compose --profile test up --build
# Сохранение логов
docker compose logs $container > $logs_file
# Получение кода завершения
exit_code=$(docker inspect -f '{{.[Link]}}' $test_container_name)
> Feature: The script not only runs tests, but also ensures correct copying of the
library and saving of test results.
Integration with CI/CD
Tests are automatically run on every push and pull request via GitHub Actions:
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run test
run: cd test && ./[Link]
- name: Archive test results
uses: actions/upload-artifact@v4
with:
name: combined_logs
path: |
./test/logs/[Link]
./test/logs/serial_output.txt
> Tip: For corporate use, you can set up a self-hosted runner on your own build
server. The approach is compatible with GitLab CI and other CI systems. In this case,
GitHub Actions is used, which is provided free of charge for open-source projects.
Analysis of results
The tests generate two files with results:
1. [Link]— debug output of the testing process
2. serial_output.txt— logs from the library being tested
These files are saved as build artifacts and can be used to:
Analysis of the causes of test failures
Checking the correctness of the library operation
Documentation of the system operation
Debugging using GDB and QEMU
Debugging embedded systems often feels like a detective investigation. Fortunately,
QEMU and GDB provide a complete set of tools for this.
How does this work?
QEMU, as a true GNU project, supports all standard debugging tools. For tests, we
specify the serial interface name pty. It is possible to run qemu in the mode where the
serial interface is output to the qemu console.
QEMU_FLAGS=(
"-nographic"
"-M" "stm32-f103c8"
"-kernel" "/app/fi[Link]"
"-serial" "pty"
> For the curious: The mapping between STM32 USARTs and QEMU PTYs is defined
in the emulator source code. If you need something special, you'll have to study the
source. RTSL — Read The Source, Luke!
Debug mode
The script test_in_docker.shsupports two operating modes:
1. Test mode - normal test run
2. Debug mode - run with GDB support:
if [ "$1" == "gdb" ]; then
QEMU_FLAGS+=(
"-gdb" "tcp::3333" # GDB-сервер на порту 3333
"-S" # Остановка при старте
echo "Starting QEMU in GDB debug mode..."
Connecting the debugger
To connect to QEMU, use arm-none-eabi-gdb. The test project has a ready-made target:
gdb:
arm-none-eabi-gdb ../elf/fi[Link] -ex "target extended-remote :3333"
> Important: There is a little magic here! The firmware for GDB is taken from the
Docker container via volume:
volumes:
- ./elf/:/elf # Файлы для GDB >
Starting debugging (GDB)
To start QEMU in debug mode use start_qemu_gdb_mode.sh:
#!/bin/bash
docker compose --profile gdb up --build
It uses a profile gdbin Docker Compose:
gdb:
<<: *run_tests # Наследуем настройки от run_tests
ports:
- "3333:3333" # Открываем порт для GDB
profiles:
- gdb # Отдельный профиль
entrypoint: ["./test_in_docker.sh", "gdb"]
> Profiles in Docker Compose allow you to have di erent configurations in a single
file. A profile testis for testing, and gdba profile is for debugging. This allows you to
choose which configuration to run depending on your current needs.
Debugging process
1. Running QEMU in debug mode:
./start_qemu_gdb_mode.sh
2. Connecting to GDB in another terminal:
cd test_project && make gdb
3. Waiting for GDB connection:
QEMU waits for connection and keeps the firmware in a paused state, allowing:
o Set breakpoints
o Study the initial state of the system
o Debug system initialization
Practice working with GDB
Once debugging starts, you will see several terminals:
1. QEMU terminal:
The terminal where the script was run start_qemu_gdb_mode.shwill display a
message that QEMU is waiting for a connection:
QEMU terminal waiting for GDB connection
2. GDB Terminal:
test/test_project$ make gdb
arm-none-eabi-gdb ../elf/fi[Link] -ex "target extended-remote :3333"
GNU gdb (Arm GNU Toolchain [Link]-Rel1 (Build arm-12-mpacbti.34))
13.1.90.20230307-git
Copyright (C) 2023 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-pc-linux-gnu --target=arm-none-eabi".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
.
--Type for more, q to quit, c to continue without paging--
3. GDB in TUI (Text User Interface) mode:
Enabling TUI mode allows you to visualize the debugging process:
# Включить TUI режим
(gdb) tui enable
GDB in TUI mode
Basic debugging commands
1. Setting a breakpoint:
Let's put a breakpoint in the function that runs the tests:
(gdb) break logging_test
2. Launching the program:
Let's enable the program execution. The execution will stop when the set breakpoint is
reached:
(gdb) continue
Running program until breakpoint
3. Step by step execution:
Allows you to execute a program line by line or step inside functions:
(gdb) next # Выполнить строку (n)
(gdb) step # Зайти внутрь функции (s)
4. View variables:
To view the value of a variable, use:
(gdb) print name # Вывести значение переменной
(gdb) info locals # Вывести все локальные переменные
GDB print variables
GDB print variables> Tip: GDB supports shortcuts for commonly used commands:
> - c= continue
> - n= next
> - s= step
> - p= print
> - bt= backtrace (call stack)
Typical problems
1. Compiler optimizations can "hide" variables:
(gdb) print i
No symbol "i" in current context.
Solution: Use flags -O0together with -g(debug symbols) when compiling for debugging.
The Makefile already has these options set.
2. Loss of context on interrupts:
(gdb) where
#0 HardFault_Handler () at ../Core/Src/stm32f1xx_it.c:89
#1
#2 ?? () at ??:?
Solution: Set a breakpoint in the interrupt handler.
> Important: Debugging embedded systems is an art. GDB may seem complicated,
but it is a powerful tool, especially when combined with integrated development
environments (IDEs).
Debugging in VS Code
Visual Studio Code provides a graphical interface for GDB that greatly simplifies the
debugging process. It is an alternative to the command line make gdb, where under the
hood, the same GDB protocol is used to connect to the debug server, but instead of a
CLI, a convenient graphical interface is o ered.
Setting up VS Code
To work with QEMU and GDB, a special configuration is required in the [Link]. VS
Code can generate a basic template for this file (F1 -> "Debug: Add Configuration"), but
it needs to be adapted to our project:
{
"configurations": [
{
"name": "QEMU ARM Debug",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/test/elf/fi[Link]",
"miDebuggerServerAddress": "localhost:3333",
"cwd": "${workspaceFolder}/test/test_project",
"targetArchitecture": "arm",
"MIMode": "gdb",
"stopAtEntry": true
}
]
}
Debugging process in VS Code
1. Start QEMU in debug mode:
./start_qemu_gdb_mode.sh
2. Open VS Code and go into debug mode (Ctrl+Shift+D). If QEMU is running, VS
Code will automatically connect to it and open the project files pointed to by the
"PC" register. Make sure you have the C/C++ extension for VS Code installed,
which allows you to view variables and manipulate the call stack.
VS Code Debug Overview
3. Set breakpoints by clicking to the left of the line number. A red dot indicator
will appear.
Setting Breakpoint in VS Code
4. Click the "Start Debugging" button (F5):
The program execution will be interrupted when the breakpoint is reached. You can then
use all GDB commands through the VS Code graphical interface.
> VS Code Benefits:
> - Visual breakpoint management
> - Real-time variable view
> - Code navigation while debugging
> - Built-in terminal for parallel work with QEMU
> - Convenient work with variables and call stack
> - Multithreading support
Note: Although VS Code provides a convenient interface, knowledge of basic GDB
commands remains useful for debugging on slow machines, where frequent requests to
the GDB server over the network can significantly slow down the process. It is also a key
skill for automating the testing process and understanding the GNU GCC tools.
Profiling FreeRTOS in VS Code
One of the most powerful debugging capabilities is visualizing the RTOS. Let's set up
tools that allow us to "look inside" the operating system and establish communication
with our device to understand where it spends time and memory.
> Note: More details about profiling capabilities can be found in the o icial FreeRTOS
documentation .
Enabling FreeRTOS Statistics
1. In STM32CubeMX, enable the required options for the RTOS on the main
page:
FreeRTOS Configuration in CubeMX
FreeRTOS Configuration in CubeMX> Important: Don't
forget RECORD_STACK_HIGH_ADDRESSto enable to track stack usage per thread.
Setting up a timer for profiling
To measure the execution time of threads, a timer is needed. Let's set up TIM2 to run
slightly faster than the system timer, but slow enough to give about 5 seconds before
overflow:
Timer Configuration
Implementation of functions for working with statistics:
Let's add them to the end of the file tim.c:
unsigned long getRunTimeCounterValue(void) {
return __HAL_TIM_GET_COUNTER(&htim2);
}
void configureTimerForRunTimeStats(void)
{
// Таймер уже настроен HAL, просто запускаем
HAL_TIM_Base_Start(&htim2);
}
> Note: These functions are declared as weakin CMSIS-RTOS2. We provide our own
implementation according to our timer configuration.
RTOS Views in VS Code
1. Install the RTOS Views extension .
2. Start debugging as described earlier.
3. Let the system run for a few seconds (to allow the threads to be created).
4. Open the RTOS tab next to the terminal:
RTOS Views in VS Code
In the table you will see:
List of all streams
The state of each thread (active/blocked/suspended)
Using a stack
Percentage of CPU time spent on each thread
> Personal experience: To be honest, I spent a lot of time trying to set this all up on real
hardware when I was just starting out in open-source STM32 development. But it was
worth it - now I have a full understanding of how the system works at any point during
debugging.
Conclusion
This article is a compilation of my experience as an embedded systems developer.
Previously, my work mainly involved interacting with hardware: an oscilloscope, a
soldering iron, and endless firmware updates for debug boards. But over time, I realized
that the key to comfortable development is a properly configured environment.
> The main lesson: When your editor is fast, debugging is launched with one button,
and the process is identical for both virtual and real hardware - development becomes a
pleasure!
In today's world, where remote work is becoming the norm, the ability to emulate
hardware opens up new horizons.
What's next?
I hope this short introduction to the world of open-source tools, emulation, automation
and debugging using STM32 and my logging library as an example was useful. All code is
available under the MIT license - feel free to use it in your projects and create forks!
( GitHub )
> Finally: Don't be afraid to experiment and create your own scripts for comfortable
development!