Back to Technology

STM32 Part 18: Production Readiness

March 31, 2026 Wasil Zafar 30 min read

The gap between a working prototype and production firmware is bridged by watchdogs that recover from lockups, HardFault handlers that capture crash context, option bytes that lock the device, and automated CI/CD pipelines that catch regressions before they reach hardware.

Table of Contents

  1. Production Firmware Mindset
  2. Independent Watchdog (IWDG)
  3. Window Watchdog (WWDG)
  4. HardFault Handler
  5. Flash Option Bytes
  6. Code Signing & Secure Boot
  7. CI/CD for Embedded
  8. Production Checklist
  9. Exercises
  10. Production Readiness Tool
  11. Conclusion & Series Complete
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.

STM32 Unleashed: HAL Driver Development

Your 18-step learning path • Currently on Step 18 (Final)
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.

Simple Approach: CRC32 + Magic Header

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:

  1. Initialise the IWDG in main() immediately after SystemClock_Config().
  2. Insert HAL_Delay(4000); in the main loop without the refresh call. Observe the MCU reset (LED goes dark then reinitialises).
  3. Add HAL_IWDG_Refresh(&hiwdg); before the delay. Verify that no reset occurs.
  4. 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:

  1. Save PC, LR, and CFSR to RTC backup registers DR0, DR1, DR2 respectively. Write a magic value 0xFAULTED to DR3 as a marker.
  2. Transmit a "FAULT" message over UART that includes the register values in hex format. Ensure UART is initialised before the fault handler runs.
  3. 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:

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. 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
Technology