I've spent years working in the layer between customer software and firmware — the middleware that has to survive MCU swaps, silicon shortages, and last-minute BOM changes. I've watched teams burn weeks on firmware ports that should have taken days, and I've done enough post-mortems to see the same mistakes repeat across companies.
These aren't theoretical. Every mistake below comes from real porting efforts I've seen or been called in to fix. Most involve moving between STM32, ESP32, and nRF52 — the three families that cover probably 80% of new embedded designs in 2026.
If you're planning a firmware port (or trying to build firmware that won't need a painful port later), this is the list I wish someone had given me five years ago.
Mistake 1: Copy-Pasting Vendor HAL Calls Into Application Logic
This is the original sin of firmware porting. Engineers write application code that directly calls HAL_SPI_Transmit() or nrf_drv_spi_transfer() in business logic functions. When the MCU changes, every file that touches a peripheral has to be rewritten.
The mistake:
// sensor_driver.c — STM32 version
#include "stm32f4xx_hal.h"
extern SPI_HandleTypeDef hspi1;
int sensor_read_temperature(uint16_t *temp_raw) {
uint8_t cmd = 0xD0;
uint8_t buf[2];
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // CS low
HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
HAL_SPI_Receive(&hspi1, buf, 2, 100);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // CS high
*temp_raw = (buf[0] << 8) | buf[1];
return 0;
}
Now you need to port to nRF52. Every line of this function changes. The SPI API is different, the GPIO API is different, and the pin numbering is completely different. Multiply this by 30 sensor/actuator functions and you're looking at a week of tedious, error-prone work.
The fix: Wrap peripheral access behind a thin abstraction. The application code calls your API, not the vendor's.
// hal_spi.h — your abstraction
typedef struct hal_spi* hal_spi_t;
int hal_spi_init(hal_spi_t *handle, const hal_spi_config_t *cfg);
int hal_spi_transfer(hal_spi_t handle, const uint8_t *tx, uint8_t *rx, size_t len);
int hal_spi_cs_assert(hal_spi_t handle);
int hal_spi_cs_deassert(hal_spi_t handle);
// sensor_driver.c — portable version
int sensor_read_temperature(hal_spi_t spi, uint16_t *temp_raw) {
uint8_t cmd = 0xD0;
uint8_t buf[2];
hal_spi_cs_assert(spi);
hal_spi_transfer(spi, &cmd, NULL, 1);
hal_spi_transfer(spi, NULL, buf, 2);
hal_spi_cs_deassert(spi);
*temp_raw = (buf[0] << 8) | buf[1];
return 0;
}
Now porting means writing one new hal_spi_nrf52.c backend. The sensor driver doesn't change at all. This is the single highest-ROI investment in any firmware architecture.
Mistake 2: Hardcoding Interrupt Priorities
STM32 uses a 4-bit priority field (0-15, where 0 is highest). nRF52 uses 3 bits (0-7). ESP32's interrupt system is completely different — it uses levels 1-6 with dedicated high-priority interrupts that can only run from IRAM.
The mistake:
// stm32_setup.c
void setup_interrupts(void) {
HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); // UART at priority 5
HAL_NVIC_SetPriority(SPI1_IRQn, 3, 0); // SPI at priority 3
HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0); // Timer at priority 1 (high)
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); // External interrupt at 2
}
Port this to nRF52 and priorities 5 and above don't exist — the maximum is 7, but the SoftDevice (BLE stack) reserves priorities 0, 1, and 4. Your "high priority" timer at 1 now collides with the SoftDevice and causes random BLE disconnections that take a week to debug.
The fix: Define priority levels semantically and map them per platform.
// irq_priorities.h
typedef enum {
IRQ_PRIO_CRITICAL, // timing-critical, cannot be preempted
IRQ_PRIO_HIGH, // fast peripherals (SPI, timer callbacks)
IRQ_PRIO_MEDIUM, // standard peripherals (UART, I2C)
IRQ_PRIO_LOW, // background tasks (ADC, low-rate sensors)
} irq_priority_level_t;
// irq_priorities_stm32.h
#define IRQ_PRIO_MAP_CRITICAL 1
#define IRQ_PRIO_MAP_HIGH 3
#define IRQ_PRIO_MAP_MEDIUM 5
#define IRQ_PRIO_MAP_LOW 8
// irq_priorities_nrf52.h (SoftDevice reserves 0, 1, 4)
#define IRQ_PRIO_MAP_CRITICAL 2
#define IRQ_PRIO_MAP_HIGH 3
#define IRQ_PRIO_MAP_MEDIUM 5
#define IRQ_PRIO_MAP_LOW 6
Document which priority levels the BLE/Wi-Fi stack reserves. This avoids the most common source of "it works on STM32 but randomly crashes on nRF52" bugs.
Mistake 3: Assuming Memory Layout Is Portable
STM32F4 has a flat memory map — flash, SRAM, and peripherals all in one address space. ESP32 has instruction RAM (IRAM), data RAM (DRAM), SPI flash with caching, and RTC slow memory. nRF52 has flash, RAM, and the SoftDevice sitting in the first chunk of both.
The mistake:
// Works on STM32 — a function pointer stored in flash and called normally
typedef void (*callback_t)(void);
const callback_t isr_table[] __attribute__((section(".rodata"))) = {
handler_timer,
handler_spi,
handler_uart,
};
// On ESP32, this crashes. Functions called from interrupts MUST be in IRAM,
// not flash. Flash access is disabled during SPI operations and cache misses
// cause exceptions in ISR context.
The fix for ESP32:
// ESP32 — ISR handlers must be in IRAM
void IRAM_ATTR handler_timer(void *arg) {
// This function lives in IRAM, safe to call from interrupts
// IMPORTANT: anything this function calls must also be in IRAM
// or be inlined. No calls to flash-resident code.
gpio_set_level(LED_PIN, 1); // gpio_set_level is IRAM-safe
}
// Do NOT call printf, logging functions, or anything that
// accesses flash from an IRAM_ATTR function
On nRF52 with SoftDevice, the first ~116KB of flash and ~8KB of RAM are owned by the SoftDevice. Your linker script must start application code after the SoftDevice region, and the size changes between SoftDevice versions (S132 v7.0 vs v7.2 have different sizes). I've seen boards that worked perfectly until a SoftDevice update shifted the memory map and the application overwrote SoftDevice data.
Build a memory map document for each target. Not in someone's head — in a file checked into the repo. Include reserved regions, stack sizes, and heap configuration.
Mistake 4: Ignoring Clock Tree Differences
Every MCU family has a different clock tree, and peripherals derive their clocks from different sources. A SPI peripheral running at 8 MHz on STM32 might end up at 6.67 MHz or 10 MHz on nRF52 because the available dividers are different.
The mistake:
// STM32: APB2 clock is 84 MHz, SPI prescaler = 16 → 5.25 MHz SPI clock
spi_handle.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16;
// nRF52: SPI frequency options are discrete:
// 125K, 250K, 500K, 1M, 2M, 4M, 8M
// There is no 5.25 MHz option. Engineer picks 8M and the
// sensor can't handle it. Or picks 4M and the data rate
// is too slow for the application.
This is worse for UART. STM32 can generate almost any baud rate from its flexible prescalers. nRF52 generates baud rates from a 16 MHz clock with limited dividers — 115200 baud actually runs at 115942 baud (0.64% error). For most UART devices this is fine, but I've seen it cause framing errors with picky GPS modules that barely tolerate 0.5% error.
The fix: Specify peripheral speeds as requirements (minimum and maximum), not as exact register values.
// peripheral_config.h — specify intent, not register values
typedef struct {
uint32_t freq_min_hz; // minimum acceptable clock
uint32_t freq_max_hz; // maximum acceptable clock
uint32_t freq_target_hz; // ideal clock
} spi_clock_requirement_t;
// The platform-specific init code finds the best available
// divider and logs a warning if it falls outside the range.
int hal_spi_init(hal_spi_t *handle, const hal_spi_config_t *cfg) {
uint32_t actual_freq = find_closest_spi_freq(cfg->clock.freq_target_hz);
if (actual_freq < cfg->clock.freq_min_hz ||
actual_freq > cfg->clock.freq_max_hz) {
LOG_WARN("SPI%d: requested %u Hz, got %u Hz (out of range)",
cfg->instance, cfg->clock.freq_target_hz, actual_freq);
return -EINVAL;
}
LOG_INFO("SPI%d: configured at %u Hz (target: %u Hz)",
cfg->instance, actual_freq, cfg->clock.freq_target_hz);
// ... configure the peripheral
return 0;
}
Mistake 5: Porting the RTOS Configuration Verbatim
Teams running FreeRTOS on STM32 copy their FreeRTOSConfig.h to the ESP32 build and wonder why things break. The problem: ESP32's FreeRTOS is a fork by Espressif (ESP-IDF FreeRTOS) that adds symmetric multiprocessing, has different defaults for tick rate, and uses a different idle task hook mechanism.
The mistake:
// FreeRTOSConfig.h — copied from STM32 project
#define configUSE_PREEMPTION 1
#define configTICK_RATE_HZ 1000
#define configMINIMAL_STACK_SIZE 128 // in words (512 bytes on ARM)
#define configTOTAL_HEAP_SIZE (32 * 1024)
#define configUSE_TICKLESS_IDLE 1
Problems when this lands on ESP32:
-
configMINIMAL_STACK_SIZEof 128 words (512 bytes) is dangerously small on ESP32 — ESP-IDF tasks typically need 2048-4096 bytes minimum because of deeper call stacks in the Wi-Fi/BLE stack. -
configTOTAL_HEAP_SIZEis ignored — ESP32 uses its own multi-region heap allocator. -
configUSE_TICKLESS_IDLEinteracts badly with Wi-Fi power management on ESP32.
On nRF52 with SoftDevice, you can't use FreeRTOS's standard vPortSVCHandler and xPortPendSVHandler — the SoftDevice owns those interrupt vectors. You need the nRF52-specific FreeRTOS port that routes through the SoftDevice.
The fix: Treat RTOS configuration as platform-specific. Keep a base config with shared application-level settings (task priorities, queue sizes) and a platform config with hardware-dependent values.
// rtos_config_common.h — shared across all platforms
#define APP_TASK_PRIORITY_SENSOR 3
#define APP_TASK_PRIORITY_COMMS 4
#define APP_TASK_PRIORITY_CONTROL 5
#define APP_QUEUE_SIZE_SENSOR 16
#define APP_QUEUE_SIZE_CMD 8
// rtos_config_stm32.h
#define PLATFORM_MIN_STACK_SIZE 512 // bytes
#define PLATFORM_DEFAULT_STACK_SIZE 1024
#define PLATFORM_TICK_RATE_HZ 1000
#define PLATFORM_USE_TICKLESS_IDLE 1
// rtos_config_esp32.h
#define PLATFORM_MIN_STACK_SIZE 2048 // ESP-IDF needs more
#define PLATFORM_DEFAULT_STACK_SIZE 4096
#define PLATFORM_TICK_RATE_HZ 100 // ESP-IDF default
#define PLATFORM_USE_TICKLESS_IDLE 0 // conflicts with Wi-Fi PM
Mistake 6: Not Auditing DMA Channel Allocation
DMA is one of the least portable subsystems across MCU families. STM32F4 has 2 DMA controllers with 8 streams each, and each stream can connect to specific peripherals via a request mapping table. nRF52 uses EasyDMA, which is peripheral-specific — each peripheral (SPI, I2C, UART) has its own DMA tied to it. ESP32 uses a GDMA controller where channels are dynamically assignable.
The mistake:
// STM32: manually assign DMA stream to SPI
// DMA1 Stream 3, Channel 0 → SPI2_RX (from reference manual)
hdma_spi2_rx.Instance = DMA1_Stream3;
hdma_spi2_rx.Init.Channel = DMA_CHANNEL_0;
hdma_spi2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
// ...
// Engineer ports to nRF52 and tries to find equivalent DMA channels.
// There are none. nRF52's EasyDMA is built into each peripheral.
// You configure it by setting the TXD.PTR, TXD.MAXCNT, RXD.PTR, RXD.MAXCNT
// registers on the SPIM peripheral itself.
The fix: Abstract DMA as a property of the peripheral, not a separate subsystem.
// Your SPI config struct should express whether DMA is desired,
// not which DMA channel to use
typedef struct {
uint8_t instance; // SPI0, SPI1, etc.
uint32_t freq_hz;
bool use_dma; // platform code handles the details
size_t dma_threshold; // only use DMA for transfers > N bytes
} hal_spi_config_t;
// hal_spi_stm32.c — DMA setup is internal
static int setup_dma_for_spi(uint8_t spi_instance, dma_handles_t *dma) {
// Look up DMA stream/channel from a mapping table
const dma_mapping_t *map = get_spi_dma_mapping(spi_instance);
if (!map) {
LOG_WARN("No DMA available for SPI%d, falling back to polling", spi_instance);
return -ENOTSUP;
}
// ... configure DMA
return 0;
}
// hal_spi_nrf52.c — EasyDMA is automatic, just set the buffer pointers
// No separate DMA configuration needed
The key insight: on some platforms DMA is a separate resource you must manage; on others it's transparent. Your abstraction should hide this difference.
Mistake 7: Forgetting That GPIO Numbering Means Different Things
STM32 uses port+pin (GPIOA pin 5). nRF52 uses a flat numbering scheme (P0.05, P0.13, P1.09). ESP32 uses GPIO numbers (GPIO_NUM_18) that may or may not correspond to the physical pin on the package. On top of this, pin muxing rules differ — STM32 has alternate function registers, nRF52 lets you route most peripherals to any pin, and ESP32 uses a GPIO matrix with some restrictions on certain functions.
The mistake:
// Scattered across the codebase, different files:
#define LED_PIN GPIO_PIN_5 // Which port? GPIOA? GPIOB?
#define BUTTON_PIN 13 // Is this a port pin or a flat GPIO number?
#define SPI_CS 4 // 4 on which port?
This is ambiguous even on a single platform. During a port, it's a nightmare.
The fix: One file, one table, fully qualified.
// board_pinmap.h — ONE file defines ALL pin assignments for a board
// This file is the ONLY thing that changes when the PCB changes
#if defined(BOARD_CUSTOM_STM32F4)
#define PIN_LED_STATUS { .port = GPIOA, .pin = 5 }
#define PIN_BUTTON_USER { .port = GPIOC, .pin = 13 }
#define PIN_SPI_SENSOR_CS { .port = GPIOA, .pin = 4 }
#define PIN_UART_DEBUG_TX { .port = GPIOA, .pin = 2 }
#define PIN_UART_DEBUG_RX { .port = GPIOA, .pin = 3 }
#elif defined(BOARD_CUSTOM_NRF52840)
// nRF52 uses flat pin numbers: port * 32 + pin
#define PIN_LED_STATUS NRF_GPIO_PIN_MAP(0, 13)
#define PIN_BUTTON_USER NRF_GPIO_PIN_MAP(0, 11)
#define PIN_SPI_SENSOR_CS NRF_GPIO_PIN_MAP(1, 8)
#define PIN_UART_DEBUG_TX NRF_GPIO_PIN_MAP(0, 6)
#define PIN_UART_DEBUG_RX NRF_GPIO_PIN_MAP(0, 8)
#elif defined(BOARD_CUSTOM_ESP32S3)
#define PIN_LED_STATUS GPIO_NUM_2
#define PIN_BUTTON_USER GPIO_NUM_0
#define PIN_SPI_SENSOR_CS GPIO_NUM_10
#define PIN_UART_DEBUG_TX GPIO_NUM_43
#define PIN_UART_DEBUG_RX GPIO_NUM_44
#else
#error "No board defined — add your pin map"
#endif
When you port to a new board, you add one #elif block. Nothing else in the codebase mentions pin numbers.
Mistake 8: Testing Only the Happy Path After Porting
The firmware boots, the LED blinks, SPI reads return data, UART prints work. Ship it, right? No. The failure modes are where ports break.
What gets missed:
-
Peripheral error recovery. STM32's HAL sets error flags on
hspi.ErrorCode— your error handler clears them and retries. nRF52's SPIM peripheral uses event registers (EVENTS_STOPPED) that work differently. Your error recovery code from STM32 does nothing on nRF52, so the first bus error hangs the SPI peripheral forever. - Timing edge cases. A watchdog timer that worked with STM32's 32 kHz LSI oscillator (which has +/- 10% accuracy) may trip on nRF52's 32.768 kHz crystal (much more accurate), or vice versa, depending on how you calculated the timeout.
- Power state transitions. Sleep/wake behavior is wildly different. STM32 has STOP, STANDBY, and SHUTDOWN modes. nRF52 has System ON (idle with RAM retention) and System OFF. ESP32 has light sleep, deep sleep, and hibernation. Your "wake from sleep" code is not portable.
- Stack overflow under load. A task that used 400 bytes of stack on STM32 might use 800 on ESP32 due to deeper call chains in ESP-IDF library functions.
The fix: Build a porting test checklist and run it on every target.
// port_validation_tests.c — run on each new target
void test_spi_error_recovery(void) {
// Intentionally cause a bus error (disconnect MISO)
// Verify the driver detects and recovers
hal_spi_transfer(spi, tx, rx, 4);
assert(hal_spi_get_error(spi) != HAL_ERR_NONE);
hal_spi_reset(spi);
// Verify SPI works again after recovery
int ret = hal_spi_transfer(spi, tx, rx, 4);
assert(ret == 0);
}
void test_watchdog_timing(void) {
// Start watchdog with 2-second timeout
hal_wdt_start(2000);
// Sleep for 1.9 seconds — should NOT trigger
hal_delay_ms(1900);
hal_wdt_feed();
// If we get here, the watchdog timing is correct on this platform
}
void test_sleep_wake_integrity(void) {
volatile uint32_t canary = 0xDEADBEEF;
hal_enter_sleep(SLEEP_MODE_LIGHT);
// ... external interrupt wakes us
assert(canary == 0xDEADBEEF); // RAM retained?
assert(hal_spi_transfer(spi, tx, rx, 4) == 0); // peripherals re-inited?
}
Mistake 9: Trying to Port Everything at Once
I've seen teams attempt to port an entire 50-file firmware project in one shot. They create a new target in the build system, switch all the HAL calls, and then spend three weeks debugging because nothing works and they have no idea which change broke what.
The fix: Port in layers, bottom-up, validating at each step.
Week 1: Board bring-up. Get the chip running — clock config, a blinking LED, and
printfover UART. Nothing else. If this doesn't work, you can't debug anything above it.Week 2: Peripheral drivers. Port one peripheral at a time. SPI first (because sensors usually need it), then I2C, then timers, then DMA. Test each one in isolation with a simple loopback or sensor read before moving on.
Week 3: RTOS + middleware. Bring up FreeRTOS (or Zephyr, or your RTOS of choice) with a single task. Verify scheduling, then add tasks one at a time. Verify inter-task communication (queues, semaphores) before adding application logic.
Week 4: Application logic. If your abstraction layer is done right, this step should require zero changes to application code. If it requires changes, your abstraction leaked — fix the abstraction, don't patch the application.
Commit at each step. If step 3 breaks something, you can diff against step 2 and see exactly what changed.
Mistake 10: No Automated Build for Multiple Targets
After the port, you have two (or more) targets. Developers work on one target and forget to compile the other. Six months later someone tries to build the second target and it's broken — header files moved, function signatures changed, a new module was added without a platform implementation.
The mistake:
# "Just build the one you're working on"
make TARGET=stm32f4
# Nobody runs this for months:
make TARGET=nrf52840
# It's been broken since March
The fix: CI that builds every target on every commit.
# .github/workflows/firmware-build.yml
name: Multi-target firmware build
on: [push, pull_request]
jobs:
build:
strategy:
matrix:
target: [stm32f4, nrf52840, esp32s3]
runs-on: ubuntu-latest
container:
image: ghcr.io/your-org/firmware-toolchain:latest
steps:
- uses: actions/checkout@v4
- name: Build ${{ matrix.target }}
run: make TARGET=${{ matrix.target }}
- name: Run unit tests
run: make test TARGET=${{ matrix.target }}
If you don't have CI, at minimum add a build_all.sh script and run it before every merge. The 30 seconds it takes to compile both targets saves the hours it takes to fix a broken build that drifted for months.
The Common Thread
Every one of these mistakes comes from the same root cause: treating firmware porting as a search-and-replace exercise instead of an architecture problem.
The time to make firmware portable is before you need to port it. The second best time is when you're planning the port — before you start changing code. An afternoon spent mapping out peripheral differences, memory layouts, and interrupt schemes saves weeks of debugging.
If you're facing a port right now and the codebase has no abstraction layer, resist the temptation to "just get it working" on the new target by copy-pasting and patching. You'll end up maintaining two divergent codebases. Take the time to extract an abstraction layer during the port — it's the last time you'll need to do this work.
Further Reading
- Zephyr's device driver model — a good reference for how a mature project handles multi-platform peripheral abstraction
- ESP-IDF FreeRTOS SMP changes — critical reading before porting FreeRTOS config to ESP32
- nRF5 SDK to nRF Connect SDK migration guide — Nordic's own porting guide, useful patterns even for non-Nordic ports
Pranav Jain is an embedded systems and middleware engineer specializing in the abstraction layer between application software and firmware. He builds tools and writes about making firmware portable, testable, and maintainable. Find his work on GitHub.
Top comments (0)