[{"content":"I\u0026rsquo;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\u0026rsquo;ve watched teams burn weeks on firmware ports that should have taken days, and I\u0026rsquo;ve done enough post-mortems to see the same mistakes repeat across companies.\nThese aren\u0026rsquo;t theoretical. Every mistake below comes from real porting efforts I\u0026rsquo;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.\nIf you\u0026rsquo;re planning a firmware port (or trying to build firmware that won\u0026rsquo;t need a painful port later), this is the list I wish someone had given me five years ago.\nMistake 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.\nThe mistake:\n// sensor_driver.c — STM32 version #include \u0026#34;stm32f4xx_hal.h\u0026#34; 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(\u0026amp;hspi1, \u0026amp;cmd, 1, 100); HAL_SPI_Receive(\u0026amp;hspi1, buf, 2, 100); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // CS high *temp_raw = (buf[0] \u0026lt;\u0026lt; 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\u0026rsquo;re looking at a week of tedious, error-prone work.\nThe fix: Wrap peripheral access behind a thin abstraction. The application code calls your API, not the vendor\u0026rsquo;s.\n// 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, \u0026amp;cmd, NULL, 1); hal_spi_transfer(spi, NULL, buf, 2); hal_spi_cs_deassert(spi); *temp_raw = (buf[0] \u0026lt;\u0026lt; 8) | buf[1]; return 0; } Now porting means writing one new hal_spi_nrf52.c backend. The sensor driver doesn\u0026rsquo;t change at all. This is the single highest-ROI investment in any firmware architecture.\nMistake 2: Hardcoding Interrupt Priorities STM32 uses a 4-bit priority field (0-15, where 0 is highest). nRF52 uses 3 bits (0-7). ESP32\u0026rsquo;s interrupt system is completely different — it uses levels 1-6 with dedicated high-priority interrupts that can only run from IRAM.\nThe mistake:\n// 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\u0026rsquo;t exist — the maximum is 7, but the SoftDevice (BLE stack) reserves priorities 0, 1, and 4. Your \u0026ldquo;high priority\u0026rdquo; timer at 1 now collides with the SoftDevice and causes random BLE disconnections that take a week to debug.\nThe fix: Define priority levels semantically and map them per platform.\n// 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 \u0026ldquo;it works on STM32 but randomly crashes on nRF52\u0026rdquo; bugs.\nMistake 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.\nThe mistake:\n// Works on STM32 — a function pointer stored in flash and called normally typedef void (*callback_t)(void); const callback_t isr_table[] __attribute__((section(\u0026#34;.rodata\u0026#34;))) = { 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:\n// 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\u0026rsquo;ve seen boards that worked perfectly until a SoftDevice update shifted the memory map and the application overwrote SoftDevice data.\nBuild a memory map document for each target. Not in someone\u0026rsquo;s head — in a file checked into the repo. Include reserved regions, stack sizes, and heap configuration.\nMistake 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.\nThe mistake:\n// 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\u0026#39;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\u0026rsquo;ve seen it cause framing errors with picky GPS modules that barely tolerate 0.5% error.\nThe fix: Specify peripheral speeds as requirements (minimum and maximum), not as exact register values.\n// 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-\u0026gt;clock.freq_target_hz); if (actual_freq \u0026lt; cfg-\u0026gt;clock.freq_min_hz || actual_freq \u0026gt; cfg-\u0026gt;clock.freq_max_hz) { LOG_WARN(\u0026#34;SPI%d: requested %u Hz, got %u Hz (out of range)\u0026#34;, cfg-\u0026gt;instance, cfg-\u0026gt;clock.freq_target_hz, actual_freq); return -EINVAL; } LOG_INFO(\u0026#34;SPI%d: configured at %u Hz (target: %u Hz)\u0026#34;, cfg-\u0026gt;instance, actual_freq, cfg-\u0026gt;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\u0026rsquo;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.\nThe mistake:\n// 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:\nconfigMINIMAL_STACK_SIZE of 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_SIZE is ignored — ESP32 uses its own multi-region heap allocator. configUSE_TICKLESS_IDLE interacts badly with Wi-Fi power management on ESP32. On nRF52 with SoftDevice, you can\u0026rsquo;t use FreeRTOS\u0026rsquo;s standard vPortSVCHandler and xPortPendSVHandler — the SoftDevice owns those interrupt vectors. You need the nRF52-specific FreeRTOS port that routes through the SoftDevice.\nThe 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.\n// 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.\nThe mistake:\n// 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\u0026#39;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.\n// 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 \u0026gt; 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(\u0026#34;No DMA available for SPI%d, falling back to polling\u0026#34;, 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\u0026rsquo;s transparent. Your abstraction should hide this difference.\nMistake 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.\nThe mistake:\n// 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\u0026rsquo;s a nightmare.\nThe fix: One file, one table, fully qualified.\n// 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 \u0026#34;No board defined — add your pin map\u0026#34; #endif When you port to a new board, you add one #elif block. Nothing else in the codebase mentions pin numbers.\nMistake 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.\nWhat gets missed:\nPeripheral error recovery. STM32\u0026rsquo;s HAL sets error flags on hspi.ErrorCode — your error handler clears them and retries. nRF52\u0026rsquo;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\u0026rsquo;s 32 kHz LSI oscillator (which has +/- 10% accuracy) may trip on nRF52\u0026rsquo;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 \u0026ldquo;wake from sleep\u0026rdquo; 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.\n// 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\u0026rsquo;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.\nThe fix: Port in layers, bottom-up, validating at each step.\nWeek 1: Board bring-up. Get the chip running — clock config, a blinking LED, and printf over UART. Nothing else. If this doesn\u0026rsquo;t work, you can\u0026rsquo;t debug anything above it.\nWeek 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.\nWeek 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.\nWeek 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\u0026rsquo;t patch the application.\nCommit at each step. If step 3 breaks something, you can diff against step 2 and see exactly what changed.\nMistake 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\u0026rsquo;s broken — header files moved, function signatures changed, a new module was added without a platform implementation.\nThe mistake:\n# \u0026#34;Just build the one you\u0026#39;re working on\u0026#34; make TARGET=stm32f4 # Nobody runs this for months: make TARGET=nrf52840 # It\u0026#39;s been broken since March The fix: CI that builds every target on every commit.\n# .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\u0026rsquo;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.\nThe 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.\nThe time to make firmware portable is before you need to port it. The second best time is when you\u0026rsquo;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.\nIf you\u0026rsquo;re facing a port right now and the codebase has no abstraction layer, resist the temptation to \u0026ldquo;just get it working\u0026rdquo; on the new target by copy-pasting and patching. You\u0026rsquo;ll end up maintaining two divergent codebases. Take the time to extract an abstraction layer during the port — it\u0026rsquo;s the last time you\u0026rsquo;ll need to do this work.\nFurther Reading Zephyr\u0026rsquo;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\u0026rsquo;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.\n","permalink":"https://pranavhj.github.io/blog/posts/firmware-porting-mistakes/","summary":"\u003cp\u003eI\u0026rsquo;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\u0026rsquo;ve watched teams burn weeks on firmware ports that should have taken days, and I\u0026rsquo;ve done enough post-mortems to see the same mistakes repeat across companies.\u003c/p\u003e\n\u003cp\u003eThese aren\u0026rsquo;t theoretical. Every mistake below comes from real porting efforts I\u0026rsquo;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.\u003c/p\u003e","title":"10 Mistakes Companies Make When Porting Firmware Between MCU Families"},{"content":"I write C for microcontrollers. My code talks to SPI peripherals, configures DMA channels, and runs in environments where a buffer overflow doesn\u0026rsquo;t crash a browser — it crashes a piece of industrial equipment. AI code assistants were not built for this.\nBut I use them every day. Here\u0026rsquo;s what actually works, what\u0026rsquo;s dangerous, and where I think this is heading.\nWhat I Tested I\u0026rsquo;ve been using three AI assistants in my embedded workflow for the past year:\nGitHub Copilot — inline completions in VS Code Claude — long-form code generation, architecture discussions, code review ChatGPT — quick questions, datasheet interpretation I used them on real firmware projects: STM32F4, nRF52840, ESP32-S3. Bare-metal and Zephyr RTOS. 20-60K LOC codebases. Here\u0026rsquo;s what happened.\nWhere AI Assistants Actually Help 1. Boilerplate Peripheral Initialization This is the clearest win. Peripheral init code is formulaic — read the reference manual, fill in the struct fields, enable the clocks, configure the pins. AI assistants are very good at this.\nExample: \u0026ldquo;Write STM32 HAL SPI1 init for PA5/PA6/PA7, Mode 0, 1 MHz, 8-bit\u0026rdquo;\nClaude gives me:\nvoid SPI1_Init(void) { __HAL_RCC_SPI1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; gpio.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOA, \u0026amp;gpio); hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_64; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(\u0026amp;hspi1); } This is correct, took 5 seconds, and would have taken me 10 minutes of cross-referencing the reference manual. The prescaler calculation (64 MHz / 64 = 1 MHz) is right. The AF mapping (AF5 for SPI1 on PA5-7) is right.\nTime saved: 10 minutes per peripheral init. Maybe 30-40 minutes per project.\n2. Protocol Decoders and Data Parsers Parsing sensor data, decoding protocol frames, bit manipulation — AI assistants handle this well because the logic is well-defined and the edge cases are enumerable.\nExample: \u0026ldquo;Parse this BME280 temperature reading from raw registers\u0026rdquo;\nint32_t bme280_compensate_temperature(int32_t adc_T, struct bme280_calib *cal) { int32_t var1 = ((((adc_T \u0026gt;\u0026gt; 3) - ((int32_t)cal-\u0026gt;dig_T1 \u0026lt;\u0026lt; 1))) * ((int32_t)cal-\u0026gt;dig_T2)) \u0026gt;\u0026gt; 11; int32_t var2 = (((((adc_T \u0026gt;\u0026gt; 4) - ((int32_t)cal-\u0026gt;dig_T1)) * ((adc_T \u0026gt;\u0026gt; 4) - ((int32_t)cal-\u0026gt;dig_T1))) \u0026gt;\u0026gt; 12) * ((int32_t)cal-\u0026gt;dig_T3)) \u0026gt;\u0026gt; 14; return (var1 + var2); } This is directly from the BME280 datasheet compensation formula. AI gets it right because it\u0026rsquo;s seen this exact code in thousands of projects.\n3. Test Scaffolding AI is good at generating test cases for embedded code — especially when you give it the function signature and describe the edge cases.\n\u0026ldquo;Write Unity test cases for this CRC-16 function, including: empty input, single byte, known test vector, maximum length buffer.\u0026rdquo;\nThis saves 15-20 minutes per function and produces tests I might not have thought of.\n4. Build System Configuration CMake for cross-compilation is arcane. AI assistants know the incantations:\n\u0026ldquo;Generate a CMake toolchain file for ARM GCC targeting Cortex-M4 with FPU\u0026rdquo;\nThis consistently produces working output. CMake is well-documented online and AI has seen thousands of examples.\n5. Documentation and Comments \u0026ldquo;Add doxygen comments to this driver interface header\u0026rdquo; — AI does this well. It reads the parameter names, infers the purpose, and produces reasonable documentation.\nWhere AI Assistants Are Dangerous 1. Register-Level Code for Uncommon Peripherals Ask for LTDC (LCD controller) configuration on STM32F4 and you\u0026rsquo;ll get plausible-looking code that doesn\u0026rsquo;t work. The AI has seen fewer examples of LTDC than SPI, so it generates something that looks right but has wrong timing parameters or missing register fields.\nRule: If the peripheral has fewer than 1000 open-source code examples on GitHub, don\u0026rsquo;t trust AI-generated register-level code without verifying against the reference manual.\n2. DMA Configuration This is where I\u0026rsquo;ve seen the most AI-generated bugs. DMA involves channel assignment, priority, FIFO thresholds, memory alignment, and peripheral-specific constraints. AI gets the structure right but misses constraints like \u0026ldquo;DMA2 Stream 0 Channel 3 is the only valid assignment for SPI1 RX on this specific STM32 variant.\u0026rdquo;\n// AI-generated DMA config — looks correct but... hdma.Init.Channel = DMA_CHANNEL_3; // Wrong channel for this peripheral hdma.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma.Init.PeriphInc = DMA_PINC_DISABLE; hdma.Init.MemInc = DMA_MINC_ENABLE; hdma.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL; // Bad choice for small transfers Rule: Always verify DMA channel assignments against the DMA request mapping table in the reference manual. AI can\u0026rsquo;t reliably do this.\n3. Interrupt Priority and RTOS Integration AI assistants don\u0026rsquo;t understand the runtime implications of interrupt priorities. They\u0026rsquo;ll generate code that assigns ISR priorities without considering what other interrupts are active, whether the RTOS uses BASEPRI masking, or what configMAX_SYSCALL_INTERRUPT_PRIORITY is set to in FreeRTOS.\n4. Timing-Critical Code Anything that depends on cycle-accurate timing — bit-banged protocols, pulse measurement, ISR latency — is a bad fit for AI assistance. The AI doesn\u0026rsquo;t know your clock speed, pipeline behavior, or compiler optimization settings.\n5. Security-Sensitive Code Cryptographic operations, secure boot, key storage. Don\u0026rsquo;t use AI for this. The surface area for subtle bugs is too large, and the consequences of getting it wrong are too severe.\nHow I Actually Use AI in My Workflow Morning: Open the project, use Copilot for autocomplete on routine code. It fills in struct initializations, for-loop bodies, and switch-case arms.\nArchitecture questions: \u0026ldquo;I need SPI communication between two MCUs with flow control. What patterns work?\u0026rdquo; — I ask Claude, get 3 approaches with tradeoffs, then implement the one that fits.\nDebugging: \u0026ldquo;This SPI transfer returns HAL_TIMEOUT. The clock is configured for 1 MHz, CPOL=0, CPHA=0. What should I check?\u0026rdquo; — AI gives a reasonable debugging checklist. Not always right, but a good starting point.\nCode review: Paste a function into Claude and ask \u0026ldquo;What bugs or edge cases do you see?\u0026rdquo; — catches things like integer overflow in ADC scaling, missing null checks on buffer pointers, off-by-one in circular buffer indices.\nWhat I never do: Copy-paste AI-generated code into production without reading every line. Especially register-level code. Especially DMA.\nThe Embedded-Specific Gap AI assistants are trained on web and application code. The embedded domain has unique challenges they handle poorly:\nHardware datasheets are not in their training data (or poorly represented) Real-time constraints aren\u0026rsquo;t something they can reason about Memory-constrained environments mean patterns that work in application code (dynamic allocation, string formatting) are wrong in embedded Vendor-specific errata — every MCU has hardware bugs documented in errata sheets. AI doesn\u0026rsquo;t know about them. The ideal embedded AI assistant would:\nHave the MCU\u0026rsquo;s reference manual in context Know the specific chip variant and its errata Understand RTOS-specific constraints (stack sizes, priority inversions) Verify DMA channel assignments against the mapping table Flag timing assumptions that depend on clock configuration We\u0026rsquo;re not there yet. But we\u0026rsquo;re closer than we were a year ago.\nBottom Line AI code assistants save me 30-60 minutes per day on embedded projects. Mostly from boilerplate generation, test scaffolding, and build system configuration.\nThey produce dangerous output for DMA, interrupt priorities, and uncommon peripherals. The cost of a subtle register-level bug in production embedded code is much higher than in a web app — hours of debugging with an oscilloscope and logic analyzer, or worse, a field failure.\nUse them as a first draft generator, not a finished code source. Every line gets reviewed against the reference manual. That discipline turns AI from a liability into a genuine productivity boost.\nPranav Jain writes middleware and abstraction layers for embedded systems. Find him on GitHub.\n","permalink":"https://pranavhj.github.io/blog/posts/ai-tools-embedded-engineers/","summary":"\u003cp\u003eI write C for microcontrollers. My code talks to SPI peripherals, configures DMA channels, and runs in environments where a buffer overflow doesn\u0026rsquo;t crash a browser — it crashes a piece of industrial equipment. AI code assistants were not built for this.\u003c/p\u003e\n\u003cp\u003eBut I use them every day. Here\u0026rsquo;s what actually works, what\u0026rsquo;s dangerous, and where I think this is heading.\u003c/p\u003e\n\u003ch2 id=\"what-i-tested\"\u003eWhat I Tested\u003c/h2\u003e\n\u003cp\u003eI\u0026rsquo;ve been using three AI assistants in my embedded workflow for the past year:\u003c/p\u003e","title":"AI Code Assistants for Embedded Engineers: What Works, What Doesn't"},{"content":"You\u0026rsquo;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\u0026rsquo;t have it.\nWhatever the reason, you\u0026rsquo;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\u0026rsquo;t make the port painless, but it\u0026rsquo;ll keep you from wasting time on the wrong things.\nBefore You Touch Any Code Step 0: Understand the Target Before you change a single line, answer these questions about the target MCU:\nQuestion Why It Matters What\u0026rsquo;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\u0026rsquo;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\u0026rsquo;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\u0026rsquo;s clock tree and peripheral overview sections. It saves weeks later.\nStep 1: Dependency Audit This is the most important step. Catalog every vendor-specific dependency in your codebase.\n# Quick audit for STM32 HAL dependencies grep -rn \u0026#34;HAL_\\|LL_\\|__HAL_\\|stm32\u0026#34; src/ --include=\u0026#34;*.c\u0026#34; --include=\u0026#34;*.h\u0026#34; | \\ grep -v \u0026#34;// \u0026#34; | \\ sort -t: -k1,1 | \\ uniq -c | sort -rn \u0026gt; hal_dependencies.txt # Count by peripheral type grep -oP \u0026#34;HAL_(GPIO|SPI|I2C|UART|TIM|DMA|ADC|DAC|RCC|PWR|FLASH|RTC|IWDG|WWDG|CAN|USB|ETH)\u0026#34; \\ hal_dependencies.txt | sort | uniq -c | sort -rn Typical output:\n187 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\u0026rsquo;re complex, but because there are the most call sites.\nStep 2: Classify Each Dependency Not all HAL calls are equal. Classify them:\nDirect 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.\nBehavioral differences (yellow): The target MCU can do the same thing, but the API works differently. STM32\u0026rsquo;s SPI uses handles and callbacks; nRF Connect SDK uses Zephyr\u0026rsquo;s SPI API with transaction descriptors. You need to understand both models.\nNo equivalent (red): The target MCU doesn\u0026rsquo;t have the feature, or implements it fundamentally differently. STM32\u0026rsquo;s flexible DMA linked-list mode vs nRF52\u0026rsquo;s EasyDMA which has a different set of constraints. These need redesign.\nMigration 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.\n# CMakeLists.txt — add target selection set(TARGET_MCU \u0026#34;stm32f4\u0026#34; CACHE STRING \u0026#34;Target MCU family\u0026#34;) set_property(CACHE TARGET_MCU PROPERTY STRINGS stm32f4 nrf52840 esp32s3) if(TARGET_MCU STREQUAL \u0026#34;nrf52840\u0026#34;) set(CMAKE_TOOLCHAIN_FILE ${NRF_SDK_PATH}/toolchain.cmake) add_subdirectory(hal/nrf52) elseif(TARGET_MCU STREQUAL \u0026#34;stm32f4\u0026#34;) add_subdirectory(hal/stm32) endif() # Application code is the SAME regardless of target add_subdirectory(application) target_link_libraries(application PRIVATE hal) If you\u0026rsquo;re migrating to a Zephyr-based SDK (nRF Connect), you\u0026rsquo;ll need to restructure into a Zephyr application. This is a bigger lift:\nmy_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\u0026#39;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.\nSTM32: CubeMX generates SystemClock_Config(). Clock tree has HSE/HSI → PLL → SYSCLK → AHB/APB prescalers.\nnRF52: Simpler clock model. HFCLK (64 MHz, from crystal or RC) and LFCLK (32.768 kHz). Less configurable but less error-prone.\nESP32: Dual-core, much more complex. PLL, CPU frequency, APB frequency, RTC clocks.\nDon\u0026rsquo;t try to match clock-for-clock. Understand what frequencies your peripherals need and configure the target\u0026rsquo;s clock tree to deliver them.\n// 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\u0026#39;s devicetree // or a few register writes void clock_init(void) { NRF_CLOCK-\u0026gt;TASKS_HFCLKSTART = 1; while (!NRF_CLOCK-\u0026gt;EVENTS_HFCLKSTARTED); } Phase 3: Green Peripherals First (Days 3-5) Start with the easy wins. GPIO, UART, basic timers.\nGPIO migration between any two Cortex-M MCUs is mostly mechanical:\n// 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.\nPhase 4: Yellow Peripherals (Days 5-15) SPI, I2C, and timers usually have behavioral differences that require understanding both MCU\u0026rsquo;s models.\nSPI example: STM32 → nRF52 (Zephyr)\n// STM32 HAL SPI HAL_SPI_TransmitReceive(\u0026amp;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 = \u0026amp;tx, .count = 1}; struct spi_buf_set rx_set = {.buffers = \u0026amp;rx, .count = 1}; spi_transceive(spi_dev, \u0026amp;spi_cfg, \u0026amp;tx_set, \u0026amp;rx_set); The API model is completely different (handle+callback vs device+descriptor), but the functionality is the same. Don\u0026rsquo;t try to write a compatibility wrapper that makes Zephyr\u0026rsquo;s API look like STM32\u0026rsquo;s. Learn the target\u0026rsquo;s API and use it idiomatically.\nPhase 5: Red Peripherals (Days 15-22) DMA, complex timers, and power management. These are where the real work is.\nDMA is the biggest headache. Every MCU family implements DMA differently:\nSTM32: 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\u0026rsquo;s DMA model.\n// STM32: DMA circular buffer for ADC hdma_adc.Init.Mode = DMA_CIRCULAR; hdma_adc.Init.MemInc = DMA_MINC_ENABLE; HAL_DMA_Start(\u0026amp;hdma_adc, (uint32_t)\u0026amp;ADC1-\u0026gt;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:\nPeripheral 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:\nInterrupt 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\u0026rsquo;t have. Brownout behavior: Different MCUs handle power dips differently. Test power-off-power-on sequences. The Estimation Formula From my experience, here\u0026rsquo;s a rough formula:\nEstimated 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.\nAdd 30% for testing and edge cases. So ~13 weeks. If your manager says \u0026ldquo;4 weeks,\u0026rdquo; show them this formula.\nTools 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 -Werror early — the compiler will find most API mismatches Git branches: Keep the original working on main, do the port on a branch. You\u0026rsquo;ll need to compare. CI with multiple targets: Build for both old and new target on every commit during the migration What I\u0026rsquo;m Building I\u0026rsquo;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.\nIt won\u0026rsquo;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\u0026rsquo;t miss anything.\nPranav Jain is an embedded systems engineer specializing in the middleware layer between hardware and application software. Find him on GitHub.\n","permalink":"https://pranavhj.github.io/blog/posts/cross-mcu-migration-guide/","summary":"\u003cp\u003eYou\u0026rsquo;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\u0026rsquo;t have it.\u003c/p\u003e\n\u003cp\u003eWhatever the reason, you\u0026rsquo;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\u0026rsquo;t make the port painless, but it\u0026rsquo;ll keep you from wasting time on the wrong things.\u003c/p\u003e","title":"Cross-MCU Migration: A Practical Guide"},{"content":"Every embedded engineer has lived through this moment: your company\u0026rsquo;s chip supplier emails you that the MCU you designed around is end-of-life, or lead times just jumped to 52 weeks. You stare at 40,000 lines of firmware that call HAL_SPI_Transmit() in 200 places, and you realize your \u0026ldquo;abstraction\u0026rdquo; was just ST\u0026rsquo;s abstraction. You\u0026rsquo;re locked in.\nI\u0026rsquo;ve spent years writing the mid-layer software that sits between customer applications and firmware — the abstraction that\u0026rsquo;s supposed to make hardware swappable. I\u0026rsquo;ve seen what works, what doesn\u0026rsquo;t, and what looks elegant in a conference talk but falls apart when you actually need to port to a different silicon vendor. This post is the guide I wish I\u0026rsquo;d had five years ago.\nThe Problem Statement A hardware abstraction layer has one job: let the code above it not care which chip is below it. That sounds simple. It isn\u0026rsquo;t. The tension is between three competing goals:\nPortability — the whole point. Write once, run on STM32, nRF, ESP32, RP2040. Performance — embedded systems have hard timing constraints. Every layer of indirection costs cycles. Expressiveness — different MCUs have different capabilities. Your abstraction either exposes them (and leaks hardware details) or hides them (and loses functionality). Pick two. The art is in knowing which two matter for your project.\nThe Landscape: How Everyone Else Does It Before building something custom, let\u0026rsquo;s look at what exists and where each approach breaks down.\nCMSIS: The Lowest Common Denominator ARM\u0026rsquo;s Cortex Microcontroller Software Interface Standard gives you register-level access with consistent naming. It\u0026rsquo;s not really an abstraction — it\u0026rsquo;s a naming convention for registers.\n// CMSIS-style GPIO toggle on STM32 GPIOA-\u0026gt;ODR ^= GPIO_PIN_5; // Same idea on an LPC LPC_GPIO0-\u0026gt;FIOPIN ^= (1 \u0026lt;\u0026lt; 22); CMSIS standardizes the Cortex-M core peripherals (NVIC, SysTick, MPU) beautifully. But GPIO? SPI? UART? Those are vendor peripherals, and CMSIS doesn\u0026rsquo;t touch them. Every vendor\u0026rsquo;s register map is different, and CMSIS doesn\u0026rsquo;t help you bridge that gap.\nVerdict: CMSIS is a foundation, not a HAL. You still need something on top.\nSTM32 HAL: The Golden Handcuffs ST\u0026rsquo;s HAL is the most widely used abstraction in the embedded world, mostly because STM32 is the most widely used MCU family. It\u0026rsquo;s comprehensive, well-documented, and it will absolutely destroy your portability.\n// STM32 HAL SPI transmit SPI_HandleTypeDef hspi1; uint8_t tx_buf[] = {0xAA, 0xBB, 0xCC}; HAL_SPI_Transmit(\u0026amp;hspi1, tx_buf, sizeof(tx_buf), HAL_MAX_DELAY); The problem isn\u0026rsquo;t that HAL_SPI_Transmit is a bad API. It\u0026rsquo;s actually pretty good. The problem is that SPI_HandleTypeDef contains 15 fields that are deeply STM32-specific — the prescaler values map to ST\u0026rsquo;s clock tree, the alternate function pin mappings are ST-specific, and the DMA channel configuration assumes ST\u0026rsquo;s DMA controller topology.\nWhen you call HAL_SPI_Init(), you\u0026rsquo;re committing to ST\u0026rsquo;s entire initialization model. Every file that touches that handle is now ST-locked, even if it never reads a vendor-specific field.\nI\u0026rsquo;ve seen codebases where the \u0026ldquo;platform-independent\u0026rdquo; business logic imports stm32f4xx_hal.h because someone passed an SPI_HandleTypeDef* through three layers of function calls. That\u0026rsquo;s the lock-in. It\u0026rsquo;s not the function call — it\u0026rsquo;s the type that leaks upward.\nArduino: Simplicity at a Cost Arduino\u0026rsquo;s approach is the opposite extreme: hide everything behind the simplest possible API.\n// Arduino SPI SPI.begin(); SPI.transfer(0xAA); // Arduino GPIO digitalWrite(13, HIGH); This is genuinely portable — Arduino runs on AVR, SAMD, ESP32, RP2040, STM32, and more. But the cost is severe. digitalWrite() is famously slow (50-80 cycles on AVR vs. 1-2 cycles for direct port manipulation) because it does a pin lookup table traversal at runtime. You can\u0026rsquo;t configure DMA-driven SPI transfers. You can\u0026rsquo;t set up half-duplex mode. You can\u0026rsquo;t do pin-level interrupts with configurable edge detection on some platforms.\nArduino proves that you can build a universal HAL, but it also proves that \u0026ldquo;universal\u0026rdquo; often means \u0026ldquo;universally limited.\u0026rdquo;\nZephyr: Device Tree and the Nuclear Option Zephyr RTOS takes the most sophisticated approach: device tree bindings (borrowed from Linux) combined with a driver model that separates API, driver implementation, and hardware description.\n// Zephyr GPIO — device tree driven const struct device *gpio_dev = DEVICE_DT_GET(DT_NODELABEL(gpio0)); gpio_pin_configure(gpio_dev, PIN, GPIO_OUTPUT_ACTIVE); gpio_pin_set(gpio_dev, PIN, 1); // Zephyr SPI — same pattern const struct device *spi_dev = DEVICE_DT_GET(DT_NODELABEL(spi1)); struct spi_buf tx_buf = { .buf = data, .len = sizeof(data) }; struct spi_buf_set tx = { .buffers = \u0026amp;tx_buf, .count = 1 }; struct spi_config cfg = { .frequency = 1000000, .operation = SPI_WORD_SET(8) | SPI_TRANSFER_MSB, }; spi_write(spi_dev, \u0026amp;cfg, \u0026amp;tx); The hardware description lives in .dts files:\n\u0026amp;spi1 { status = \u0026#34;okay\u0026#34;; cs-gpios = \u0026lt;\u0026amp;gpio0 4 GPIO_ACTIVE_LOW\u0026gt;; my_sensor: sensor@0 { compatible = \u0026#34;bosch,bme280\u0026#34;; reg = \u0026lt;0\u0026gt;; spi-max-frequency = \u0026lt;1000000\u0026gt;; }; }; This is the gold standard for portability. The same application code runs on any board with a Zephyr BSP. Swapping MCUs means changing the device tree overlay and the board config — zero application code changes.\nBut Zephyr is an entire operating system. You\u0026rsquo;re adopting a build system (west + CMake + Kconfig), a threading model, a memory management policy, a logging framework, and a driver model. For a blinking LED project, this is absurd. For a product with 3+ year lifecycle and potential MCU swaps, it might be exactly right.\nVerdict: If you can afford the complexity, Zephyr\u0026rsquo;s model is the best-designed HAL in the embedded ecosystem. But \u0026ldquo;can you afford the complexity\u0026rdquo; is doing a lot of heavy lifting in that sentence.\nHand-Rolled: The Default Choice Most production firmware I\u0026rsquo;ve worked on uses a hand-rolled HAL. Not because engineers sat down and designed one, but because someone created hal_gpio.h one afternoon and it grew organically. These range from elegant to horrifying.\nThe horrifying ones look like this:\n// The \u0026#34;abstraction\u0026#34; that isn\u0026#39;t #ifdef STM32F4 #include \u0026#34;stm32f4xx_hal.h\u0026#34; #define MY_SPI_HANDLE hspi1 #define MY_SPI_TRANSMIT(buf, len) HAL_SPI_Transmit(\u0026amp;MY_SPI_HANDLE, buf, len, 1000) #elif defined(NRF52) #include \u0026#34;nrfx_spi.h\u0026#34; #define MY_SPI_HANDLE m_spi #define MY_SPI_TRANSMIT(buf, len) nrfx_spi_xfer(\u0026amp;MY_SPI_HANDLE, \\ \u0026amp;(nrfx_spi_xfer_desc_t){.p_tx_buffer = buf, .tx_length = len}, 0) #endif This is a thin preprocessor skin over vendor APIs. It \u0026ldquo;works\u0026rdquo; until you need error handling (each vendor returns errors differently), or async transfers (each vendor\u0026rsquo;s callback model is different), or you add a third platform. The #ifdef jungle grows until no one can reason about it.\nWhat Actually Works: Building a Portable GPIO + SPI Abstraction Here\u0026rsquo;s how I\u0026rsquo;d design a HAL for a team that needs to support 2-3 MCU families without adopting Zephyr. This is the pattern I\u0026rsquo;ve used in production.\nPrinciple 1: Your Types, Not Theirs The single most important rule: never let a vendor type appear in your public API. The moment SPI_HandleTypeDef or nrfx_spi_t shows up in a header that application code includes, you\u0026rsquo;re locked in.\nDefine your own types. They can be thin wrappers — that\u0026rsquo;s fine. But they\u0026rsquo;re yours.\n// hal/hal_gpio.h — YOUR public API #pragma once #include \u0026lt;stdint.h\u0026gt; #include \u0026lt;stdbool.h\u0026gt; typedef enum { HAL_GPIO_MODE_INPUT, HAL_GPIO_MODE_OUTPUT_PP, // push-pull HAL_GPIO_MODE_OUTPUT_OD, // open-drain HAL_GPIO_MODE_AF, // alternate function (SPI, I2C, etc.) } hal_gpio_mode_t; typedef enum { HAL_GPIO_PULL_NONE, HAL_GPIO_PULL_UP, HAL_GPIO_PULL_DOWN, } hal_gpio_pull_t; typedef struct { uint8_t port; // 0 = GPIOA / P0, 1 = GPIOB / P1, etc. uint8_t pin; // 0-15 typically } hal_gpio_pin_t; typedef struct { hal_gpio_mode_t mode; hal_gpio_pull_t pull; uint8_t af_num; // alternate function number (ignored if mode != AF) } hal_gpio_config_t; // API int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg); int hal_gpio_write(hal_gpio_pin_t pin, bool state); bool hal_gpio_read(hal_gpio_pin_t pin); int hal_gpio_toggle(hal_gpio_pin_t pin); Notice what\u0026rsquo;s missing: no GPIO_TypeDef*, no nrf_gpio_pin_dir_t, no vendor anything. Application code includes this header and only this header.\nPrinciple 2: One Implementation File Per Platform Each platform gets its own .c file. The build system picks which one to compile.\n// hal/stm32/hal_gpio_stm32.c #include \u0026#34;hal/hal_gpio.h\u0026#34; #include \u0026#34;stm32f4xx_hal.h\u0026#34; // vendor header — confined to this file static GPIO_TypeDef* port_map[] = { GPIOA, GPIOB, GPIOC, GPIOD, GPIOE }; int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg) { if (pin.port \u0026gt;= sizeof(port_map)/sizeof(port_map[0])) return -1; // Enable clock — this is the kind of vendor-specific detail // that MUST live in the platform file __HAL_RCC_GPIOA_CLK_ENABLE(); // simplified; real code uses port index GPIO_InitTypeDef gpio_init = { .Pin = (1U \u0026lt;\u0026lt; pin.pin), .Mode = (cfg-\u0026gt;mode == HAL_GPIO_MODE_OUTPUT_PP) ? GPIO_MODE_OUTPUT_PP : (cfg-\u0026gt;mode == HAL_GPIO_MODE_OUTPUT_OD) ? GPIO_MODE_OUTPUT_OD : (cfg-\u0026gt;mode == HAL_GPIO_MODE_AF) ? GPIO_MODE_AF_PP : GPIO_MODE_INPUT, .Pull = (cfg-\u0026gt;pull == HAL_GPIO_PULL_UP) ? GPIO_PULLUP : (cfg-\u0026gt;pull == HAL_GPIO_PULL_DOWN) ? GPIO_PULLDOWN : GPIO_NOPULL, .Speed = GPIO_SPEED_FREQ_HIGH, .Alternate = cfg-\u0026gt;af_num, }; HAL_GPIO_Init(port_map[pin.port], \u0026amp;gpio_init); return 0; } int hal_gpio_write(hal_gpio_pin_t pin, bool state) { HAL_GPIO_WritePin(port_map[pin.port], (1U \u0026lt;\u0026lt; pin.pin), state ? GPIO_PIN_SET : GPIO_PIN_RESET); return 0; } bool hal_gpio_read(hal_gpio_pin_t pin) { return HAL_GPIO_ReadPin(port_map[pin.port], (1U \u0026lt;\u0026lt; pin.pin)) == GPIO_PIN_SET; } int hal_gpio_toggle(hal_gpio_pin_t pin) { HAL_GPIO_TogglePin(port_map[pin.port], (1U \u0026lt;\u0026lt; pin.pin)); return 0; } // hal/nrf52/hal_gpio_nrf52.c #include \u0026#34;hal/hal_gpio.h\u0026#34; #include \u0026#34;nrf_gpio.h\u0026#34; // nRF uses a flat pin numbering: port * 32 + pin static inline uint32_t to_nrf_pin(hal_gpio_pin_t p) { return (uint32_t)p.port * 32 + p.pin; } int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg) { uint32_t npin = to_nrf_pin(pin); nrf_gpio_pin_pull_t pull = (cfg-\u0026gt;pull == HAL_GPIO_PULL_UP) ? NRF_GPIO_PIN_PULLUP : (cfg-\u0026gt;pull == HAL_GPIO_PULL_DOWN) ? NRF_GPIO_PIN_PULLDOWN : NRF_GPIO_PIN_NOPULL; if (cfg-\u0026gt;mode == HAL_GPIO_MODE_INPUT) { nrf_gpio_cfg_input(npin, pull); } else { nrf_gpio_cfg_output(npin); } return 0; } int hal_gpio_write(hal_gpio_pin_t pin, bool state) { state ? nrf_gpio_pin_set(to_nrf_pin(pin)) : nrf_gpio_pin_clear(to_nrf_pin(pin)); return 0; } bool hal_gpio_read(hal_gpio_pin_t pin) { return nrf_gpio_pin_read(to_nrf_pin(pin)) != 0; } int hal_gpio_toggle(hal_gpio_pin_t pin) { nrf_gpio_pin_toggle(to_nrf_pin(pin)); return 0; } The application code is identical regardless of which platform file is compiled. That\u0026rsquo;s the whole point.\nPrinciple 3: SPI — Where It Gets Interesting SPI is harder than GPIO because it has more modes, needs error handling, and often involves DMA for performance. Here\u0026rsquo;s a practical abstraction:\n// hal/hal_spi.h #pragma once #include \u0026#34;hal/hal_gpio.h\u0026#34; #include \u0026lt;stdint.h\u0026gt; #include \u0026lt;stddef.h\u0026gt; typedef struct hal_spi hal_spi_t; // opaque — defined per platform typedef enum { HAL_SPI_MODE_0, // CPOL=0, CPHA=0 HAL_SPI_MODE_1, // CPOL=0, CPHA=1 HAL_SPI_MODE_2, // CPOL=1, CPHA=0 HAL_SPI_MODE_3, // CPOL=1, CPHA=1 } hal_spi_mode_t; typedef enum { HAL_SPI_BIT_ORDER_MSB_FIRST, HAL_SPI_BIT_ORDER_LSB_FIRST, } hal_spi_bit_order_t; typedef struct { uint32_t max_freq_hz; hal_spi_mode_t mode; hal_spi_bit_order_t bit_order; hal_gpio_pin_t cs_pin; // managed by HAL, not by caller bool cs_active_low; } hal_spi_config_t; typedef void (*hal_spi_callback_t)(int status, void *ctx); // Lifecycle hal_spi_t *hal_spi_open(uint8_t instance, const hal_spi_config_t *cfg); void hal_spi_close(hal_spi_t *spi); // Blocking int hal_spi_transfer(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len); int hal_spi_write(hal_spi_t *spi, const uint8_t *tx, size_t len); int hal_spi_read(hal_spi_t *spi, uint8_t *rx, size_t len); // Async (optional — returns -ENOTSUP if platform doesn\u0026#39;t support it) int hal_spi_transfer_async(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len, hal_spi_callback_t cb, void *ctx); Key design decisions here:\nOpaque handle. hal_spi_t is forward-declared in the header and defined in each platform\u0026rsquo;s .c file. This is the critical firewall — application code can\u0026rsquo;t reach into the handle and touch vendor-specific fields because it doesn\u0026rsquo;t know what they are.\nCS pin management. The HAL asserts/deasserts chip select. This sounds minor but it\u0026rsquo;s one of the most common sources of porting bugs. Different vendors handle CS differently (hardware vs. software, active high vs. low), and if your application code manages CS directly, every call site needs platform ifdefs.\nAsync as optional. Not every platform supports DMA-driven SPI with the same callback model. Rather than forcing a lowest-common-denominator async API, we let it return -ENOTSUP and let the caller fall back to blocking. Pragmatic beats pure.\nThe Dispatch Question: Compile-Time vs. Runtime This is where HAL design gets philosophical. How does hal_spi_transfer() know which implementation to call?\nOption A: Compile-Time Dispatch (Link-Time Selection) The simplest approach: only one platform .c file is compiled into the binary. The linker resolves hal_spi_transfer to whichever implementation was compiled.\n# Makefile ifeq ($(PLATFORM),stm32) HAL_SRC = hal/stm32/hal_spi_stm32.c hal/stm32/hal_gpio_stm32.c else ifeq ($(PLATFORM),nrf52) HAL_SRC = hal/nrf52/hal_spi_nrf52.c hal/nrf52/hal_gpio_nrf52.c endif Pros: Zero overhead. No function pointers, no vtables, no indirection. The compiler can inline everything. This is what you want for hard real-time systems where every cycle matters.\nCons: You can only target one platform per binary. You can\u0026rsquo;t have a test binary that mocks the hardware — unless you add a hal/mock/ platform and compile against that.\nThis is the approach I recommend for 90% of projects. The \u0026ldquo;one platform per binary\u0026rdquo; limitation sounds restrictive until you realize that\u0026rsquo;s what you\u0026rsquo;re doing anyway — you don\u0026rsquo;t ship the same .elf to an STM32 and an nRF52.\nOption B: Function Pointer Tables // hal/hal_spi.h (function pointer variant) typedef struct { hal_spi_t* (*open)(uint8_t instance, const hal_spi_config_t *cfg); void (*close)(hal_spi_t *spi); int (*transfer)(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len); int (*write)(hal_spi_t *spi, const uint8_t *tx, size_t len); int (*read)(hal_spi_t *spi, uint8_t *rx, size_t len); } hal_spi_ops_t; // Each platform registers its ops extern const hal_spi_ops_t hal_spi_ops; // Application code calls through the table #define hal_spi_transfer(spi, tx, rx, len) hal_spi_ops.transfer(spi, tx, rx, len) Pros: You can swap implementations at link time or at runtime. Makes mocking trivial for tests — just plug in a mock ops table. This is essentially what Zephyr does under the hood.\nCons: One level of pointer indirection per call. On a Cortex-M4 at 168 MHz, this is ~2-5 extra cycles — irrelevant for SPI (the bus itself is the bottleneck) but potentially meaningful for GPIO bit-banging in a tight loop.\nOption C: C++ Virtual Dispatch (vtable) If you\u0026rsquo;re in C++ land:\nclass ISpiDriver { public: virtual ~ISpiDriver() = default; virtual int transfer(const uint8_t *tx, uint8_t *rx, size_t len) = 0; virtual int write(const uint8_t *tx, size_t len) = 0; }; class Stm32SpiDriver : public ISpiDriver { /* ... */ }; class Nrf52SpiDriver : public ISpiDriver { /* ... */ }; This is functionally identical to Option B but with language support for the dispatch. Same overhead, slightly cleaner syntax, and you get type safety on the ops table for free. The downside is that many embedded teams avoid C++ virtual dispatch because the vtable pointer adds 4 bytes per object instance, and the compiler-generated vtable code is harder to audit in safety-critical contexts.\nOption D: Preprocessor Switching // hal_spi.h #if defined(PLATFORM_STM32) #include \u0026#34;hal/stm32/hal_spi_stm32_inline.h\u0026#34; #elif defined(PLATFORM_NRF52) #include \u0026#34;hal/nrf52/hal_spi_nrf52_inline.h\u0026#34; #endif This is compile-time dispatch with the implementations inlined into the header. Maximum performance (everything can be inlined and optimized), but it means your public headers pull in vendor headers transitively. The whole point of the abstraction was to avoid this. Use this only for truly hot-path operations where the function call overhead is measured and proven to matter.\nWhat I\u0026rsquo;d Actually Recommend After shipping firmware on STM32, nRF, TI, and Renesas parts, here\u0026rsquo;s my practical decision tree:\nIf you\u0026rsquo;re starting a new product with potential for MCU changes (most products):\nUse compile-time dispatch (Option A) with the opaque handle pattern. Define your own types. One .c per platform, selected by the build system. This gives you zero overhead, clean separation, and easy porting. When you need to test, add a hal/mock/ platform with stub implementations.\nIf you\u0026rsquo;re building a framework or SDK that ships to other developers:\nUse function pointer tables (Option B). Your users might need to mock, extend, or substitute drivers. The pointer indirection cost is negligible for anything above bit-bang-speed peripherals. This is the right tradeoff for flexibility.\nIf you\u0026rsquo;re already using Zephyr or considering an RTOS:\nJust use Zephyr\u0026rsquo;s driver model. Seriously. Don\u0026rsquo;t build a second HAL on top of Zephyr\u0026rsquo;s HAL — that\u0026rsquo;s two layers of abstraction for the same job. Zephyr\u0026rsquo;s device tree + driver API is the most well-designed HAL in the ecosystem. The cost is adopting Zephyr, but if you\u0026rsquo;re already there, you\u0026rsquo;ve already paid it.\nIf your product will only ever run on one MCU family:\nDon\u0026rsquo;t build a HAL at all. Use the vendor HAL (STM32 HAL, nrfx, ESP-IDF drivers) directly. A HAL is insurance against hardware changes. If that risk is genuinely zero, the insurance premium (development time, code complexity, debugging difficulty) isn\u0026rsquo;t worth paying.\nCommon Mistakes A few patterns I\u0026rsquo;ve seen fail repeatedly:\nLeaking vendor types through \u0026ldquo;convenience\u0026rdquo; macros. Someone adds #define MY_SPI hspi1 and now every file that uses MY_SPI transitively depends on ST\u0026rsquo;s headers. The macro looked harmless. It wasn\u0026rsquo;t.\nOver-abstracting initialization. Init is where platforms differ the most (clock trees, pin muxing, DMA channel allocation). Trying to make init generic leads to massive config structs that are just as vendor-specific as the original API but harder to understand. Let init be platform-specific. Abstract the operations, not the setup.\nAbstracting too early. Don\u0026rsquo;t build a HAL on day one of a project. Write directly against the vendor HAL, get the product working, then extract the abstraction boundary once you can see which operations are actually used. Premature abstraction creates APIs that don\u0026rsquo;t match real usage patterns.\nIgnoring error semantics. STM32 HAL returns HAL_StatusTypeDef (OK, ERROR, BUSY, TIMEOUT). nrfx returns nrfx_err_t. ESP-IDF returns esp_err_t. If your HAL just returns int with 0 for success and -1 for failure, you\u0026rsquo;ve lost the ability to distinguish between \u0026ldquo;bus busy, retry later\u0026rdquo; and \u0026ldquo;hardware fault, pin not configured.\u0026rdquo; Define your own error codes that capture the categories your application actually needs to handle.\nThe Test Story The strongest argument for a clean HAL isn\u0026rsquo;t portability — it\u0026rsquo;s testability. With the opaque handle pattern and compile-time dispatch, you can create a hal/mock/ implementation that:\nRecords every call (what was written, to which pin/bus) Returns scripted responses (inject error conditions) Validates sequencing (CS asserted before transfer, released after) Runs on your development machine, not on target hardware This means your application logic — the part that talks to sensors, runs protocols, makes decisions — can be tested on x86 with a standard test framework. No JTAG required. No waiting for hardware. No \u0026ldquo;it works on my board\u0026rdquo; debugging.\n// hal/mock/hal_spi_mock.c static struct { uint8_t tx_log[4096]; size_t tx_log_pos; uint8_t rx_script[4096]; size_t rx_script_pos; int next_error; } mock_state; int hal_spi_transfer(hal_spi_t *spi, const uint8_t *tx, uint8_t *rx, size_t len) { if (mock_state.next_error) { int err = mock_state.next_error; mock_state.next_error = 0; return err; } memcpy(\u0026amp;mock_state.tx_log[mock_state.tx_log_pos], tx, len); mock_state.tx_log_pos += len; memcpy(rx, \u0026amp;mock_state.rx_script[mock_state.rx_script_pos], len); mock_state.rx_script_pos += len; return 0; } // Test helper: inject an error on the next call void mock_spi_set_next_error(int err) { mock_state.next_error = err; } This mock compiles and runs on any platform. Your CI pipeline catches protocol bugs before hardware is even available. That\u0026rsquo;s the real payoff of a well-designed HAL.\nClosing Thoughts The best HAL is the one your team can actually maintain. I\u0026rsquo;ve seen beautiful, theoretically perfect abstractions that nobody understood and everyone worked around. I\u0026rsquo;ve also seen ugly #ifdef forests that somehow shipped reliable products for a decade.\nThe principles that matter:\nYour types at the boundary. Vendor types stay in platform files. Opaque handles. Application code can\u0026rsquo;t reach into hardware-specific fields. Abstract operations, not init. Let setup be messy and platform-specific. Compile-time dispatch by default. Add indirection only when you have a concrete reason. Mock-friendly from day one. If you can\u0026rsquo;t test it on x86, your abstraction has holes. The goal isn\u0026rsquo;t a perfect universal API. The goal is that when — not if — you need to swap silicon, the damage is contained to a set of well-defined platform files, and your application logic doesn\u0026rsquo;t change.\nThat\u0026rsquo;s a HAL that doesn\u0026rsquo;t lock you in.\nPranav Jain is a semiconductor software engineer specializing in the abstraction layer between customer applications and firmware. He works with C++, Python, and embedded systems across multiple MCU platforms. Find his open-source work on GitHub.\n","permalink":"https://pranavhj.github.io/blog/posts/hal-design-guide/","summary":"\u003cp\u003eEvery embedded engineer has lived through this moment: your company\u0026rsquo;s chip supplier emails you that the MCU you designed around is end-of-life, or lead times just jumped to 52 weeks. You stare at 40,000 lines of firmware that call \u003ccode\u003eHAL_SPI_Transmit()\u003c/code\u003e in 200 places, and you realize your \u0026ldquo;abstraction\u0026rdquo; was just ST\u0026rsquo;s abstraction. You\u0026rsquo;re locked in.\u003c/p\u003e\n\u003cp\u003eI\u0026rsquo;ve spent years writing the mid-layer software that sits between customer applications and firmware — the abstraction that\u0026rsquo;s supposed to make hardware swappable. I\u0026rsquo;ve seen what works, what doesn\u0026rsquo;t, and what looks elegant in a conference talk but falls apart when you actually need to port to a different silicon vendor. This post is the guide I wish I\u0026rsquo;d had five years ago.\u003c/p\u003e","title":"Designing a HAL That Doesn't Lock You In"},{"content":"Most firmware teams test by flashing the board and watching an LED blink. That works until your test matrix is 200 cases, your CI pipeline needs to run on every commit, and the dev kit is on someone else\u0026rsquo;s desk.\nHere\u0026rsquo;s how I test firmware without hardware for roughly 80% of the test surface. The remaining 20% — timing-critical code, analog peripherals, RF — still needs real silicon. But 80% is enough to catch the bugs that matter.\nThe Testing Pyramid for Firmware Borrow the concept from web development but adapt it:\n/\\ / \\ Hardware-in-the-loop (HIL) / \\ Real board, real peripherals /------\\ / \\ Integration tests on target / \\ QEMU or native_sim /------------\\ / \\ Unit tests on host (x86) /________________\\ Mocked HAL, no hardware Most of your tests should be at the bottom. Fast, cheap, run on any machine.\nLevel 1: Unit Tests on Host (x86) This is the highest-ROI testing strategy for firmware. Compile your application code for your development machine, mock the hardware interfaces, test with any C test framework.\nThe Setup Your code needs to be structured so that application logic doesn\u0026rsquo;t directly call vendor HAL functions:\n// application/sensor_reader.c #include \u0026#34;hal/spi.h\u0026#34; #include \u0026#34;hal/gpio.h\u0026#34; #define SENSOR_CS_PIN ((gpio_pin_t){.port = 0, .pin = 4}) int sensor_read_temperature(int16_t *temp_out) { uint8_t cmd = 0x80; // Read temperature register uint8_t rx[2] = {0}; gpio_write(SENSOR_CS_PIN, 0); int err = spi_transfer(SPI_BUS_0, \u0026amp;cmd, rx, 2); gpio_write(SENSOR_CS_PIN, 1); if (err != 0) return err; *temp_out = (int16_t)((rx[0] \u0026lt;\u0026lt; 8) | rx[1]) / 16; return 0; } Now mock the HAL for host testing:\n// test/mocks/mock_spi.c #include \u0026#34;hal/spi.h\u0026#34; #include \u0026lt;string.h\u0026gt; static uint8_t spi_rx_buffer[256]; static size_t spi_rx_len = 0; static int spi_fail_next = 0; void mock_spi_set_rx_data(const uint8_t *data, size_t len) { memcpy(spi_rx_buffer, data, len); spi_rx_len = len; } void mock_spi_set_fail(int fail) { spi_fail_next = fail; } int spi_transfer(spi_bus_t bus, const uint8_t *tx, uint8_t *rx, size_t len) { if (spi_fail_next) { spi_fail_next = 0; return -1; } if (rx \u0026amp;\u0026amp; spi_rx_len \u0026gt;= len) { memcpy(rx, spi_rx_buffer, len); } return 0; } And the test:\n// test/test_sensor_reader.c #include \u0026#34;unity.h\u0026#34; // or any C test framework #include \u0026#34;application/sensor_reader.h\u0026#34; #include \u0026#34;test/mocks/mock_spi.h\u0026#34; void test_sensor_read_temperature_normal(void) { // 25.0°C = 400 raw = 0x0190 uint8_t fake_data[] = {0x01, 0x90}; mock_spi_set_rx_data(fake_data, 2); int16_t temp; int err = sensor_read_temperature(\u0026amp;temp); TEST_ASSERT_EQUAL(0, err); TEST_ASSERT_EQUAL(25, temp); } void test_sensor_read_temperature_spi_failure(void) { mock_spi_set_fail(1); int16_t temp; int err = sensor_read_temperature(\u0026amp;temp); TEST_ASSERT_NOT_EQUAL(0, err); } void test_sensor_read_negative_temperature(void) { // -10.0°C = -160 raw = 0xFF60 uint8_t fake_data[] = {0xFF, 0x60}; mock_spi_set_rx_data(fake_data, 2); int16_t temp; int err = sensor_read_temperature(\u0026amp;temp); TEST_ASSERT_EQUAL(0, err); TEST_ASSERT_EQUAL(-10, temp); } Compile and run on your laptop:\ngcc -o test_sensor test/test_sensor_reader.c \\ application/sensor_reader.c \\ test/mocks/mock_spi.c test/mocks/mock_gpio.c \\ -Iinclude -Itest/frameworks/unity/src \\ test/frameworks/unity/src/unity.c ./test_sensor Runs in milliseconds. No hardware. Catches logic bugs, edge cases, error handling.\nWhat You Can Test This Way Data parsing and protocol decoding State machines Command handlers Configuration validation Error handling paths Math and algorithms Buffer management Anything that doesn\u0026rsquo;t depend on timing or real peripherals What You CAN\u0026rsquo;T Test This Way Real-time behavior (ISR latency, DMA timing) Peripheral initialization sequences Power management Analog signal paths RF communication Boot sequences Level 2: QEMU for ARM Targets QEMU emulates ARM Cortex-M processors well enough to run firmware images. It won\u0026rsquo;t emulate your specific board\u0026rsquo;s peripherals, but it handles the CPU, memory map, NVIC, and SysTick.\nZephyr + QEMU Zephyr has first-class QEMU support:\n# Build for QEMU Cortex-M3 west build -b qemu_cortex_m3 samples/hello_world west build -t run # Output: # *** Booting Zephyr OS build v3.x.0 *** # Hello World! qemu_cortex_m3 This runs your Zephyr application in QEMU — including the kernel, scheduler, and any drivers that have QEMU backends.\nWhat QEMU Gives You RTOS task scheduling and synchronization testing Memory allocation and stack overflow detection Kernel API correctness Multi-threaded logic bugs What QEMU Doesn\u0026rsquo;t Give You Real peripheral behavior (SPI, I2C, GPIO are stubs or absent) Real timing (QEMU runs faster or slower than real hardware) Board-specific initialization Level 3: Zephyr native_sim (Best of Both Worlds) This is my favorite approach. Zephyr\u0026rsquo;s native_sim target compiles your firmware as a native Linux/macOS executable. It uses POSIX threads to simulate Zephyr\u0026rsquo;s threading model, and you can link against host-side libraries.\nwest build -b native_sim samples/hello_world ./build/zephyr/zephyr.exe Why this is powerful:\n// Your Zephyr application #include \u0026lt;zephyr/kernel.h\u0026gt; #include \u0026lt;zephyr/drivers/gpio.h\u0026gt; void main(void) { const struct device *gpio = DEVICE_DT_GET(DT_NODELABEL(gpio0)); gpio_pin_configure(gpio, 13, GPIO_OUTPUT); while (1) { gpio_pin_toggle(gpio, 13); k_msleep(500); } } On native_sim, this compiles to a normal executable. The GPIO driver is a stub that logs calls. You can add assertions, inject faults, and run under Valgrind or AddressSanitizer:\nwest build -b native_sim -DCONFIG_ASAN=y my_app ./build/zephyr/zephyr.exe # AddressSanitizer catches buffer overflows, use-after-free, etc. Level 4: Hardware-in-the-Loop (HIL) For the 20% that needs real hardware, automate it:\n┌──────────┐ USB/SWD ┌──────────┐ │ CI Host │ ──────────────── │ Dev Kit │ │ (RPi) │ Serial │ (DUT) │ │ │ ──────────────── │ │ └──────────┘ └──────────┘ A Raspberry Pi (or any Linux machine) connected to your dev kit via SWD (for flashing) and serial (for output). The CI pipeline:\nFlashes the firmware via OpenOCD / pyOCD / nrfjprog Resets the board Reads serial output Asserts on expected output Reports pass/fail #!/bin/bash # hil_test.sh pyocd flash build/firmware.hex pyocd reset timeout 10 cat /dev/ttyACM0 | grep -q \u0026#34;SELF_TEST: PASS\u0026#34; if [ $? -eq 0 ]; then echo \u0026#34;HIL test PASSED\u0026#34; else echo \u0026#34;HIL test FAILED\u0026#34; exit 1 fi CI Pipeline Example # .github/workflows/firmware-test.yml name: Firmware Tests on: [push, pull_request] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and run unit tests run: | mkdir build \u0026amp;\u0026amp; cd build cmake .. -DTARGET=host_test make ctest --output-on-failure zephyr-native: runs-on: ubuntu-latest container: ghcr.io/zephyrproject-rtos/ci:latest steps: - uses: actions/checkout@v4 - name: Build for native_sim run: | west build -b native_sim app timeout 30 ./build/zephyr/zephyr.exe || true - name: Run with ASAN run: | west build -b native_sim app -- -DCONFIG_ASAN=y timeout 30 ./build/zephyr/zephyr.exe # HIL tests run on self-hosted runner with physical board hil-tests: runs-on: self-hosted # RPi with connected dev kit needs: [unit-tests, zephyr-native] steps: - uses: actions/checkout@v4 - name: Flash and test run: ./scripts/hil_test.sh Unit tests and native_sim run on every commit (free, fast). HIL tests run on merge to main (needs hardware, slower).\nPractical Advice Start with unit tests on host. If your code can\u0026rsquo;t compile for x86 because of vendor HAL dependencies, that\u0026rsquo;s the first problem to fix. Introduce a HAL interface, mock it, get your application code compiling on the host.\nUse Unity or CMock for C testing. They\u0026rsquo;re lightweight, embedded-friendly, and widely used. Unity is just a single .c and .h file.\nDon\u0026rsquo;t mock too much. If you\u0026rsquo;re mocking 15 interfaces to test one function, your function is too coupled. Refactor.\nKeep tests fast. All host-side tests should complete in under 10 seconds. If they don\u0026rsquo;t, something is wrong.\nTest error paths. The happy path usually works. The bugs are in: SPI timeout handling, buffer overflow on unexpected response length, negative temperature values, config validation edge cases.\nMeasure coverage, but don\u0026rsquo;t worship it. 80% coverage with meaningful tests beats 100% coverage with trivial assertions.\nThe Payoff On a recent project, we had 340 unit tests running on x86, 20 integration tests on native_sim, and 12 HIL tests on a self-hosted runner. The unit tests caught 90% of bugs before they ever touched hardware. The average debug cycle went from \u0026ldquo;flash, observe, wonder, flash again\u0026rdquo; (15 minutes) to \u0026ldquo;run test, see failure, fix, run test\u0026rdquo; (30 seconds).\nIt\u0026rsquo;s more work upfront. But firmware debugging is expensive — an hour saved per bug, across hundreds of bugs, across the life of a project, is measured in weeks.\nPranav Jain is an embedded systems engineer focused on middleware, abstraction layers, and developer tooling for firmware teams. Find him on GitHub.\n","permalink":"https://pranavhj.github.io/blog/posts/testing-firmware-without-hardware/","summary":"\u003cp\u003eMost firmware teams test by flashing the board and watching an LED blink. That works until your test matrix is 200 cases, your CI pipeline needs to run on every commit, and the dev kit is on someone else\u0026rsquo;s desk.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s how I test firmware without hardware for roughly 80% of the test surface. The remaining 20% — timing-critical code, analog peripherals, RF — still needs real silicon. But 80% is enough to catch the bugs that matter.\u003c/p\u003e","title":"How to Test Firmware Without Physical Hardware"},{"content":"I\u0026rsquo;ve spent the last few years writing abstraction layers between hardware and application software. Part of my job is knowing what\u0026rsquo;s out there, what works, and what breaks when you need it most. So I went through five open-source hardware abstraction layers and evaluated them on the criteria that matter for production firmware.\nHere\u0026rsquo;s what I found.\nThe Evaluation Criteria I scored each HAL on five dimensions:\nPortability — How many MCU families does it support? How hard is it to add a new one? Performance — What\u0026rsquo;s the overhead vs direct register access? API Design — Is the API intuitive? Consistent? Does it leak hardware details? Documentation — Can a new developer figure it out without reading the source? Production Readiness — Is it used in real products? Are there gotchas? Scale: 1 (poor) to 5 (excellent).\n1. Zephyr RTOS Device Driver Model What it is: Zephyr isn\u0026rsquo;t just an RTOS — it\u0026rsquo;s a full operating system with a driver model based on Linux\u0026rsquo;s device tree concept. Hardware is described in .dts files, and drivers bind to device tree nodes.\nExample — GPIO:\n#include \u0026lt;zephyr/drivers/gpio.h\u0026gt; #define LED_NODE DT_ALIAS(led0) static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED_NODE, gpios); void main(void) { gpio_pin_configure_dt(\u0026amp;led, GPIO_OUTPUT_ACTIVE); while (1) { gpio_pin_toggle_dt(\u0026amp;led); k_msleep(500); } } What they got right:\nDevice tree separates hardware description from driver code. Your app code literally doesn\u0026rsquo;t know which MCU it\u0026rsquo;s running on. Consistent API across all peripherals. GPIO, SPI, I2C, UART all follow the same pattern. Massive MCU support — STM32, nRF, ESP32, NXP, TI, Renesas, Microchip, RISC-V families. Active community, regular releases, Linux Foundation backing. What they got wrong:\nLearning curve is steep. Device tree overlay syntax is intimidating for embedded engineers coming from bare-metal. Build system (west + CMake + Kconfig) is powerful but complex. First build can take 30 minutes of setup. Overhead isn\u0026rsquo;t zero. The driver model uses function pointer dispatch, which adds a few cycles per call. Fine for most apps, not ideal for bit-banging at MHz speeds. Flash footprint starts around 40-60KB minimum. Not viable for small Cortex-M0 parts with 32KB flash. Scores:\nPortability Performance API Design Documentation Production Ready 5 3 4 4 5 Verdict: Best choice if your project can afford the footprint and learning curve. The portability is unmatched.\n2. CMSIS (Cortex Microcontroller Software Interface Standard) What it is: ARM\u0026rsquo;s official standard for Cortex-M software interfaces. Defines core access functions, DSP intrinsics, RTOS API, and driver APIs.\nExample — GPIO (CMSIS-Driver):\n#include \u0026#34;Driver_GPIO.h\u0026#34; extern ARM_DRIVER_GPIO Driver_GPIO0; void led_init(void) { Driver_GPIO0.Setup(5, NULL); // Pin 5 Driver_GPIO0.SetDirection(5, ARM_GPIO_OUTPUT); } void led_toggle(void) { static int state = 0; Driver_GPIO0.SetOutput(5, state ^= 1); } What they got right:\nIt\u0026rsquo;s a standard. If every vendor implemented it, portability would be solved. CMSIS-Core (register access, NVIC, SysTick) is universally used and excellent. CMSIS-DSP is genuinely useful and well-optimized. What they got wrong:\nAlmost nobody implements CMSIS-Driver in practice. Vendors ship their own HALs (STM32 HAL, nRF drivers) instead. The driver API is over-abstracted. It tries to be generic enough for every peripheral on every chip, resulting in an API that\u0026rsquo;s awkward for all of them. Documentation exists but is dense and spec-like, not tutorial-like. CMSIS-RTOS is a wrapper API, not a real RTOS. It adds overhead without adding capability. Scores:\nPortability Performance API Design Documentation Production Ready 2 4 2 3 3 Verdict: CMSIS-Core is essential. CMSIS-Driver is a good idea that the industry ignored. Don\u0026rsquo;t build on it unless you want to maintain the vendor implementations yourself.\n3. Arduino HAL What it is: The most successful embedded abstraction layer in terms of adoption. digitalWrite(), analogRead(), Serial.begin() — you know it.\nExample:\nvoid setup() { pinMode(LED_BUILTIN, OUTPUT); Serial.begin(115200); } void loop() { digitalWrite(LED_BUILTIN, HIGH); delay(500); digitalWrite(LED_BUILTIN, LOW); delay(500); Serial.println(\u0026#34;blink\u0026#34;); } What they got right:\nSimplicity. A beginner can blink an LED in 5 minutes. That\u0026rsquo;s an achievement. Massive ecosystem. Libraries for every sensor, display, and communication module. Runs on Arduino AVR, ESP32 (arduino-esp32), STM32 (STM32duino), nRF (Adafruit), RP2040. Genuine portability. What they got wrong:\nNo DMA, no interrupts (without platform-specific extensions), no low-power modes. The API hides too much. digitalWrite() is slow. On AVR, it\u0026rsquo;s ~50 clock cycles vs 2 for direct port manipulation. On ARM it\u0026rsquo;s better, but still has overhead from pin lookup. Global state everywhere. One Serial object, one SPI object. Multi-instance is hacked in. C++ requirement. Many embedded teams work in C. Arduino forces C++ with its class-based API. Error handling is nonexistent. Functions either work or silently fail. No error codes, no status flags. Scores:\nPortability Performance API Design Documentation Production Ready 4 2 3 5 2 Verdict: Great for prototypes and education. Not for production firmware that needs DMA, interrupts, or low power. The API design philosophy (hide everything) is the opposite of what embedded needs (control everything).\n4. libopencm3 What it is: An open-source, community-maintained firmware library for ARM Cortex-M microcontrollers. Lower level than STM32 HAL, higher level than direct register access.\nExample — GPIO:\n#include \u0026lt;libopencm3/stm32/rcc.h\u0026gt; #include \u0026lt;libopencm3/stm32/gpio.h\u0026gt; void led_init(void) { rcc_periph_clock_enable(RCC_GPIOA); gpio_mode_setup(GPIOA, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, GPIO5); } void led_toggle(void) { gpio_toggle(GPIOA, GPIO5); } What they got right:\nClean, C-only API. No opaque handles, no callback registration, no HAL_StatusTypeDef. Just function calls that map closely to hardware operations. Thin. The overhead is minimal — most functions compile to a handful of register writes. Supports STM32, EFM32, LPC, SAM, and some others. Not as wide as Zephyr but respectable. Good linker scripts and startup code included. Build system is straightforward. What they got wrong:\nCommunity-maintained means inconsistent coverage. Some MCU families (STM32F1/F4) are well-supported. Others have gaps. Not enough abstraction for easy porting. The API uses vendor-specific register names (GPIOA, RCC_GPIOA). Porting from STM32 to SAM requires changing every call. No RTOS integration. It\u0026rsquo;s purely a peripheral library. Documentation is sparse. You\u0026rsquo;ll read source code. Scores:\nPortability Performance API Design Documentation Production Ready 2 5 4 2 3 Verdict: Excellent for single-MCU projects where you want clean register access without the bloat of vendor HALs. Not a portability solution.\n5. Tock OS What it is: A secure embedded operating system written in Rust. Uses Rust\u0026rsquo;s type system and ownership model to enforce isolation between the kernel, drivers, and applications.\nExample — GPIO (Tock capsule):\n// Kernel-side driver (capsule) impl\u0026lt;\u0026#39;a, G: hil::gpio::Pin\u0026gt; hil::gpio::Client for GpioDriver\u0026lt;\u0026#39;a, G\u0026gt; { fn fired(\u0026amp;self) { self.callback.map(|callback| { callback.schedule(0, 0, 0); }); } } What they got right:\nMemory safety guaranteed by the compiler. Buffer overflows, use-after-free, data races — caught at compile time. Strong isolation model. Untrusted applications can\u0026rsquo;t crash the kernel or other apps. The hardware interface layer (HIL) traits are well-designed. Clean separation between hardware-specific and hardware-independent code. Academic rigor — published in SOSP, backed by serious research. What they got wrong:\nRust on embedded is still maturing. Toolchain support, debugging tools, and community knowledge are improving but behind C. Very limited MCU support compared to C-based alternatives. Primarily Nordic nRF52, some STM32, some RISC-V. Overhead from the capsule/process model. More suitable for application processors (Cortex-M4+) than constrained MCUs. Small community. If you hit a problem, there are fewer people who can help. Hard to integrate with existing C codebases. If you have 50K lines of C firmware, Tock isn\u0026rsquo;t a migration target. Scores:\nPortability Performance API Design Documentation Production Ready 2 3 5 3 2 Verdict: The future of secure embedded systems, but not the present for most teams. If you\u0026rsquo;re starting a greenfield project on a supported MCU and your team knows Rust, it\u0026rsquo;s worth evaluating.\nSummary Scorecard HAL Portability Performance API Design Docs Production Total Zephyr 5 3 4 4 5 21 CMSIS 2 4 2 3 3 14 Arduino 4 2 3 5 2 16 libopencm3 2 5 4 2 3 16 Tock 2 3 5 3 2 15 What I\u0026rsquo;d Actually Recommend For a new production project that needs portability: Zephyr. The learning curve is real, but the portability payoff is worth it. The ecosystem is growing fast.\nFor a bare-metal project on a single MCU: libopencm3 (if your MCU is supported) or the vendor HAL with a thin abstraction layer on top.\nFor a prototype or proof-of-concept: Arduino. Get it working, then rewrite for production.\nFor security-critical applications on supported hardware: Look at Tock. It\u0026rsquo;s early but the safety guarantees are compelling.\nFor everyone: Don\u0026rsquo;t use CMSIS-Driver. Use CMSIS-Core (it\u0026rsquo;s great), but build your own driver abstraction or use Zephyr\u0026rsquo;s.\nAnd regardless of which HAL you choose — keep vendor types out of your application code. That single rule does more for portability than any framework.\nPranav Jain builds the middleware between hardware and application software. Find him on GitHub.\n","permalink":"https://pranavhj.github.io/blog/posts/open-source-hal-review/","summary":"\u003cp\u003eI\u0026rsquo;ve spent the last few years writing abstraction layers between hardware and application software. Part of my job is knowing what\u0026rsquo;s out there, what works, and what breaks when you need it most. So I went through five open-source hardware abstraction layers and evaluated them on the criteria that matter for production firmware.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s what I found.\u003c/p\u003e\n\u003ch2 id=\"the-evaluation-criteria\"\u003eThe Evaluation Criteria\u003c/h2\u003e\n\u003cp\u003eI scored each HAL on five dimensions:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003ePortability\u003c/strong\u003e — How many MCU families does it support? How hard is it to add a new one?\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePerformance\u003c/strong\u003e — What\u0026rsquo;s the overhead vs direct register access?\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAPI Design\u003c/strong\u003e — Is the API intuitive? Consistent? Does it leak hardware details?\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDocumentation\u003c/strong\u003e — Can a new developer figure it out without reading the source?\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eProduction Readiness\u003c/strong\u003e — Is it used in real products? Are there gotchas?\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eScale: 1 (poor) to 5 (excellent).\u003c/p\u003e","title":"I Reviewed 5 Open-Source HALs — What They Got Right and Wrong"},{"content":"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\u0026rsquo;t exist. And their entire codebase was welded to one vendor\u0026rsquo;s HAL.\nNow that lead times have mostly normalized, it\u0026rsquo;s tempting to forget. Don\u0026rsquo;t. The shortage exposed a structural weakness in how most teams write firmware, and the fix isn\u0026rsquo;t \u0026ldquo;keep more inventory.\u0026rdquo; It\u0026rsquo;s in how you architect your code.\nThe 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:\nSTM32F4 goes to 40-week lead time Purchasing finds an nRF52840 that\u0026rsquo;s available NOW Engineering estimates the port at \u0026ldquo;2-3 weeks\u0026rdquo; Actual port takes 8-12 weeks 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.\nWhy \u0026ldquo;Just Use Zephyr\u0026rdquo; Isn\u0026rsquo;t the Full Answer The reflexive response is \u0026ldquo;use Zephyr RTOS\u0026rdquo; or \u0026ldquo;use an RTOS with a HAL.\u0026rdquo; And yes, Zephyr\u0026rsquo;s device tree model gives you portability. But:\nMost firmware doesn\u0026rsquo;t run an RTOS. Bare-metal is still the majority of embedded projects, especially at the lower end. If you\u0026rsquo;re on a Cortex-M0 with 32KB flash, Zephyr isn\u0026rsquo;t an option.\nRTOS HALs have their own lock-in. You\u0026rsquo;re not locked to STM32 HAL anymore — you\u0026rsquo;re locked to Zephyr\u0026rsquo;s API. If Zephyr\u0026rsquo;s SPI driver doesn\u0026rsquo;t support your use case (say, a specific DMA mode), you\u0026rsquo;re back to writing vendor-specific code anyway.\nThe abstraction has to be yours. The only HAL you fully control is one you wrote. It doesn\u0026rsquo;t need to be complex. It needs to be intentional.\nWhat Portable Firmware Actually Looks Like Here\u0026rsquo;s what the teams that survived the shortage had in common:\n1. A Thin Peripheral Interface // hal/gpio.h — YOUR abstraction, not the vendor\u0026#39;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:\n// hal/stm32/gpio.c #include \u0026#34;hal/gpio.h\u0026#34; #include \u0026#34;stm32f4xx_hal.h\u0026#34; int gpio_init(gpio_pin_t pin, gpio_mode_t mode) { GPIO_InitTypeDef init = {0}; init.Pin = (1U \u0026lt;\u0026lt; 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, \u0026amp;init); return 0; } // hal/nrf52/gpio.c #include \u0026#34;hal/gpio.h\u0026#34; #include \u0026#34;nrf_gpio.h\u0026#34; 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.\n2. Build System That Supports Multiple Targets # CMakeLists.txt option(TARGET_MCU \u0026#34;Target MCU family\u0026#34; \u0026#34;stm32f4\u0026#34;) if(TARGET_MCU STREQUAL \u0026#34;stm32f4\u0026#34;) add_subdirectory(hal/stm32) target_compile_definitions(app PRIVATE TARGET_STM32F4) elseif(TARGET_MCU STREQUAL \u0026#34;nrf52\u0026#34;) add_subdirectory(hal/nrf52) target_compile_definitions(app PRIVATE TARGET_NRF52) endif() If your build system can\u0026rsquo;t switch targets with a single flag, your abstraction isn\u0026rsquo;t real.\n3. 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.\n// 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, \u0026amp;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:\n// 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.\nThe Cost of NOT Doing This Let me put numbers on it. From three migrations I was involved with:\nProject 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.\nThe \u0026ldquo;But We\u0026rsquo;ll Never Switch\u0026rdquo; Fallacy I\u0026rsquo;ve heard this from every team that eventually had to switch. \u0026ldquo;We\u0026rsquo;re committed to STM32.\u0026rdquo; \u0026ldquo;Nordic is our long-term partner.\u0026rdquo; \u0026ldquo;We\u0026rsquo;ll never need to port.\u0026rdquo;\nUntil:\nYour 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\u0026rsquo;t whether you\u0026rsquo;ll port. It\u0026rsquo;s when.\nWhat I\u0026rsquo;d Do on a New Project Today If I were starting a bare-metal project tomorrow:\nDay 1: Define the peripheral interface (GPIO, SPI, I2C, UART, Timer — maybe 200 lines of headers total) Day 2-3: Implement backend for the primary target Day 3: Set up CMake with target selection Day 3: Write mock backends for host testing Ongoing: Never let vendor types leak past the HAL boundary Total overhead: 2-3 days. Insurance against a multi-week port later.\nIf you\u0026rsquo;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.\nThe Broader Lesson The chip shortage wasn\u0026rsquo;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\u0026rsquo;ll switch eventually).\nThe 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.\nThe teams that did this barely noticed the shortage. Everyone else had a very expensive year.\nPranav Jain is an embedded systems engineer specializing in middleware and abstraction layers between hardware and application software. He\u0026rsquo;s building PortPilot, a tool that automates MCU migration analysis. Find him on GitHub.\n","permalink":"https://pranavhj.github.io/blog/posts/chip-shortage-firmware-lessons/","summary":"\u003cp\u003eBetween 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\u0026rsquo;t exist. And their entire codebase was welded to one vendor\u0026rsquo;s HAL.\u003c/p\u003e\n\u003cp\u003eNow that lead times have mostly normalized, it\u0026rsquo;s tempting to forget. Don\u0026rsquo;t. The shortage exposed a structural weakness in how most teams write firmware, and the fix isn\u0026rsquo;t \u0026ldquo;keep more inventory.\u0026rdquo; It\u0026rsquo;s in how you architect your code.\u003c/p\u003e","title":"The Chip Shortage Taught Us One Thing: Don't Vendor-Lock Your Firmware"},{"content":"I\u0026rsquo;m Pranav Jain — an embedded systems engineer specializing in the middleware layer between hardware and application software.\nWhat I do:\nFirmware architecture for multi-MCU products Hardware abstraction layer design Cross-MCU migration and porting Developer tooling for embedded teams Tech: C/C++, Python, STM32, ESP32, nRF52, Zephyr RTOS, ROS2, Unity/C#\nBuilding: PortPilot — a tool that automates MCU migration analysis.\nFind me: GitHub\n","permalink":"https://pranavhj.github.io/blog/about/","summary":"\u003cp\u003eI\u0026rsquo;m Pranav Jain — an embedded systems engineer specializing in the middleware layer between hardware and application software.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eWhat I do:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eFirmware architecture for multi-MCU products\u003c/li\u003e\n\u003cli\u003eHardware abstraction layer design\u003c/li\u003e\n\u003cli\u003eCross-MCU migration and porting\u003c/li\u003e\n\u003cli\u003eDeveloper tooling for embedded teams\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eTech:\u003c/strong\u003e C/C++, Python, STM32, ESP32, nRF52, Zephyr RTOS, ROS2, Unity/C#\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eBuilding:\u003c/strong\u003e \u003ca href=\"https://pranavhj.github.io/portpilot-landing/\"\u003ePortPilot\u003c/a\u003e — a tool that automates MCU migration analysis.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eFind me:\u003c/strong\u003e \u003ca href=\"https://github.com/pranavhj\"\u003eGitHub\u003c/a\u003e\u003c/p\u003e","title":"About"}]