Between 2021 and 2024, I watched firmware teams go through the five stages of grief. Their chip was on a 52-week lead time. The drop-in replacement didn’t exist. And their entire codebase was welded to one vendor’s HAL.

Now that lead times have mostly normalized, it’s tempting to forget. Don’t. The shortage exposed a structural weakness in how most teams write firmware, and the fix isn’t “keep more inventory.” It’s in how you architect your code.

The Pattern I Saw Repeatedly

Company ships a product on STM32F4. Works great. Firmware is 30-50K lines of C, tightly coupled to STM32 HAL. Then:

  1. STM32F4 goes to 40-week lead time
  2. Purchasing finds an nRF52840 that’s available NOW
  3. Engineering estimates the port at “2-3 weeks”
  4. Actual port takes 8-12 weeks
  5. Product launch slips a quarter

I saw this pattern at three different companies between 2022 and 2023. The details varied, but the story was always the same: the firmware was married to the silicon.

Why “Just Use Zephyr” Isn’t the Full Answer

The reflexive response is “use Zephyr RTOS” or “use an RTOS with a HAL.” And yes, Zephyr’s device tree model gives you portability. But:

Most firmware doesn’t run an RTOS. Bare-metal is still the majority of embedded projects, especially at the lower end. If you’re on a Cortex-M0 with 32KB flash, Zephyr isn’t an option.

RTOS HALs have their own lock-in. You’re not locked to STM32 HAL anymore — you’re locked to Zephyr’s API. If Zephyr’s SPI driver doesn’t support your use case (say, a specific DMA mode), you’re back to writing vendor-specific code anyway.

The abstraction has to be yours. The only HAL you fully control is one you wrote. It doesn’t need to be complex. It needs to be intentional.

What Portable Firmware Actually Looks Like

Here’s what the teams that survived the shortage had in common:

1. A Thin Peripheral Interface

// hal/gpio.h — YOUR abstraction, not the vendor's
typedef struct {
    uint8_t port;
    uint8_t pin;
} gpio_pin_t;

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT_PP,
    GPIO_MODE_OUTPUT_OD,
    GPIO_MODE_AF
} gpio_mode_t;

int gpio_init(gpio_pin_t pin, gpio_mode_t mode);
int gpio_write(gpio_pin_t pin, int value);
int gpio_read(gpio_pin_t pin);
int gpio_toggle(gpio_pin_t pin);

This is maybe 50 lines of header. The implementation file is different per target:

// hal/stm32/gpio.c
#include "hal/gpio.h"
#include "stm32f4xx_hal.h"

int gpio_init(gpio_pin_t pin, gpio_mode_t mode) {
    GPIO_InitTypeDef init = {0};
    init.Pin = (1U << pin.pin);
    init.Mode = (mode == GPIO_MODE_OUTPUT_PP) ? GPIO_MODE_OUTPUT_PP : GPIO_MODE_INPUT;
    init.Pull = GPIO_NOPULL;
    init.Speed = GPIO_SPEED_FREQ_LOW;

    GPIO_TypeDef *port = /* port lookup */;
    HAL_GPIO_Init(port, &init);
    return 0;
}
// hal/nrf52/gpio.c
#include "hal/gpio.h"
#include "nrf_gpio.h"

int gpio_init(gpio_pin_t pin, gpio_mode_t mode) {
    uint32_t nrf_pin = NRF_GPIO_PIN_MAP(pin.port, pin.pin);
    if (mode == GPIO_MODE_OUTPUT_PP) {
        nrf_gpio_cfg_output(nrf_pin);
    } else {
        nrf_gpio_cfg_input(nrf_pin, NRF_GPIO_PIN_NOPULL);
    }
    return 0;
}

When the shortage hit, the team with this structure swapped backends in 3 days. The team without it spent 6 weeks.

2. Build System That Supports Multiple Targets

# CMakeLists.txt
option(TARGET_MCU "Target MCU family" "stm32f4")

if(TARGET_MCU STREQUAL "stm32f4")
    add_subdirectory(hal/stm32)
    target_compile_definitions(app PRIVATE TARGET_STM32F4)
elseif(TARGET_MCU STREQUAL "nrf52")
    add_subdirectory(hal/nrf52)
    target_compile_definitions(app PRIVATE TARGET_NRF52)
endif()

If your build system can’t switch targets with a single flag, your abstraction isn’t real.

3. No Vendor Types in Application Code

This is the rule that matters most. If your application code contains GPIO_TypeDef, nrf_gpio_pin_map, or esp_err_t, you have a portability problem. Vendor types belong in the backend implementation files — nowhere else.

// BAD — vendor type leaked into application
void sensor_init(void) {
    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = GPIO_PIN_5;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    HAL_GPIO_Init(GPIOA, &gpio);
}

// GOOD — application uses your types only
void sensor_init(void) {
    gpio_pin_t cs_pin = {.port = 0, .pin = 5};
    gpio_init(cs_pin, GPIO_MODE_OUTPUT_PP);
}

4. Test Without Hardware

The teams that ported fastest had tests that ran on their host machine (x86), not on the target. They mocked the HAL interface:

// test/mock_gpio.c
static int gpio_states[256] = {0};

int gpio_write(gpio_pin_t pin, int value) {
    gpio_states[pin.pin] = value;
    return 0;
}

int gpio_read(gpio_pin_t pin) {
    return gpio_states[pin.pin];
}

When they switched MCUs, the application tests still passed. They only needed new tests for the backend implementation — which is a much smaller surface area.

The Cost of NOT Doing This

Let me put numbers on it. From three migrations I was involved with:

Project LOC Abstraction? Port Time Bugs Found Post-Port
A 45K None 11 weeks 23
B 38K Partial (GPIO/UART only) 5 weeks 8
C 52K Full (all peripherals) 2 weeks 2

Project C had more code but ported 5x faster. The abstraction cost maybe 2 weeks to build originally. It paid for itself on the first port.

The “But We’ll Never Switch” Fallacy

I’ve heard this from every team that eventually had to switch. “We’re committed to STM32.” “Nordic is our long-term partner.” “We’ll never need to port.”

Until:

  • Your chip goes EOL
  • A new product variant needs a different feature set
  • Your customer requires a specific silicon vendor
  • A cheaper chip cuts your BOM by $2 (which, at volume, is millions)
  • Another shortage hits

The question isn’t whether you’ll port. It’s when.

What I’d Do on a New Project Today

If I were starting a bare-metal project tomorrow:

  1. Day 1: Define the peripheral interface (GPIO, SPI, I2C, UART, Timer — maybe 200 lines of headers total)
  2. Day 2-3: Implement backend for the primary target
  3. Day 3: Set up CMake with target selection
  4. Day 3: Write mock backends for host testing
  5. Ongoing: Never let vendor types leak past the HAL boundary

Total overhead: 2-3 days. Insurance against a multi-week port later.

If you’re using an RTOS, the RTOS likely provides the abstraction. But verify: can you actually switch targets with just a config change? If not, you have hidden vendor dependencies.

The Broader Lesson

The chip shortage wasn’t a freak event. It was a stress test. And it revealed that most firmware architectures are optimized for the happy path (one chip, forever) rather than the realistic path (you’ll switch eventually).

The fix is simple. Not easy — it requires discipline to keep vendor code out of your application layer. But simple. A thin abstraction, a switchable build system, and the willingness to spend 2-3 days on infrastructure at the start of a project.

The teams that did this barely noticed the shortage. Everyone else had a very expensive year.


Pranav Jain is an embedded systems engineer specializing in middleware and abstraction layers between hardware and application software. He’s building PortPilot, a tool that automates MCU migration analysis. Find him on GitHub.