A minimal bare-metal C++20 demo for the STM32F429I-DISCO development board that integrates three embedded libraries via CMake: The demo is compilable using clang. While pigweed is clang ready, unfortunately modm is not, so it required a bunch of ugly patches in the CMakeLists.txt. It would be a better idea to use gcc instead, since pigweed is gcc friendly too, but i just wanted to do some testing on clang on embedded for educational purpose.
| Library | Role in this demo |
|---|---|
| modm | Hardware abstraction – board init, GPIO (LEDs), UART1, SysTick delay |
| Pigweed | pw_log_tokenized – compact binary logging, pw_assert safe-halt, pw_status / pw_span |
| ETL | etl::vector and etl::string – static containers, zero heap |
| Feature | Detail |
|---|---|
| MCU | STM32F429ZIT6 (Cortex-M4F, 180 MHz, 2 MB Flash, 256 KB RAM) |
| Green LED | PG13 – heartbeat, toggles every 500 ms |
| Red LED | PG14 – flashes briefly after each batch is processed |
| UART1 TX | PA9 – 115 200 Bd, 8N1 – carries all pw_log output |
| UART1 RX | PA10 |
Connect any USB-serial adapter to PA9/PA10 or use the ST-LINK VCP (if wired on your board revision) to read the log output.
| Tool | Version | Install |
|---|---|---|
arm-none-eabi-gcc |
≥ 12 | Arm GNU Toolchain |
| CMake | ≥ 3.25 | https://cmake.org/download/ |
| Ninja | any | pip install ninja or your package manager |
| Python | ≥ 3.10 | https://python.org |
| lbuild | ≥ 1.0 | pip install lbuild |
| Git | ≥ 2.20 | (for submodules) |
- OpenOCD
openocd -f board/stm32f429disc1.cfg … - or STM32CubeProgrammer / ST-LINK Utility using the generated
.hex
git clone --recurse-submodules https://github.com/YOUR_USER/stm32demo.git
cd stm32demoIf you already cloned without --recurse-submodules:
git submodule update --init --recursiveCMake will run lbuild build automatically on first configure if the
modm/ directory is absent. You can also run it manually:
lbuild build # reads lbuild.xml, writes modm/cmake --preset debug
# or: cmake --preset releasecmake --build --preset debugArtifacts land in build/debug/:
stm32f429i_demo– ELF (for GDB)stm32f429i_demo.bin– raw binarystm32f429i_demo.hex– Intel HEX
openocd -f board/stm32f429disc1.cfg \
-c "program build/debug/stm32f429i_demo.hex verify reset exit"See Tokenized Logging below.
This demo uses pw_log_tokenized instead of plain-text logging. Every
PW_LOG_* call stores only a 32-bit hash token in the firmware binary — the
format string itself is stripped out at compile time. At runtime, only the
token plus varint-encoded arguments are transmitted, wrapped in a
$-prefixed Base64 line. The host-side tool looks the token up in a
database extracted from the ELF and reconstructs the original message.
| Approach | Flash cost per log site | UART bytes per message |
|---|---|---|
printf / pw_log_basic |
~40–200 B (string in flash) | ~40–80 B (ASCII) |
pw_log_tokenized |
4 B (token only) | 8–16 B (Base64) |
On a 2 MB device this makes little practical difference, but it demonstrates the technique and the tooling end-to-end.
Each log message is one newline-terminated line on the UART:
$Qlqz/hM=
The payload inside the Base64 is [ 4-byte LE token ][ varint args... ].
The $ prefix lets the detokenizer recognise tokenized lines even when
mixed with other UART traffic.
The CMake post-build step automatically extracts the token→string database from the ELF after every build and writes it next to the ELF:
build/debug/stm32f429i_demo.tokens.csv
To regenerate it manually:
# Linux / macOS
PYTHONPATH=ext/pigweed/pw_tokenizer/py \
python3 -m pw_tokenizer.database create \
--database build/debug/stm32f429i_demo.tokens.csv \
build/debug/stm32f429i_demo:: Windows (cmd) – run from the repo root
set PYTHONPATH=ext\pigweed\pw_tokenizer\py
python -m pw_tokenizer.database create ^
--database build\debug\stm32f429i_demo.tokens.csv ^
build\debug\stm32f429i_demoInstall the only host-side dependency (once):
pip install pyserialFind your COM port: on Windows open Device Manager → Ports (COM & LPT) and look for STMicroelectronics STLink Virtual COM Port.
# Linux / macOS
PYTHONPATH=ext/pigweed/pw_tokenizer/py \
python3 -m pw_tokenizer.serial_detokenizer \
--device /dev/ttyACM0 --baudrate 115200 \
build/debug/stm32f429i_demo.tokens.csv:: Windows (cmd) – run from the repo root
set PYTHONPATH=ext\pigweed\pw_tokenizer\py
python -m pw_tokenizer.serial_detokenizer ^
--device COM4 --baudrate 115200 ^
build\debug\stm32f429i_demo.tokens.csvYou can also point the tool directly at the ELF to skip the CSV step:
PYTHONPATH=ext/pigweed/pw_tokenizer/py \
python3 -m pw_tokenizer.serial_detokenizer \
--device /dev/ttyACM0 --baudrate 115200 \
build/debug/stm32f429i_demo[DEMO] =========================================
[DEMO] STM32F429I-DISCO modm + Pigweed + ETL
[DEMO] =========================================
[DEMO] Build ID: a3f9c1e8b72d...
[DEMO] Git: 40a38ab @ main
[DEMO] Built: Feb 28 2026 14:23:07
[DEMO] System clock: 180000000 Hz
[DEMO] ETL reading buffer capacity: 16
[DEMO] --- Batch #1 (t=8000 ms) ---
[DEMO] t=500 raw=-49
[DEMO] t=1000 raw=-48
...
[DEMO] batch mean=-8 n=16
Every firmware image logs three pieces of build identity at boot, making it easy to correlate a running binary with its source and ELF file:
| Log line | Source | Example |
|---|---|---|
Build ID: <hex> |
GNU build ID embedded by the linker (-Wl,--build-id=sha1) and read at runtime via pw_build_info::BuildId() |
a3f9c1e8b72d… (20-byte SHA1) |
Git: <commit>[-dirty] @ <branch> |
Captured at build time by cmake/GenGitInfo.cmake via git rev-parse / git status |
40a38ab-dirty @ main |
Built: <date> <time> |
Compiler built-ins __DATE__ / __TIME__ expanded when main.cpp is compiled |
Feb 28 2026 14:23:07 |
The -dirty suffix appears when the working tree has uncommitted changes at
build time. The GNU build ID is deterministic for the same binary — two
builds from the same source and toolchain produce the same ID, while any
change (including a new commit) produces a different one.
In addition to being logged at boot, the metadata is stored in a dedicated
.build_metadata ELF section (src/build_metadata.cc) as a 71-byte packed
struct. A CRC-32 field (IEEE 802.3 polynomial, identical to Python's
zlib.crc32()) is computed at compile time by a constexpr lambda — no
post-build patching or linker magic required. The host-side script verifies
the checksum and exits non-zero on mismatch:
python tools/read_build_meta.py build/debug/stm32f429i_demoCommit : 40a38ab @ main
Branch : main
Built : Feb 28 2026 14:23:07
CRC32 : 0x4a7c91f2 OK
The script uses only Python stdlib — zlib, struct, and subprocess — and
calls arm-none-eabi-objcopy --only-section=.build_metadata to extract the
raw bytes, so no pyelftools or other packages are needed.
stm32demo/
├── CMakeLists.txt # root build description
├── CMakePresets.json # debug / release / minsizerel presets
├── lbuild.xml # modm module selection for DISCO-F429ZI
├── cmake/
│ └── GenGitInfo.cmake # build-time script: captures git metadata → git_info.h
├── toolchain/
│ └── arm-none-eabi.cmake # cross-compilation toolchain file
├── src/
│ ├── main.cpp # application entry point
│ ├── build_metadata.cc # packed struct in .build_metadata ELF section + constexpr CRC-32
│ ├── log_backend.cc # pw_sys_io backend (WriteByte → UART1)
│ ├── log_tokenized_handler.cc # pw_log_tokenized handler ($-Base64 over UART)
│ └── pw_assert_backend/
│ └── assert_backend.cc # pw_assert_basic backend (safe-halt)
├── tools/
│ └── read_build_meta.py # host script: extract + CRC-verify .build_metadata
└── ext/
├── modm/ # git submodule – modm source + lbuild recipes
└── pigweed/ # git submodule – Pigweed source
ETL is fetched automatically by CMake
FetchContentat configure time – no manual step required.
modm lives in
ext/modm/(submodule) and the generated library is written tomodm/(git-ignored) by lbuild.
main.cpp
│
├── modm::Board::initialize() ← sets up clocks, FPU, LEDs
├── Usart1::initialize() ← UART1 for log output
│
├── pw_log (PW_LOG_INFO / PW_LOG_WARN / …)
│ └── PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD macro
│ └── _pw_log_tokenized_EncodeTokenizedLog() ← log_tokenized.cc
│ └── pw_log_tokenized_HandleLog() ← log_tokenized_handler.cc
│ └── "$" + Base64(token+args) + "\n" → UART1
│
├── pw_assert (PW_CHECK_OK)
│ └── pw_assert_basic_HandleFailure() in assert_backend.cc
│ └── safe-halt + LED indicator
│
└── etl::vector<SensorReading, 16> ← stack-allocated, no heap
└── pw::span view passed to ProcessBatch()
- UART baud rate – change
115200_Bdinmain.cpp;log_tokenized_handler.ccinherits the rate from the same UART instance. - Log verbosity – define
PW_LOG_LEVELin the target's compile options (e.g.target_compile_definitions(… PRIVATE PW_LOG_LEVEL=PW_LOG_LEVEL_DEBUG)). - Additional modm modules – add
<module>entries tolbuild.xmland re-runlbuild build. - Additional Pigweed modules – add the module's
public/directory toPIGWEED_INCLUDE_DIRSinCMakeLists.txtand link any required.ccfiles.