You've been told to port the firmware from one MCU to another. Maybe the chip went EOL. Maybe the shortage made it unavailable. Maybe the new product variant needs Bluetooth and your current MCU doesn't have it.
Whatever the reason, you're staring at tens of thousands of lines of C that were written for one specific chip, and you need them running on a different one. This guide is the process I follow. It won't make the port painless, but it'll keep you from wasting time on the wrong things.
Before You Touch Any Code
Step 0: Understand the Target
Before you change a single line, answer these questions about the target MCU:
| Question | Why It Matters |
|---|---|
| What's the clock tree look like? | Peripheral speeds, PLL config, clock domains are never the same |
| What DMA model does it use? | Linked-list DMA vs channel-based vs no DMA — big architectural impact |
| What's the interrupt priority scheme? | ARM NVIC is standard, but the number of priority levels and grouping differs |
| What SDK/HAL does the vendor provide? | STM32 HAL vs nRF Connect SDK vs ESP-IDF — completely different philosophies |
| What's the flash/RAM budget? | Tight MCUs may need code restructuring |
| What RTOS does the vendor SDK expect? | nRF Connect SDK assumes Zephyr. ESP-IDF has FreeRTOS built in. STM32 HAL is RTOS-agnostic. |
Spend a day on this. Read the reference manual's clock tree and peripheral overview sections. It saves weeks later.
Step 1: Dependency Audit
This is the most important step. Catalog every vendor-specific dependency in your codebase.
# Quick audit for STM32 HAL dependencies
grep -rn "HAL_\|LL_\|__HAL_\|stm32" src/ --include="*.c" --include="*.h" | \
grep -v "// " | \
sort -t: -k1,1 | \
uniq -c | sort -rn > hal_dependencies.txt
# Count by peripheral type
grep -oP "HAL_(GPIO|SPI|I2C|UART|TIM|DMA|ADC|DAC|RCC|PWR|FLASH|RTC|IWDG|WWDG|CAN|USB|ETH)" \
hal_dependencies.txt | sort | uniq -c | sort -rn
Typical output:
187 HAL_GPIO
143 HAL_SPI
98 HAL_I2C
87 HAL_TIM
76 HAL_UART
54 HAL_DMA
34 HAL_ADC
29 HAL_RCC
18 HAL_PWR
12 HAL_FLASH
8 HAL_RTC
This tells you where the work is. GPIO and SPI will take the most effort not because they're complex, but because there are the most call sites.
Step 2: Classify Each Dependency
Not all HAL calls are equal. Classify them:
Direct equivalents (green): The target MCU has a function that does exactly the same thing with different syntax. HAL_GPIO_WritePin() → nrf_gpio_pin_write(). These are mechanical translations.
Behavioral differences (yellow): The target MCU can do the same thing, but the API works differently. STM32's SPI uses handles and callbacks; nRF Connect SDK uses Zephyr's SPI API with transaction descriptors. You need to understand both models.
No equivalent (red): The target MCU doesn't have the feature, or implements it fundamentally differently. STM32's flexible DMA linked-list mode vs nRF52's EasyDMA which has a different set of constraints. These need redesign.
Migration Effort Matrix:
┌─────────────────────────────────────────────────┐
│ Peripheral │ Calls │ Class │ Est. Effort │
├───────────────┼─────────┼────────┼──────────────┤
│ GPIO │ 187 │ Green │ 1 day │
│ SPI │ 143 │ Yellow │ 3 days │
│ I2C │ 98 │ Yellow │ 2 days │
│ Timer │ 87 │ Yellow │ 3 days │
│ UART │ 76 │ Green │ 1 day │
│ DMA │ 54 │ Red │ 5 days │
│ ADC │ 34 │ Yellow │ 2 days │
│ Clock config │ 29 │ Red │ 2 days │
│ Power mgmt │ 18 │ Yellow │ 1 day │
│ Flash/NVM │ 12 │ Red │ 2 days │
│ RTC │ 8 │ Green │ 0.5 days │
├───────────────┼─────────┼────────┼──────────────┤
│ TOTAL │ 746 │ │ ~22 days │
└─────────────────────────────────────────────────┘
The Migration Process
Phase 1: Build System (Days 1-2)
Get the project compiling for the new target — even if nothing works yet.
# CMakeLists.txt — add target selection
set(TARGET_MCU "stm32f4" CACHE STRING "Target MCU family")
set_property(CACHE TARGET_MCU PROPERTY STRINGS stm32f4 nrf52840 esp32s3)
if(TARGET_MCU STREQUAL "nrf52840")
set(CMAKE_TOOLCHAIN_FILE ${NRF_SDK_PATH}/toolchain.cmake)
add_subdirectory(hal/nrf52)
elseif(TARGET_MCU STREQUAL "stm32f4")
add_subdirectory(hal/stm32)
endif()
# Application code is the SAME regardless of target
add_subdirectory(application)
target_link_libraries(application PRIVATE hal)
If you're migrating to a Zephyr-based SDK (nRF Connect), you'll need to restructure into a Zephyr application. This is a bigger lift:
my_project/
├── CMakeLists.txt # Zephyr-style CMake
├── prj.conf # Kconfig
├── boards/ # Board overlays
│ ├── nrf52840dk_nrf52840.overlay
│ └── nucleo_f429zi.overlay
├── src/
│ └── main.c
└── hal/ # Your abstraction (if not using Zephyr's drivers directly)
Phase 2: Clock Tree and Startup (Days 2-3)
This is where most estimates go wrong. Every MCU has a different clock tree, and getting it wrong produces bizarre failures later.
STM32: CubeMX generates SystemClock_Config(). Clock tree has HSE/HSI → PLL → SYSCLK → AHB/APB prescalers.
nRF52: Simpler clock model. HFCLK (64 MHz, from crystal or RC) and LFCLK (32.768 kHz). Less configurable but less error-prone.
ESP32: Dual-core, much more complex. PLL, CPU frequency, APB frequency, RTC clocks.
Don't try to match clock-for-clock. Understand what frequencies your peripherals need and configure the target's clock tree to deliver them.
// STM32: complex clock config generated by CubeMX
void SystemClock_Config(void) {
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
// ... 40 lines of clock configuration
}
// nRF52: much simpler
// Most clock setup happens automatically via Zephyr's devicetree
// or a few register writes
void clock_init(void) {
NRF_CLOCK->TASKS_HFCLKSTART = 1;
while (!NRF_CLOCK->EVENTS_HFCLKSTARTED);
}
Phase 3: Green Peripherals First (Days 3-5)
Start with the easy wins. GPIO, UART, basic timers.
GPIO migration between any two Cortex-M MCUs is mostly mechanical:
// STM32 → nRF52 GPIO mapping
// HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET)
// becomes:
// nrf_gpio_pin_set(NRF_GPIO_PIN_MAP(0, 5))
// Or if you have an abstraction layer:
// gpio_write((gpio_pin_t){0, 5}, 1) // same call, different backend
Get LEDs blinking and UART printing. This validates your build system, startup code, and basic peripheral access. Everything else builds on this.
Phase 4: Yellow Peripherals (Days 5-15)
SPI, I2C, and timers usually have behavioral differences that require understanding both MCU's models.
SPI example: STM32 → nRF52 (Zephyr)
// STM32 HAL SPI
HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, len, HAL_MAX_DELAY);
// Zephyr SPI (used by nRF Connect SDK)
struct spi_buf tx = {.buf = tx_buf, .len = len};
struct spi_buf rx = {.buf = rx_buf, .len = len};
struct spi_buf_set tx_set = {.buffers = &tx, .count = 1};
struct spi_buf_set rx_set = {.buffers = &rx, .count = 1};
spi_transceive(spi_dev, &spi_cfg, &tx_set, &rx_set);
The API model is completely different (handle+callback vs device+descriptor), but the functionality is the same. Don't try to write a compatibility wrapper that makes Zephyr's API look like STM32's. Learn the target's API and use it idiomatically.
Phase 5: Red Peripherals (Days 15-22)
DMA, complex timers, and power management. These are where the real work is.
DMA is the biggest headache. Every MCU family implements DMA differently:
- STM32: DMA streams/channels, each assignable to specific peripherals. Flexible but complex.
- nRF52: EasyDMA, tightly integrated with each peripheral. Less flexible but simpler.
- ESP32: GDMA with channel allocation. Different yet again.
There is no mechanical translation. You need to understand what the DMA was doing (circular buffer? ping-pong? linked list?) and redesign it for the target's DMA model.
// STM32: DMA circular buffer for ADC
hdma_adc.Init.Mode = DMA_CIRCULAR;
hdma_adc.Init.MemInc = DMA_MINC_ENABLE;
HAL_DMA_Start(&hdma_adc, (uint32_t)&ADC1->DR, (uint32_t)adc_buf, ADC_BUF_LEN);
// nRF52: SAADC with EasyDMA — completely different model
nrfx_saadc_buffer_set(adc_buf, ADC_BUF_LEN);
// EasyDMA handles the transfer internally — no separate DMA config
Phase 6: Integration Testing (Days 22-25)
Once all peripherals are ported, test the system as a whole:
- Peripheral smoke test: Each peripheral works in isolation
- Communication test: SPI/I2C devices respond correctly
- Timing test: Real-time operations meet deadlines
- Power test: Sleep modes work, current consumption is acceptable
- Stress test: Run for 48 hours, check for memory leaks, watchdog resets
Phase 7: Edge Cases (Days 25-28)
These are what catches teams 3 weeks into testing:
- Interrupt priority differences: STM32 has 16 priority levels, nRF52 has 4 (in some configs). If your original code relied on fine-grained priorities, you need to restructure.
- Byte ordering in peripheral registers: Usually the same (little-endian ARM), but DMA scatter-gather can expose ordering issues.
- Startup timing: Some peripherals need time to stabilize after power-on. Your original code might have had implicit delays from slow clock startup that the new MCU doesn't have.
- Brownout behavior: Different MCUs handle power dips differently. Test power-off-power-on sequences.
The Estimation Formula
From my experience, here's a rough formula:
Estimated weeks = (LOC / 10000) × peripheral_complexity × abstraction_factor
where:
peripheral_complexity = 1.0 (GPIO/UART only)
= 1.5 (+ SPI/I2C)
= 2.5 (+ DMA/complex timers)
= 4.0 (+ USB/Ethernet/RF)
abstraction_factor = 0.3 (full HAL abstraction in place)
= 1.0 (no abstraction)
= 1.5 (spaghetti code, vendor types everywhere)
Example: 40K LOC, SPI+DMA, no abstraction = (40/10) × 2.5 × 1.0 = 10 weeks.
Add 30% for testing and edge cases. So ~13 weeks. If your manager says "4 weeks," show them this formula.
Tools That Help
- grep / ripgrep: Fast dependency auditing
- ctags / cscope: Navigate call chains to find hidden vendor dependencies
-
Compiler warnings: Build for the new target with
-Wall -Werrorearly — the compiler will find most API mismatches -
Git branches: Keep the original working on
main, do the port on a branch. You'll need to compare. - CI with multiple targets: Build for both old and new target on every commit during the migration
What I'm Building
I'm working on a tool called PortPilot that automates the dependency audit and mapping phases (Steps 1-2 above). It scans your firmware, classifies every vendor HAL call, and generates a migration report showing what maps directly, what needs review, and what needs redesign.
It won't do the port for you — the red peripherals still need engineering judgment. But it cuts the audit from a week to an hour and makes sure you don't miss anything.
Pranav Jain is an embedded systems engineer specializing in the middleware layer between hardware and application software. Find him on GitHub.
Top comments (0)