Series Complete: This is the final article — Part 18 of 18 — of the STM32 Unleashed: HAL Driver Development series. Congratulations on reaching this milestone. This article distils everything you need to transition from a working prototype to a production-grade, field-deployable firmware image.
1
Architecture & CubeMX Setup
STM32 family, clock tree, HAL vs LL, CubeMX workflow, first project
2
GPIO & Button Debounce
GPIO modes, pull-up/down, EXTI, software debounce, HAL_GPIO_ReadPin
3
UART Communication
Polling, interrupt, DMA modes, printf retargeting, ring buffers
4
Timers, PWM & Input Capture
TIM basics, PWM generation, input capture, encoder mode
5
ADC & DAC
Single/continuous conversion, DMA, injected channels, DAC waveforms
6
SPI Protocol
SPI master/slave, full-duplex, DMA transfers, sensor drivers
7
I2C Protocol
I2C master, 7/10-bit addressing, DMA, multi-master, error handling
8
DMA & Memory Efficiency
DMA streams, circular mode, memory-to-memory, zero-copy patterns
9
Interrupt Management & NVIC
Priority grouping, preemption, ISR design, HAL callbacks, latency
10
Low-Power Modes
Sleep, Stop, Standby modes, RTC wakeup, LP UART, power profiling
11
RTC & Calendar
RTC configuration, alarms, backup registers, calendar subseconds
12
CAN Bus
FDCAN/bxCAN, filters, message frames, error handling, automotive use
13
USB CDC Virtual COM Port
USB FS/HS, CDC class, virtual serial, control transfers, descriptors
14
FreeRTOS Integration
Tasks, queues, semaphores, mutexes, CMSIS-RTOS2 wrapper, stack sizing
15
Bootloader Development
Custom IAP bootloader, UART/USB DFU, flash programming, jump-to-app
16
External Storage: SD & QSPI Flash
FATFS on SD card, QSPI NOR flash, memory-mapped execution, wear levelling
17
Ethernet & TCP/IP Stack
LwIP integration, DHCP, TCP server, HTTP, MQTT, Ethernet DMA descriptors
18
Production Readiness
Watchdog, HardFault handler, flash option bytes, code signing, CI/CD
You Are Here
Production Firmware Mindset
Every embedded engineer eventually discovers that a prototype that works on the bench is not the same thing as firmware that can be trusted in the field. Production firmware must survive power-on transients, noisy environments, software bugs that slip through code review, and adversarial actors who may try to read or replace the firmware image. The transition from prototype to production is not about adding features — it is about adding robustness, observability, and security.
Three questions frame every production firmware decision:
- What happens when something goes wrong? Watchdogs and HardFault handlers ensure the device recovers or at least records diagnostic data before any potential loss of function.
- Can the firmware image be trusted? Read protection, write protection, and code signing prevent a device from running unauthorised or corrupted code.
- How do we catch regressions before they reach hardware? CI/CD pipelines enforce that every commit is built, tested, and checked for binary size growth — on every pull request, before any firmware reaches a device.
Reliability Requirements
Industrial and medical products typically specify a Mean Time Between Failures (MTBF) measured in thousands of hours. Consumer products may accept occasional reboots; safety-critical systems cannot. Understanding where your product sits on this spectrum drives every decision in this article. Fail-safe systems default to a safe state on failure (a motor controller stops the motor). Fail-secure systems maintain security on failure (an access control system locks the door). Both require the firmware to detect and respond to failure — which starts with the watchdog.
| Production Concern |
Mechanism |
STM32 Feature |
Covered In |
| Firmware lockup recovery |
Independent Watchdog |
IWDG peripheral (LSI clocked) |
Section 2 |
| Stuck-loop detection |
Window Watchdog |
WWDG peripheral (APB1 clocked) |
Section 3 |
| Crash diagnostics |
HardFault handler |
ARM Cortex-M exception model |
Section 4 |
| IP protection / cloning prevention |
Read protection |
Flash RDP option bytes |
Section 5 |
| Unauthorised firmware prevention |
Code signing |
CRC32 / ECDSA / STM32 Secure Boot |
Section 6 |
| Regression prevention |
CI/CD pipeline |
GitHub Actions / GitLab CI + OpenOCD |
Section 7 |
A typical production boot sequence ties all of these together:
/* production_boot.c — typical production firmware startup sequence */
#include "stm32f4xx_hal.h"
#include "iwdg.h"
#include "crc.h"
#include "uart.h"
/* Firmware version stored in a dedicated Flash section */
const char FW_VERSION[] __attribute__((section(".fw_version"))) = "1.2.3-release";
/* CRC stored at end of firmware image by linker/build script */
extern uint32_t _fw_crc;
extern uint32_t _fw_start;
extern uint32_t _fw_end;
static HAL_StatusTypeDef verify_firmware_crc(void)
{
__HAL_RCC_CRC_CLK_ENABLE();
CRC_HandleTypeDef hcrc = {0};
hcrc.Instance = CRC;
if (HAL_CRC_Init(&hcrc) != HAL_OK) return HAL_ERROR;
uint32_t fw_len = (uint32_t)&_fw_end - (uint32_t)&_fw_start;
uint32_t calculated = HAL_CRC_Calculate(&hcrc,
(uint32_t *)&_fw_start,
fw_len / 4);
return (calculated == _fw_crc) ? HAL_OK : HAL_ERROR;
}
void production_boot(void)
{
/* 1. Check Flash option bytes — RDP must be Level 1 in production */
FLASH_OBProgramInitTypeDef ob = {0};
HAL_FLASHEx_OBGetConfig(&ob);
if (ob.RDPLevel != OB_RDP_LEVEL_1) {
/* Log warning; optionally halt or proceed with degraded trust */
uart_log("WARN: RDP not set to Level 1\r\n");
}
/* 2. Verify firmware CRC before running application logic */
if (verify_firmware_crc() != HAL_OK) {
uart_log("FATAL: Firmware CRC mismatch — halting\r\n");
NVIC_SystemReset(); /* or enter failsafe state */
}
/* 3. Start watchdog — must be fed within 2 seconds */
MX_IWDG_Init(); /* configured for ~2000 ms timeout */
/* 4. Log boot banner with version */
uart_log("FW: ");
uart_log(FW_VERSION);
uart_log(" boot OK\r\n");
/* 5. Run application */
app_main();
}
Independent Watchdog (IWDG)
The Independent Watchdog is the simplest and most reliable recovery mechanism in any embedded system. It uses the internal LSI oscillator (typically 32 kHz, independent of the main system clock), has a 12-bit down counter with a configurable prescaler and reload value, and once started it cannot be stopped. If the application fails to call HAL_IWDG_Refresh() before the counter reaches zero, the MCU issues a system reset.
The IWDG's key properties that make it production-suitable:
- Clocked by LSI — survives Stop mode on many STM32 families, still runs if SYSCLK fails.
- Cannot be disabled by software after start — provides an absolute guarantee of recovery.
- Can be configured in hardware mode (forced on at power-up) via option bytes.
- Reset source is recorded in RCC_CSR, allowing firmware to detect and log watchdog resets.
Timeout Calculation
The IWDG timeout is determined by: Timeout = (Prescaler × 4096) / LSI_frequency. With LSI at 32 kHz and a prescaler of 16, the minimum timeout is 2.048 ms; with prescaler 256 it reaches 32.768 s. The HAL wraps this in the IWDG_HandleTypeDef.Init.Prescaler and .Init.Reload fields.
IWDG in Low-Power Modes
On STM32F4/F7 the IWDG continues counting in Stop mode. On STM32L4/U5 you can optionally freeze it in Stop mode via the DBG freeze register during development, but this should be disabled in production. Always verify in the reference manual for your specific family.
/* iwdg_production.c — IWDG at ~2-second timeout, production pattern */
#include "stm32f4xx_hal.h"
IWDG_HandleTypeDef hiwdg;
/**
* @brief Initialise IWDG with a ~2000 ms timeout.
* LSI ≈ 32 kHz, Prescaler = 32, Reload = 1999
* Timeout = (32 × 4096) / 32000 = 4.096 s (conservative)
* For tighter 2 s: Prescaler = 16, Reload = 3999 → 2.0 s
*/
HAL_StatusTypeDef IWDG_Init_Production(void)
{
hiwdg.Instance = IWDG;
hiwdg.Init.Prescaler = IWDG_PRESCALER_16; /* /16 → tick = 0.5 ms */
hiwdg.Init.Reload = 3999; /* 3999 × 0.5 ms = 2000 ms */
if (HAL_IWDG_Init(&hiwdg) != HAL_OK) {
return HAL_ERROR;
}
return HAL_OK;
}
/**
* @brief Feed (refresh) the IWDG.
* Call this from the main loop — must be called within 2 s.
*/
void IWDG_Feed(void)
{
HAL_IWDG_Refresh(&hiwdg);
}
/**
* @brief Emergency feed — call from HardFault handler before logging,
* to buy time for UART transmission.
*/
void IWDG_EmergencyFeed(void)
{
/* Write the KEY_RELOAD value directly — no HAL overhead */
hiwdg.Instance->KR = IWDG_KEY_RELOAD;
}
/* ---- main.c excerpt ---- */
int main(void)
{
HAL_Init();
SystemClock_Config();
/* Detect if last reset was caused by watchdog */
if (__HAL_RCC_GET_FLAG(RCC_FLAG_IWDGRST)) {
uart_log("BOOT: Previous reset caused by IWDG timeout\r\n");
__HAL_RCC_CLEAR_RESET_FLAGS();
}
IWDG_Init_Production(); /* Start IWDG — cannot be stopped after this */
while (1) {
app_process(); /* Application work */
IWDG_Feed(); /* Refresh before timeout — must be < 2 s apart */
}
}
Window Watchdog (WWDG)
The Window Watchdog addresses a failure mode the IWDG cannot: a firmware bug that creates a tight loop which still calls the refresh fast enough to prevent an IWDG reset. The WWDG enforces a window — refresh too early (counter above the window) causes a reset, and refresh too late (counter reaches 0x40) also causes a reset. Only refreshes within the correct window are accepted.
Key WWDG characteristics:
- Clocked by APB1 — unlike IWDG, it is affected by the system clock and will not run if the CPU hangs in a way that stops APB1 clocking.
- Provides an Early Wakeup Interrupt (EWI) when the counter reaches 0x40, giving firmware a last chance to log diagnostics before the forced reset.
- The window boundary is set by the
WWDG_CR.W[6:0] field. The counter must be below this value before refresh is accepted.
Window Calculation
WWDG clock = PCLK1 / (4096 × Prescaler). At PCLK1 = 42 MHz and Prescaler = 8: WWDG clock = 42 MHz / 32768 ≈ 1.281 kHz → period ≈ 0.781 ms per tick. With counter = 127 (0x7F) and window = 80 (0x50): the valid refresh window spans from counter reaching 80 down to 64 (0x40) — approximately 12.5 ms wide.
Early Warning Interrupt
/* wwdg_production.c — WWDG with early warning interrupt for diagnostics */
#include "stm32f4xx_hal.h"
#include "uart.h"
WWDG_HandleTypeDef hwwdg;
HAL_StatusTypeDef WWDG_Init_Production(void)
{
hwwdg.Instance = WWDG;
hwwdg.Init.Prescaler = WWDG_PRESCALER_8;
hwwdg.Init.Window = 0x50; /* Window boundary = 80 decimal */
hwwdg.Init.Counter = 0x7F; /* Reload counter = 127 (max) */
hwwdg.Init.EWIMode = WWDG_EWI_ENABLE; /* Enable early warning */
if (HAL_WWDG_Init(&hwwdg) != HAL_OK) {
return HAL_ERROR;
}
return HAL_OK;
}
/**
* @brief Feed the WWDG — must be called AFTER counter drops below 0x50
* but BEFORE it reaches 0x40. Too early = reset. Too late = reset.
*/
void WWDG_Feed(void)
{
HAL_WWDG_Refresh(&hwwdg);
}
/**
* @brief WWDG Early Wakeup Interrupt callback.
* Called when counter reaches 0x40 — imminent reset.
* Use this window to log state before the reset occurs.
*/
void HAL_WWDG_EarlyWakeupCallback(WWDG_HandleTypeDef *hwwdg)
{
/* Log warning — UART must be fast enough (115200+) to transmit before reset */
uart_log("WWDG: Early warning — imminent reset. Counter=0x40\r\n");
/* Store a fault code in RTC backup register for post-reset analysis */
HAL_PWR_EnableBkUpAccess();
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR0, 0xDEAD0001U); /* WWDG fault code */
HAL_PWR_DisableBkUpAccess();
/* Do NOT call WWDG_Feed() here — let the reset happen.
* If the code reached this point, the scheduling assumption was violated. */
}
HardFault Handler
A HardFault is the ARM Cortex-M's way of telling you that something went seriously wrong: an invalid memory access, a stack overflow, an attempt to execute a non-executable memory region, or an unaligned access when CCR.UNALIGN_TRP is set. Without a proper handler, the processor sits in the default weak handler (an infinite loop) with no diagnostic information — the fault is invisible.
A production HardFault handler must:
- Determine whether the fault occurred in Thread mode (PSP) or Handler mode (MSP).
- Extract the stacked exception frame to find the PC at fault, the LR, the faulting address (BFAR/MMFAR), and the CFSR flags.
- Transmit diagnostics over UART (or store in RTC backup registers if UART is unavailable).
- Loop forever — never return from a HardFault.
Fault Status Registers
The Configurable Fault Status Register (CFSR) at address 0xE000ED28 contains three sub-registers: the MemManage Fault Status Register (MMFSR), BusFault Status Register (BFSR), and UsageFault Status Register (UFSR). Together they describe why the fault occurred. BFAR (0xE000ED38) holds the bus fault address when BFARVALID is set; MMFAR (0xE000ED34) holds the MemManage fault address when MMARVALID is set.
Extracting PC from Stack Frame
When a Cortex-M exception fires, the processor automatically pushes R0–R3, R12, LR, PC, and xPSR onto the stack. The PC pushed is the instruction address that caused the fault. The assembly entry point reads the appropriate stack pointer (MSP or PSP) and passes it to a C handler.
/* hardfault_handler.c — production HardFault handler */
#include "stm32f4xx_hal.h"
#include "uart.h"
#include <stdio.h>
/* Stacked exception frame layout (pushed automatically by Cortex-M hardware) */
typedef struct {
uint32_t r0;
uint32_t r1;
uint32_t r2;
uint32_t r3;
uint32_t r12;
uint32_t lr; /* Link Register at time of fault */
uint32_t pc; /* Program Counter — instruction that caused fault */
uint32_t xpsr; /* xPSR status register */
} ExceptionFrame_t;
static char fault_buf[128];
/**
* @brief C-level HardFault handler — called from assembly entry below.
* @param frame Pointer to the stacked exception frame.
* @param lr_val EXC_RETURN value from LR register on entry.
*/
void HardFault_Handler_C(ExceptionFrame_t *frame, uint32_t lr_val)
{
uint32_t cfsr = SCB->CFSR; /* Configurable Fault Status Register */
uint32_t hfsr = SCB->HFSR; /* HardFault Status Register */
uint32_t bfar = SCB->BFAR; /* Bus Fault Address Register */
uint32_t mmfar = SCB->MMFAR; /* MemManage Fault Address Register */
/* Feed IWDG to buy time for UART transmission */
IWDG->KR = 0xAAAAU; /* IWDG_KEY_RELOAD */
/* Transmit fault diagnostics over UART */
snprintf(fault_buf, sizeof(fault_buf),
"\r\n=== HARDFAULT ===\r\n"
"PC=0x%08lX LR=0x%08lX\r\n"
"CFSR=0x%08lX HFSR=0x%08lX\r\n"
"BFAR=0x%08lX MMFAR=0x%08lX\r\n",
frame->pc, frame->lr, cfsr, hfsr, bfar, mmfar);
uart_log(fault_buf);
/* Store fault data in RTC backup registers (survive reset) */
HAL_PWR_EnableBkUpAccess();
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR0, frame->pc); /* Fault PC */
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, cfsr); /* Fault reason */
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR2, 0xFAULT000U); /* Magic marker */
HAL_PWR_DisableBkUpAccess();
/* Blink LED rapidly to provide visual indication */
while (1) {
HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_14);
HAL_Delay(100);
}
}
/* Assembly entry point — determines stack pointer and calls C handler */
__attribute__((naked)) void HardFault_Handler(void)
{
__asm volatile (
"TST LR, #4 \n" /* Test EXC_RETURN bit 2 */
"ITE EQ \n"
"MRSEQ R0, MSP \n" /* bit2=0: fault in handler, use MSP */
"MRSNE R0, PSP \n" /* bit2=1: fault in thread, use PSP */
"MOV R1, LR \n" /* Pass EXC_RETURN as second argument */
"B HardFault_Handler_C \n"
);
}
Flash Option Bytes
Option bytes are non-volatile configuration bits stored in a dedicated area of Flash memory. They are programmed once (or a limited number of times) and control security-critical features that persist across power cycles and software resets. On most STM32 families they are stored in a separate Flash bank and loaded into shadow registers at boot.
Read Protection (RDP) Levels
| RDP Level |
Description |
Debug Access |
Flash Readback |
| Level 0 |
No protection (default) |
Full JTAG/SWD access |
Allowed via debugger |
| Level 1 |
Readout protection |
Debugging allowed (run-mode only) |
Blocked from debugger / external access |
| Level 2 |
Chip-level protection (permanent) |
JTAG/SWD permanently disabled |
Permanently blocked — no recovery |
For most production deployments, RDP Level 1 is the correct choice. It prevents firmware cloning via a debugger but still allows field debugging with a production debug token and does not brick the device. RDP Level 2 should only be used for the most security-sensitive products where permanent non-recoverability is acceptable.
Write Protection
Independent of RDP, individual Flash sectors can be write-protected via option bytes. At minimum, the bootloader sectors (typically Sector 0 and Sector 1) should be write-protected to prevent the application from accidentally overwriting the bootloader. The nWRP bits in the option bytes control per-sector write protection.
The IWDG_SW option bit controls whether the IWDG operates in software mode (application must start it) or hardware mode (IWDG starts automatically at power-on). For production, hardware mode is the safer choice.
/* option_bytes.c — enable RDP Level 1 and IWDG hardware mode */
#include "stm32f4xx_hal.h"
/**
* @brief Program option bytes: set RDP Level 1 and IWDG hardware mode.
* @note Call this ONCE during production programming, not on every boot.
* Changing RDP from Level 1 to Level 0 erases all user Flash.
* @retval HAL_StatusTypeDef
*/
HAL_StatusTypeDef OB_SetProductionConfig(void)
{
FLASH_OBProgramInitTypeDef ob_init = {0};
/* Read current option bytes */
HAL_FLASHEx_OBGetConfig(&ob_init);
/* Check if already configured */
if (ob_init.RDPLevel == OB_RDP_LEVEL_1 &&
(ob_init.USERConfig & OB_IWDG_HW) == OB_IWDG_HW) {
return HAL_OK; /* Already in production config */
}
/* Unlock Flash and option bytes */
if (HAL_FLASH_Unlock() != HAL_OK) return HAL_ERROR;
if (HAL_FLASH_OB_Unlock() != HAL_OK) return HAL_ERROR;
/* Configure RDP Level 1 + IWDG hardware mode */
FLASH_OBProgramInitTypeDef ob_config = {0};
ob_config.OptionType = OPTIONBYTE_RDP | OPTIONBYTE_USER;
ob_config.RDPLevel = OB_RDP_LEVEL_1;
ob_config.USERConfig = OB_IWDG_HW | OB_STOP_NO_RST | OB_STDBY_NO_RST;
if (HAL_FLASHEx_OBProgram(&ob_config) != HAL_OK) {
HAL_FLASH_OB_Lock();
HAL_FLASH_Lock();
return HAL_ERROR;
}
/* Write-protect bootloader sectors (Sector 0 and Sector 1) */
FLASH_OBProgramInitTypeDef ob_wp = {0};
ob_wp.OptionType = OPTIONBYTE_WRP;
ob_wp.WRPState = OB_WRPSTATE_ENABLE;
ob_wp.WRPSector = OB_WRP_SECTOR_0 | OB_WRP_SECTOR_1;
ob_wp.Banks = FLASH_BANK_1;
if (HAL_FLASHEx_OBProgram(&ob_wp) != HAL_OK) {
HAL_FLASH_OB_Lock();
HAL_FLASH_Lock();
return HAL_ERROR;
}
/* Launch option byte loading — causes system reset */
HAL_FLASH_OB_Launch();
/* Never reached after OB_Launch */
return HAL_OK;
}
Code Signing & Secure Boot
Flash read protection prevents cloning, but it does not prevent someone from replacing the firmware with a different image. Code signing ensures that only firmware cryptographically authorised by the manufacturer can run on the device. The complexity of the signing scheme scales with your security requirements.
For products without stringent security requirements, a CRC32 check in the bootloader (as shown in Section 1) provides integrity verification — it detects corruption but not deliberate modification, since an attacker can recalculate the CRC. This is sufficient for verifying that an OTA update was not corrupted in transit, but not for preventing malicious firmware.
RSA/ECDSA Signature Verification
True secure boot requires asymmetric cryptography. The build system signs the firmware image with the manufacturer's private key (RSA-2048 or ECDSA-P256). The bootloader holds the corresponding public key in protected Flash and verifies the signature before jumping to the application. On STM32U5/H5, ST provides the CMOX (Cryptographic MOdule eXtendable) library that implements ECDSA-P256 in hardware-accelerated form. On STM32L5/U5, the hardware supports a full Secure Boot and Secure Firmware Update (SBSFU) solution with a hardware root of trust.
| Security Level |
Mechanism |
STM32 Family Support |
Bootloader Complexity |
| Basic integrity |
CRC32 + magic header |
All families |
Low — 20 lines of C |
| Cryptographic integrity |
SHA-256 hash |
All families (software library) |
Medium — mbedTLS or Mbed Crypto |
| Authenticated firmware |
ECDSA-P256 signature |
STM32F4+ (CMOX on U5/H5) |
High — key management required |
| Hardware root of trust |
STM32 SBSFU / TrustZone |
STM32L5, U5, H5 (Cortex-M33) |
Very high — ST secure boot SDK |
CI/CD for Embedded
Continuous Integration and Continuous Delivery is not just for web applications. A properly configured CI/CD pipeline for embedded firmware catches build regressions, API breakage, and binary size bloat automatically on every pull request — before any code reaches hardware. The key components of an embedded CI pipeline are:
- Reproducible build environment: A Docker image containing
arm-none-eabi-gcc, CMake, and OpenOCD ensures every developer and every CI runner produces an identical binary.
- Native-compiled unit tests: Pure logic (ring buffers, state machines, CRC functions) is compiled for the host machine (x86/AMD64) and run with a test framework such as Unity or CppUTest. No hardware needed.
- Binary size check: A pipeline step that fails the build if the
.text section grows beyond a threshold catches accidental large library pulls early.
- Hardware-in-the-loop (optional): For critical paths, OpenOCD can flash the device and run functional tests on real hardware within the CI runner, though this requires a self-hosted runner with the target board attached.
GitHub Actions Workflow
# .github/workflows/stm32-build.yml
# Complete CI/CD pipeline for STM32 firmware
name: STM32 Firmware CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-22.04
steps:
# 1. Check out repository with submodules (FreeRTOS, lwIP, etc.)
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
# 2. Install ARM GCC toolchain
- name: Install arm-none-eabi-gcc 12.x
run: |
sudo apt-get update -qq
sudo apt-get install -y gcc-arm-none-eabi cmake ninja-build \
gcc g++ python3-pip
arm-none-eabi-gcc --version
# 3. Configure CMake for firmware build
- name: Configure CMake (firmware)
run: |
cmake -B build/firmware \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake \
-G Ninja
# 4. Build firmware
- name: Build STM32 firmware
run: cmake --build build/firmware --config Release -- -j$(nproc)
# 5. Check binary size — fail if .text > 60 KB
- name: Check binary size
run: |
ELF=build/firmware/my_project.elf
arm-none-eabi-size $ELF
TEXT_SIZE=$(arm-none-eabi-size $ELF | awk 'NR==2{print $1}')
echo "text section size: $TEXT_SIZE bytes"
MAX_SIZE=65536 # 64 KB limit
if [ "$TEXT_SIZE" -gt "$MAX_SIZE" ]; then
echo "ERROR: Binary size $TEXT_SIZE exceeds limit $MAX_SIZE bytes"
exit 1
fi
echo "Binary size OK: $TEXT_SIZE / $MAX_SIZE bytes"
# 6. Configure and build native unit tests (host machine)
- name: Configure CMake (unit tests, native)
run: |
cmake -B build/tests \
-DCMAKE_BUILD_TYPE=Debug \
-DBUILD_UNIT_TESTS=ON \
-G Ninja
- name: Build unit tests
run: cmake --build build/tests --config Debug -- -j$(nproc)
# 7. Run unit tests
- name: Run unit tests
run: |
cd build/tests
ctest --output-on-failure -j$(nproc)
# 8. Upload .elf and .bin as build artifacts
- name: Upload firmware artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-${{ github.sha }}
path: |
build/firmware/*.elf
build/firmware/*.bin
build/firmware/*.hex
retention-days: 30
Unit Tests in CI
The critical design principle for embedded unit testing is hardware abstraction. Any function that only performs computation (no peripheral access) can be compiled for x86 and tested natively. Functions that access hardware use HAL abstractions — in tests, these are replaced with mock implementations. The Unity test framework is single-file C (unity.c + unity.h), adds no external dependencies, and compiles on any C99-compliant compiler.
Production Checklist
Use this checklist before signing off any STM32 firmware release. Each item represents a category of failure mode observed in production embedded systems.
| Category |
Checklist Item |
Why It Matters |
| Watchdog |
IWDG enabled with an appropriate timeout |
Guarantees recovery from any firmware lockup |
| Watchdog refresh called in all code paths, including ISRs that may run without returning to main loop |
A blocked task or long ISR must not starve the watchdog refresh |
| IWDG hardware mode set in option bytes |
Prevents application from forgetting to start the watchdog |
| HardFault |
HardFault handler captures PC, LR, CFSR, BFAR |
Without this, fault diagnosis is impossible in the field |
| Fault data stored in RTC backup registers before reset |
Survives the reset so next boot can report the fault |
| WWDG configured if timing accuracy matters |
Detects stuck-loop bugs that IWDG cannot |
| Flash Protection |
RDP Level 1 set in option bytes |
Prevents firmware readback and IP theft |
| Bootloader sectors write-protected |
Application cannot brick device by overwriting bootloader |
| Clocks |
HSE startup verified in SystemClock_Config() |
Silent HSE failure causes subtle peripheral timing errors |
| PLL lock verified before proceeding |
Prevents running at wrong frequency without indication |
| DMA |
All DMA buffers in DMA-accessible SRAM |
CCM RAM on F4 is not DMA-accessible — silent data corruption |
| Cache maintenance performed on Cortex-M7 (F7/H7) |
D-Cache coherency not maintained by hardware for DMA buffers |
| NVIC |
All interrupt priorities set explicitly (not left at default 0) |
Default priority 0 for all IRQs violates Cortex-M BASEPRI design |
| FreeRTOS-safe priorities respect configMAX_SYSCALL_INTERRUPT_PRIORITY |
Calling FreeRTOS API from too-high-priority ISR causes hard fault |
| GPIO |
Unused GPIO pins set to Analog Input mode |
Floating digital inputs can toggle and consume power; analog mode is lowest leakage |
| Debug |
All debug printf/uart_log removed or gated by a compile-time flag |
Debug output adds latency, can block tasks, and leaks information |
| Version |
Firmware version string stored in a known Flash address |
Bootloader and field tools can read version without executing the firmware |
| CI/CD |
Automated build on every commit |
Catches merge conflicts that break compilation |
| Binary size check in CI |
Detects accidental large library inclusion before it reaches release |
| Native unit tests pass in CI (ring buffer, CRC, state machine) |
Logic regressions are caught without needing hardware |
Exercises
BeginnerExercise 1: IWDG Verification
Enable the IWDG with a 3-second timeout on your STM32 board. To verify it works:
- Initialise the IWDG in
main() immediately after SystemClock_Config().
- Insert
HAL_Delay(4000); in the main loop without the refresh call. Observe the MCU reset (LED goes dark then reinitialises).
- Add
HAL_IWDG_Refresh(&hiwdg); before the delay. Verify that no reset occurs.
- After each boot, read
RCC_CSR to detect whether the reset was caused by IWDG and print the result over UART.
Expected outcome: The IWDG reset is clearly visible; adding the refresh call prevents it; and the post-reset diagnostic correctly identifies the IWDG as the reset source.
IntermediateExercise 2: HardFault Diagnostic Handler
Write a production HardFault handler that performs all three actions below, then verify it by deliberately triggering a fault:
- Save PC, LR, and CFSR to RTC backup registers DR0, DR1, DR2 respectively. Write a magic value
0xFAULTED to DR3 as a marker.
- Transmit a "FAULT" message over UART that includes the register values in hex format. Ensure UART is initialised before the fault handler runs.
- Loop forever — do not call
NVIC_SystemReset(); let the IWDG reset the device so the watchdog reset flag is also set.
Deliberately trigger a HardFault by writing to address 0x00000000: *(volatile uint32_t *)0x00000000U = 0xDEAD;. Verify that the handler prints the correct PC (which should point to the line with the write) and that the RTC backup registers contain the expected values after reset.
AdvancedExercise 3: GitHub Actions CI Pipeline
Set up a complete GitHub Actions CI pipeline for your STM32 project with all five requirements:
- The pipeline must install arm-none-eabi-gcc 12.x from the Ubuntu package repository (or a cached toolchain) on an
ubuntu-22.04 runner.
- The firmware must build with CMake + Ninja in Release configuration. The CMake toolchain file must correctly target the ARM Cortex-M4 (or whichever core you are using).
- A separate CMake build target compiles and runs native unit tests using the Unity framework for at least two modules: your ring buffer implementation and your CRC32 function. All tests must pass for the pipeline to succeed.
- A pipeline step checks that the firmware
.elf binary's .text section is smaller than 64 KB. If it exceeds this threshold, the step must exit with a non-zero status code.
- The pipeline uploads the compiled
.elf, .bin, and .hex files as a GitHub Actions workflow artifact with a 30-day retention period, named using the commit SHA.
Bonus: Add a second job that runs only on the main branch and uses a self-hosted runner with your STM32 board attached. This job flashes the firmware via OpenOCD and reads back the UART boot message to verify the build passes a basic hardware smoke test.
STM32 Production Readiness Tool
Fill in your project details below and download your production readiness checklist as Word, Excel, PDF, or PowerPoint. The document will include your watchdog configuration, flash protection settings, code signing approach, CI/CD platform, test plan, and release notes.
Conclusion & Series Complete
In this final article of the STM32 Unleashed series we have covered the complete production readiness toolkit for STM32 firmware:
- The Independent Watchdog (IWDG) provides unconditional recovery from firmware lockups, is clocked independently of the system clock, and should be started as early as possible in the boot sequence with option bytes enforcing hardware mode.
- The Window Watchdog (WWDG) adds a temporal constraint, detecting firmware that refreshes the watchdog too early (stuck in a tight loop) as well as too late — providing a deeper diagnostic capability than IWDG alone, including the Early Wakeup Interrupt for last-chance logging.
- A proper HardFault handler is non-negotiable in production. Extracting the stacked exception frame in assembly, saving PC, LR, and CFSR to RTC backup registers, and transmitting diagnostics over UART transforms an invisible fault into a diagnosable field event.
- Flash option bytes (RDP Level 1, write-protected bootloader sectors, hardware IWDG mode) should be programmed as part of the manufacturing process — a firmware image without these settings is not a production firmware image.
- Code signing scales from a simple CRC32 magic header for integrity checking to full ECDSA-P256 with hardware root of trust on STM32L5/U5 for high-security applications.
- A CI/CD pipeline based on GitHub Actions with arm-none-eabi-gcc, CMake, native unit tests, and binary size checks transforms the development workflow — regressions are caught on every commit, not during pre-release testing.
Series Complete!
Congratulations — you have completed all 18 parts of STM32 Unleashed: HAL Driver Development. You now have the skills to build production-quality STM32 firmware from scratch.
Explore More Series
Related Articles in This Series
Part 1: Architecture & CubeMX Setup
The STM32 family, Cortex-M core selection, memory layout, clock tree configuration, HAL vs LL vs bare-metal, and your first CubeIDE project.
Read Article
Part 9: Interrupt Management & NVIC
Priority grouping, preemption levels, ISR design principles, HAL callbacks, and interrupt latency measurement — the foundation for all production ISR code.
Read Article
Part 15: Bootloader Development
Custom IAP bootloader design, UART and USB DFU update interfaces, flash programming, application jump, and failsafe rollback strategies.
Read Article