
Legacy embedded firmware is not simply old code—it is field-proven software that has survived silicon errata, board-level revisions, toolchain upgrades, and critical operational environments. However, as hardware platforms evolve and feature requirements expand, legacy architectures often resist modification. Tightly coupled hardware dependencies, monolithic interrupt handlers, hidden global mutable state, and absent unit test harnesses create high friction for engineering teams.
Refactoring embedded C and C++ differs fundamentally from enterprise software refactoring. In resource-constrained microcontrollers and real-time operating systems (RTOS), timing predictability and memory layout are correctness criteria. A refactoring change that preserves functional output but increases Worst-Case Execution Time (WCET) by 15% can cause an actuator deadline overrun, trigger a watchdog reset, or destabilize a communication bus.
This guide details a disciplined, measurement-first methodology for refactoring legacy embedded software safely, establishing test harnesses via architectural seams, and incrementally modernizing firmware without introducing regressions.
+-------------------------------------------------------------------------------+| EMBEDDED REFACTORING WORKFLOW PIPELINE |+-------------------------------------------------------------------------------+| || +-----------------------------+ || | 1. MONOLITHIC LEGACY CODE | - Mixed hardware access & domain logic || | (Baseline Status) | - Zero unit testability; fragile timing || +--------------+--------------+ || | || v || +-----------------------------+ TARGET BASELINE MEASUREMENT || | 2. CHARACTERIZE & MEASURE | +---------------------------------------+ || | - Measure ISR WCET | | * DWT_CYCCNT / PMU cycle counting | || | - Measure Stack HWM | | * Flash (.text/.rodata) & SRAM map | || | - Record Bus Throughput | | * FreeRTOS Task High-Water Mark | || +--------------+--------------+ +---------------------------------------+ || | || v || +-----------------------------+ SEAM CREATION || | 3. INTRODUCE SEAMS | +---------------------------------------+ || | - Link-time / weak syms | | * Mockable interfaces for I/O | || | - Function pointer ops | | * Decouple MMIO from application | || | - CMake target splits | | * Zero changes to observable behavior| || +--------------+--------------+ +---------------------------------------+ || | || v || +-----------------------------+ DOMAIN LOGIC EXTRACTION || | 4. EXTRACT PURE LOGIC | +---------------------------------------+ || | - State machines & FSMs | | * Zero hardware includes | || | - Parsers & Framers | | * 100% portable ANSI C99/C11 | || | - Math & Filter routines | | * Pass state via context structs | || +--------------+--------------+ +---------------------------------------+ || | || v || +-----------------------------+ AUTOMATED HOST VERIFICATION || | 5. HOST-BASED TEST HARNESS | +---------------------------------------+ || | - Unity / CMock / CppUT | | * Millisecond execution in CI | || | - ASan / UBSan sanitizer | | * Branch & MC/DC structural coverage | || | - Comprehensive unit test| | * Strict invariant validation | || +--------------+--------------+ +---------------------------------------+ || | || v || +-----------------------------+ TARGET HARDWARE VALIDATION || | 6. ON-TARGET RE-MEASUREMENT | +---------------------------------------+ || | - Compare vs. Baseline | | * WCET Timing regression = BLOCKER | || | - Verify RAM / Stack HWM | | * Memory footprint regression = FAIL | || | - Full HIL integration | | * Pass = Safe to release commit | || +-----------------------------+ +---------------------------------------+ || |+-------------------------------------------------------------------------------+
When refactoring desktop or cloud software, functional correctness and code clarity dominate design decisions. In embedded systems, three non-functional constraints restrict refactoring operations:
In hard real-time systems, an operation delivered late is an incorrect operation. Compilers optimize instruction sequences based on register availability, inlining heuristics, and loop unrolling. Extracting a function or introducing an indirection layer (such as a function pointer or wrapper) can introduce register spills to stack, function call prologues/epilogues, and branch predictor misses. In interrupt service routines (ISRs) and high-frequency control loops, these extra cycles can violate critical timing constraints.
Embedded software directly observes memory geometry:
struct layout without considering compiler padding bytes can corrupt memory-mapped I/O (MMIO) access or network framing.Legacy code often relies on implicit hardware synchronization, such as assuming an interrupt will not fire during a specific sequence of instructions, or relying on non-atomic read-modify-write operations on global variables. Modifying compiler optimization flags or refactoring variable scopes can expose latent data races that were masked by specific compiler-generated instruction sequences.
Before modifying a single line of legacy source code, you must establish an empirical baseline on the target hardware. This baseline serves as a regression contract throughout the refactoring lifecycle.
/*** @file timing_baseline.h* @brief Empirical performance and memory footprint characterization.*/#ifndef TIMING_BASELINE_H#define TIMING_BASELINE_H#include <stdint.h>#include <stddef.h>#include <stdbool.h>#define BENCHMARK_SAMPLE_COUNT 10000U#define ISR_BENCHMARK_COUNT 8U#define TASK_BENCHMARK_COUNT 4Utypedef struct {uint32_t min_cycles;uint32_t max_cycles; /* Worst-Case Execution Time (WCET) */uint64_t total_cycles;uint32_t sample_count;} execution_stat_t;typedef struct {execution_stat_t isr_stats[ISR_BENCHMARK_COUNT];execution_stat_t task_switch_stats[TASK_BENCHMARK_COUNT];uint32_t stack_high_water_bytes[TASK_BENCHMARK_COUNT];uint32_t flash_text_bytes;uint32_t flash_rodata_bytes;uint32_t sram_data_bytes;uint32_t sram_bss_bytes;} firmware_baseline_t;void baseline_init_cycle_counter(void);uint32_t baseline_read_cyccnt(void);void baseline_record_execution(execution_stat_t *stat, uint32_t start_cyc, uint32_t end_cyc);void baseline_capture_memory_footprint(firmware_baseline_t *out_baseline);#endif /* TIMING_BASELINE_H */
On ARM Cortex-M3/M4/M7/M33/M55/M85 architectures, the Data Watchpoint and Trace (DWT) unit provides a 32-bit hardware cycle counter (DWT->CYCCNT). To ensure cycle measurements are not skewed by compiler instruction reordering, memory barriers must surround the capture points:
#include "timing_baseline.h"/* CMSIS Core Header for ARM Cortex-M */#if defined(__ARM_ARCH_7M__) || defined(__ARM_ARCH_7EM__) || defined(__ARM_ARCH_8M_MAIN__)#include "core_cm4.h"void baseline_init_cycle_counter(void) {/* Enable Trace System and DWT Hardware Cycle Counter */CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;DWT->CYCCNT = 0U;}static inline uint32_t baseline_read_cyccnt_bounded(void) {__asm__ volatile("" ::: "memory"); /* Compiler memory barrier */uint32_t cycles = DWT->CYCCNT;__asm__ volatile("" ::: "memory");return cycles;}#else/* Fallback for Cortex-M0/M0+ using SysTick or a 32-bit Hardware Timer */void baseline_init_cycle_counter(void) {/* Initialize dedicated 32-bit hardware peripheral timer (e.g., TIM2) */}static inline uint32_t baseline_read_cyccnt_bounded(void) {return TIM2->CNT;}#endifvoid baseline_record_execution(execution_stat_t *stat, uint32_t start_cyc, uint32_t end_cyc) {/* Unsigned 32-bit arithmetic naturally handles a single timer overflow */uint32_t elapsed = end_cyc - start_cyc;if (stat->sample_count == 0U) {stat->min_cycles = elapsed;stat->max_cycles = elapsed;} else {if (elapsed < stat->min_cycles) {stat->min_cycles = elapsed;}if (elapsed > stat->max_cycles) {stat->max_cycles = elapsed;}}stat->total_cycles += elapsed;stat->sample_count++;}
+-------------------------------------------------------------------------------+| HEXAGONAL ARCHITECTURE IN EMBEDDED C |+-------------------------------------------------------------------------------+| || HOST TEST HARNESS (CI / POSIX) TARGET HARDWARE (ARM Cortex-M) || || +--------------------------+ +--------------------------+ || | Unity / CMock Runner | | FreeRTOS Task / App | || +------------+-------------+ +------------+-------------+ || | | || +--------------------+---------------------+ || | || v || +----------------------------------------------------------------------+ || | CORE DOMAIN LOGIC (PURE C99/C11) | || | - HDLC Frame Parser (hdlc_framer.c) - Circular Buffer (ringbuf.c) | || | - Checksum Algorithms (crc16.c) - State Machine (comm_fsm.c) | || | - Digital Filter (biquad.c) - Config Engine (param_mgr.c) | || | | || | * No hardware register inclusions (#include "stm32f4xx.h" FORBIDDEN)| || | * Re-entrant context pointers passed explicitly | || | * Static memory allocation only; zero heap dependency | || +---------------------------------+------------------------------------+ || | || v [HAL Port Interface] || +----------------------------------+ || | HARDWARE ABSTRACTION LAYER (HAL)| || | (hal_spi.h, hal_uart.h, etc.) | || +-----------------+----------------+ || | || +------------------+------------------+ || | | || v v || +--------------------------+ +--------------------------+ || | MOCK ADAPTER (TEST) | | HARDWARE DRIVER (PROD) | || | - mock_spi.c | | - stm32_spi_hal.c | || | - Host memory buffers | | - MMIO Register Access | || | - Injected error states | | - DMA Controller & NVIC | || +--------------------------+ +--------------------------+ || |+-------------------------------------------------------------------------------+
A seam, as defined by Michael Feathers, is a place where behavior can be altered without editing the source code in that location. In embedded C, seams enable substituting physical hardware interactions with software test doubles on the host workstation.
+-------------------------------------------------------------------------------+| SEAM IMPLEMENTATION STRATEGIES IN C |+-------------------+------------------------+----------------+-----------------+| Seam Type | Mechanism | Execution Cost | Memory Cost |+-------------------+------------------------+----------------+-----------------+| Compile-Time | CMake target sources / | 0 cycles | 0 bytes || | Preprocessor `#include`| (Full inlining)| |+-------------------+------------------------+----------------+-----------------+| Link-Time | `__attribute__((weak))`| 0 cycles | 0 bytes || | or `-Wl,--wrap=func` | (Direct call) | |+-------------------+------------------------+----------------+-----------------+| Object / Virtual | Struct of function | 1-3 cycles | 4-8 bytes/op || | pointers (`ops_t`) | (Indirect BLX) | (Table in Flash)|+-------------------+------------------------+----------------+-----------------+| Register-Level | Memory-mapped struct | 0 cycles | 0 bytes || | vs mock memory buffer | (Direct MMIO) | (Mock array RAM)|+-------------------+------------------------+----------------+-----------------+
Compile-time seams switch translation units via the build system (CMake/Make). Link-time seams leverage symbol resolution mechanics to override hardware functions with test mocks.
Using GNU linker symbol wrapping (-Wl,--wrap=symbol_name), the linker redirects calls to flash_erase_sector to __wrap_flash_erase_sector during test builds, without modifying production code:
/*** @file flash_driver.h* @brief Production Flash memory driver interface.*/#ifndef FLASH_DRIVER_H#define FLASH_DRIVER_H#include <stdint.h>#include <stddef.h>int flash_erase_sector(uint32_t sector_addr);int flash_write(uint32_t addr, const uint8_t *data, size_t len);#endif /* FLASH_DRIVER_H */
In the host unit test build:
/*** @file test_flash_mock.c* @brief Link-time wrap implementation for host-based testing.*/#include "flash_driver.h"#include <string.h>#include <assert.h>#define MOCK_FLASH_SIZE (64U * 1024U)static uint8_t s_mock_flash_memory[MOCK_FLASH_SIZE];static uint32_t s_erase_call_count = 0U;/* GNU ld symbol wrap: replaces flash_erase_sector */int __wrap_flash_erase_sector(uint32_t sector_addr) {if (sector_addr >= MOCK_FLASH_SIZE) {return -1; /* Out of bounds error */}s_erase_call_count++;memset(&s_mock_flash_memory[sector_addr], 0xFF, 4096U);return 0;}int __wrap_flash_write(uint32_t addr, const uint8_t *data, size_t len) {if ((addr + len) > MOCK_FLASH_SIZE) {return -1;}memcpy(&s_mock_flash_memory[addr], data, len);return 0;}
When dynamic switching between hardware implementations (e.g., hardware SPI vs. bit-banged SPI vs. diagnostic loopback) is required at runtime, an operation table (struct of function pointers) provides a standardized abstraction:
/*** @file hal_spi.h* @brief Hardware-independent SPI peripheral abstraction table.*/#ifndef HAL_SPI_H#define HAL_SPI_H#include <stdint.h>#include <stddef.h>#include <stdbool.h>typedef struct spi_driver spi_driver_t;typedef struct {int (*init)(spi_driver_t *instance, uint32_t baudrate_hz);int (*transfer)(spi_driver_t *instance, const uint8_t *tx_buf, uint8_t *rx_buf, size_t len);int (*deinit)(spi_driver_t *instance);} spi_ops_t;struct spi_driver {const spi_ops_t *ops;void *hw_context; /* Pointer to peripheral registers or mock state */bool is_initialized;};static inline int spi_init(spi_driver_t *drv, uint32_t baudrate_hz) {if ((drv == NULL) || (drv->ops == NULL) || (drv->ops->init == NULL)) {return -1;}return drv->ops->init(drv, baudrate_hz);}static inline int spi_transfer(spi_driver_t *drv, const uint8_t *tx, uint8_t *rx, size_t len) {if ((drv == NULL) || (drv->ops == NULL) || (drv->ops->transfer == NULL)) {return -1;}return drv->ops->transfer(drv, tx, rx, len);}#endif /* HAL_SPI_H */
Legacy firmware frequently concentrates parsing logic, protocol state machines, and hardware manipulation directly within Interrupt Service Routines (ISRs). This creates severe latency, prevents unit testing, and risks system instability.
+-------------------------------------------------------------------------------+| STRANGLER FIG REFACTORING FOR INTERRUPT HANDLERS |+-------------------------------------------------------------------------------+| || BEFORE (Monolithic ISR): || +-----------------------------------------------------------------------+ || | UART1_IRQHandler() | || | - Direct MMIO access (UART1->DR, UART1->SR) | || | - 300 lines of inline HDLC frame parsing & byte stuffing logic | || | - Inline CRC-16 calculation loop over received payload | || | - Direct RTOS queue dispatch: xQueueSendFromISR() | || | - WCET: ~180 microseconds (Blocks lower-priority interrupts!) | || +-----------------------------------------------------------------------+ || || | || v [Refactoring Step: Extract Core Logic] || || AFTER (Thin Adapter ISR + Pure Protocol Module): || +--------------------------------------+ +----------------------------+ || | UART1_IRQHandler() (Thin Adapter) | | hdlc_framer.c (Pure Logic) | || | - Read MMIO data register (DR) |-->| - Pure State Machine | || | - Feed byte to hdlc_parse_byte() | | - CRC-16 computation | || | - If frame complete: signal RTOS |<--| - Zero hardware includes | || | - Manage TXE/RXNE interrupt flags | | - 100% Host Unit-Tested | || | - WCET: < 1.2 microseconds | +----------------------------+ || +--------------------------------------+ || |+-------------------------------------------------------------------------------+
/*** @file hdlc_framer.h* @brief Pure, hardware-agnostic HDLC framing state machine.*/#ifndef HDLC_FRAMER_H#define HDLC_FRAMER_H#include <stdint.h>#include <stddef.h>#include <stdbool.h>#define HDLC_FLAG_BYTE 0x7EU#define HDLC_ESCAPE_BYTE 0x7DU#define HDLC_ESCAPE_XOR 0x20U#define HDLC_MAX_FRAME_SIZE 256Utypedef enum {HDLC_STATE_IDLE,HDLC_STATE_RECEIVING,HDLC_STATE_ESCAPED} hdlc_rx_state_t;typedef struct {uint8_t frame_buffer[HDLC_MAX_FRAME_SIZE];size_t frame_length;hdlc_rx_state_t state;uint16_t running_crc;} hdlc_context_t;void hdlc_init(hdlc_context_t *ctx);bool hdlc_process_rx_byte(hdlc_context_t *ctx, uint8_t byte, uint8_t *out_frame, size_t *out_len);size_t hdlc_encode_frame(const uint8_t *payload, size_t payload_len, uint8_t *out_buf, size_t max_out);#endif /* HDLC_FRAMER_H */
/*** @file uart_driver.c* @brief Hardware adapter ISR connecting CMSIS USART to HDLC state machine.*/#include "hdlc_framer.h"#include "stm32f4xx.h"#include "FreeRTOS.h"#include "queue.h"extern QueueHandle_t g_rx_frame_queue;static hdlc_context_t s_hdlc_ctx;static uint8_t s_completed_frame[HDLC_MAX_FRAME_SIZE];void USART1_IRQHandler(void) {BaseType_t xHigherPriorityTaskWoken = pdFALSE;uint32_t status = USART1->SR;/* Handle Receive Data Register Not Empty (RXNE) */if ((status & USART_SR_RXNE) != 0U) {uint8_t rx_byte = (uint8_t)(USART1->DR & 0xFFU);size_t frame_len = 0U;if (hdlc_process_rx_byte(&s_hdlc_ctx, rx_byte, s_completed_frame, &frame_len)) {/* Full frame decoded: post pointer/data to RTOS worker queue */(void)xQueueSendFromISR(g_rx_frame_queue, s_completed_frame, &xHigherPriorityTaskWoken);}}/* Handle Transmit Data Register Empty (TXE) safely */if (((status & USART_SR_TXE) != 0U) && ((USART1->CR1 & USART_CR1_TXEIE) != 0U)) {/* Service transmit buffer or disable interrupt if complete */USART1->CR1 &= ~USART_CR1_TXEIE;}portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}
Legacy embedded C code frequently relies on scattered static globals, creating synchronization hazards and preventing multiple peripheral instances.
/* ========================================================================== *//* BEFORE: Static Global Spaghetti (Non-reentrant, untestable) *//* ========================================================================== */static uint8_t s_ring_buffer[256];static uint16_t s_head = 0;static uint16_t s_tail = 0;static bool s_is_locked = false;void ring_buf_push(uint8_t byte) {s_ring_buffer[s_head] = byte;s_head = (s_head + 1U) % 256U;}/* ========================================================================== *//* AFTER: Re-entrant Context Object (MISRA-compliant, highly testable) *//* ========================================================================== */typedef struct {uint8_t * const buffer;const size_t capacity;volatile size_t head;volatile size_t tail;} ring_buffer_t;typedef enum {RING_BUF_OK = 0,RING_BUF_ERR_NULL,RING_BUF_ERR_FULL,RING_BUF_ERR_EMPTY} ring_buf_status_t;ring_buf_status_t ring_buffer_push(ring_buffer_t *rb, uint8_t byte) {if ((rb == NULL) || (rb->buffer == NULL)) {return RING_BUF_ERR_NULL;}size_t next_head = (rb->head + 1U);if (next_head >= rb->capacity) {next_head = 0U;}if (next_head == rb->tail) {return RING_BUF_ERR_FULL; /* Buffer full */}rb->buffer[rb->head] = byte;rb->head = next_head;return RING_BUF_OK;}
#ifdef ForestsPreprocessor #ifdef blocks scattered throughout driver logic obscure code paths and prevent multi-target compiler verification:
/*** @file adc_config.h* @brief Constant configuration descriptors placed in Flash (.rodata).*/#ifndef ADC_CONFIG_H#define ADC_CONFIG_H#include <stdint.h>#include <stdbool.h>typedef struct {uint32_t base_address;uint8_t channel_count;uint8_t resolution_bits;uint32_t sampling_cycles;bool supports_dma;bool has_internal_temp_sensor;} adc_hw_descriptor_t;/* Driver references a single immutable descriptor pointer */extern const adc_hw_descriptor_t * const g_active_adc_config;#endif /* ADC_CONFIG_H */
/*** @file adc_config_stm32f4.c* @brief Board-specific descriptor instance compiled per target variant.*/#include "adc_config.h"static const adc_hw_descriptor_t s_stm32f4_adc_desc = {.base_address = 0x40012000U, /* ADC1 Base */.channel_count = 16U,.resolution_bits = 12U,.sampling_cycles = 480U,.supports_dma = true,.has_internal_temp_sensor= true};const adc_hw_descriptor_t * const g_active_adc_config = &s_stm32f4_adc_desc;
Direct macro pointer casting (#define REG (*(volatile uint32_t*)0x4000)) violates MISRA C:2023 Rule 11.4 and prevents unit test mocking. Encapsulating registers inside structured types enables clean pointer redirection:
/*** @file uart_registers.h* @brief MISRA C:2023 compliant MMIO peripheral structure layout.*/#ifndef UART_REGISTERS_H#define UART_REGISTERS_H#include <stdint.h>typedef struct {volatile uint32_t SR; /*!< Status register, Address offset: 0x00 */volatile uint32_t DR; /*!< Data register, Address offset: 0x04 */volatile uint32_t BRR; /*!< Baud rate register, Address offset: 0x08 */volatile uint32_t CR1; /*!< Control register 1, Address offset: 0x0C */volatile uint32_t CR2; /*!< Control register 2, Address offset: 0x10 */volatile uint32_t CR3; /*!< Control register 3, Address offset: 0x14 */} uart_reg_map_t;/* Production memory mapping */#define TARGET_UART1 ((uart_reg_map_t *)0x40011000U)/* Host test double mapping: simply allocate a standard structure instance */#ifdef HOST_TEST_BUILDextern uart_reg_map_t s_mock_uart1;#define MOCK_UART1 (&s_mock_uart1)#endif#endif /* UART_REGISTERS_H */
Automated gates in Continuous Integration (CI) ensure that code refactoring decreases technical debt without introducing silent performance or memory regressions.
| Metric Category | Assessment Tool | Target Threshold | Rationale & Safety Impact |
|---|---|---|---|
| Cyclomatic Complexity v(G) | lizard, pmccabe | v(G) ≤ 10 per function | Ensures maintainability and bounds test case permutations. |
| Cognitive Complexity | SonarQube, lizard | ≤ 15 per function | Minimizes cognitive load during safety-critical audits. |
| Static Stack Consumption | gcc -fstack-usage, puncover | ≤ 70% of allocated task stack | Prevents catastrophic RTOS stack overflows. |
| Flash / SRAM Footprint | Linker .map parser, bloaty | Δ ≤ ±2% vs Baseline | Prevents memory allocation overruns on bounded silicon. |
| Worst-Case Latency (WCET) | DWT->CYCCNT, Oscilloscope / Trace | 0% timing regression on critical ISRs | Guarantees real-time deadline compliance. |
| Structural Code Coverage | gcov, lcov, Bullseye | ≥ 85% Branch, 100% MC/DC for safety paths | Required for ISO 26262 ASIL D / DO-178C Level A compliance. |
| Static Analysis Compliance | cppcheck --addon=misra, PC-lint Plus | Zero Mandatory/Required violations | Enforces memory safety and eliminates undefined behavior. |
Refactoring must be treated as an engineering task with explicit, measurable exit criteria:
-fsanitize=address) and UndefinedBehaviorSanitizer (-fsanitize=undefined).malloc, free) have been introduced into real-time execution paths.Quick Links
Legal Stuff




