STM32WBA BLE Stack Initialization: Why the Startup Sequence Trips Up Every Beginner

Common pitfalls when initializing the BLE stack on STM32WBA microcontrollers

Your LED blinks. Your UART happily prints BLE Init OK. You open nRF Connect on your phone, hit scan, and… nothing. No advertising packet. No error. Just silence.

If you’ve spent the last two hours staring at app_ble.c wondering what you missed, you’re in the right place. The STM32WBA BLE init chain is strict, mostly undocumented in one place, and fails quietly when you get it wrong. Worse, if you’re coming from STM32WB (dual-core) or from Nordic/TI, the mental model you already have is actively misleading you.

A quick note on sources: I wasn’t able to verify the current state of ST’s Wiki page on WBA BLE stack initialization against the latest STM32CubeWBA firmware package at the time of writing. ST’s Wiki has a history of lagging SDK releases by 6 to 12 months, and several API names around ll_sys_* and BleStack_Init have shifted between firmware versions. Treat this guide as the pragmatic walkthrough, and cross-check macro and function names against whatever version of CubeWBA you pulled.

What’s Actually Running on the M33

The first thing to unlearn: there is no CPU2 on STM32WBA.

On STM32WB, you had an M4 running your app and an M0+ running a sealed BLE binary, with IPCC shuttling messages between them. That’s gone. WBA has a single Cortex-M33, and the BLE stack is a static library linked into your application. Your app code, the host stack, and the link layer all share the same address space, the same heap, and the same interrupt priorities.

This matters because every memory bug you write is now also a stack memory bug. And every priority inversion you create can wedge the radio.

Three software layers sit on top of the hardware:

 +--------------------------------------------------+
 |  Application (app_ble.c, p2p_server.c)           |
 +--------------------------------------------------+
 |  Host Stack: GAP / GATT / SMP / L2CAP            |
 +--------------------------------------------------+
 |  Link Layer (ll_sys)                             |
 +--------------------------------------------------+
 |  RF Driver + MAC  |  Sequencer / FreeRTOS        |
 +--------------------------------------------------+
 |  HAL  |  RNG  |  RCC (HSE/LSE)  |  PKA           |
 +--------------------------------------------------+
 |              Cortex-M33 (single core)            |
 +--------------------------------------------------+

The Sequencer (ST’s cooperative task runner in bare-metal examples) or FreeRTOS is how BLE events reach your code. If it’s not running before the first event fires, events get dropped. No error, no warning, just missed packets.

The Full Startup Sequence, Step by Step

Order matters. Get it wrong and you’ll either hard fault or boot into a system that does nothing.

HAL_Init
   |
   v
SystemClock (HSE + LSE)
   |
   v
RNG --> RTC --> MemMgr
                   |
                   v
             Sequencer / RTOS
                   |
                   v
             ll_sys_init
                   |
                   v
            Host stack (HCI)
                   |
                   v
             SVCCTL_Init
                   |
                   v
          App profile + advertise

Step by step, with the failure mode if you skip it:

  1. HAL_Init() + SystemClock_Config(): sets up HSE (32 MHz for RF) and LSE (32.768 kHz for sleep timing). Skip HSE and the radio won’t transmit. Run off HSI and you’ll see intermittent connection drops that look like firmware bugs.
  2. MX_RNG_Init(): the BLE stack pulls entropy at init for address generation and pairing. No RNG means a stack init error, or silently zeroed keys.
  3. MX_RTC_Init() with LSE source: the link layer uses RTC for connection interval timing. HSI-clocked RTC works on a Nucleo for 30 seconds before drift kills your connection.
  4. Memory manager init: buffer pools must exist before anything stack-related runs. This is where most init errors originate, even when the error surfaces two calls later.
  5. UTIL_SEQ_Init() or osKernelInitialize(): the task dispatcher has to be ready to receive BLE events. Register your BLE task handlers here, before link layer init, not after.
  6. ll_sys_config_params() then ll_sys_init(): link layer comes up. This call returns non-zero if memory, clocks, or RNG aren’t right. The return code is usually your first concrete clue something’s wrong.
  7. Host stack init (BleStack_Init() or equivalent): GAP/GATT/SMP/L2CAP spin up on top of the link layer. The function name has changed across recent CubeWBA versions, so check your SDK’s app_ble.c for the current symbol.
  8. SVCCTL_Init(): service controller. Your GATT services register against this.
  9. Application profile init: P2PS_APP_Init() for the peer-to-peer server, or your custom profile.
  10. aci_gap_set_discoverable() starts advertising. Until you call this, the radio is idle.

The #1 Failure Point: Memory Configuration

If your stm32wba ble init returns error 0x1F (out of memory), or if init succeeds but the first connection triggers a hard fault, this is your problem 80% of the time.

The BLE stack needs a heap carved out of SRAM at link time, sized according to how many connections, attributes, and characteristics you plan to support. These live in app_conf.h and ble_stack_config.h in the generated project. The macro names have drifted between SDK versions, so verify against the headers in your actual project:

  • CFG_BLE_NUM_LINK: max simultaneous connections
  • CFG_BLE_NUM_GATT_ATTRIBUTES: total GATT attribute count across all services
  • CFG_BLE_ATT_VALUE_ARRAY_SIZE: backing storage for characteristic values
  • BLE_DYN_ALLOC_SIZE: total dynamic heap for the stack

ST provides a formula in the header comments that computes required bytes from these inputs. Read it. Don’t guess and bump the number up until it works, because over-allocation will either blow your linker script or leave you with no RAM for your application.

Under-allocation shows up three ways: ll_sys_init() returns 0x1F, or init passes but aci_gatt_add_service() fails, or the stack boots and the first GATT write faults in some function you’ve never heard of.

Over-allocation fails loudly: linker error, usually region RAM overflowed.

Safe starting values for a single-connection peripheral with one custom service:

ParameterStarting valueNotes
CFG_BLE_NUM_LINK1single central
CFG_BLE_NUM_GATT_ATTRIBUTES68GAP + GATT + one custom service
CFG_BLE_ATT_VALUE_ARRAY_SIZE1344raise this if characteristic values exceed 20 bytes

Confirm the exact macro spelling against your SDK. ST renamed at least two of these between recent releases.

The Second Trap: Clocks on Custom Boards

It works on your Nucleo. You move the firmware to your custom PCB. Nothing advertises. You didn’t change a line of code.

It’s the clocks.

Nucleo-WBA boards ship with a 32 MHz HSE (often a TCXO or a well-matched crystal) and a 32.768 kHz LSE, both tuned and tested. Your custom board probably has a cheaper crystal with the wrong load capacitors, or LSE routed through a noisy trace, or no LSE at all because someone figured the internal LSI would be “close enough.”

The WBA RF subsystem requires HSE accurate to roughly ±50 ppm. Outside that window, you get symptoms that masquerade as stack bugs: connections that establish and then drop after 10 seconds, advertising that’s visible in nRF Connect but won’t accept connections, throughput that collapses under load.

Before you debug any more firmware, scope the HSE pin or route it to MCO and confirm:

  • Frequency is 32.000 MHz ±1.6 kHz
  • Amplitude matches the datasheet spec
  • No glitches on startup ramp

If it works on Nucleo and fails on your custom board, it’s the clocks 90% of the time. The other 10% is antenna matching, which is a different article.

A Minimal Init Debug Checklist

When MX_BLE_Init() fails and you need a fast triage path, run these in order:

  1. Does ll_sys_init() return 0? If not, it’s memory or clocks. Check heap size first.
  2. Set a breakpoint in SVCCTL_App_Notification. Does it ever fire? If no, the host stack didn’t start.
  3. Call aci_hal_get_firmware_details() after init. Does it return a sane version? If no, the link layer is wedged.
  4. Scope HSE. Actually put a probe on it. Don’t trust the config.
  5. Open the .map file. Look at .bss and heap totals. Are you inside your SRAM budget with margin for stack growth?

If all five pass and you still don’t advertise, the issue is in your GAP configuration or your advertising data payload, which is its own can of worms.

After The Radio Is On

Once you’ve got a clean stm32wba ble init and your device is advertising, the next things you’ll want to pin down are advertising parameters (interval, tx power, payload format), connection parameter negotiation, and a real plan for handling the hci_disconnection_complete_event cleanly.

A few useful references:

  • The STM32CubeWBA firmware package on st.com, and the Projects/NUCLEO-WBA*/Applications/BLE/ example tree. These are your canonical source for current API names.
  • ST’s community forum, specifically the STM32WBA tag, where ST engineers do respond to init-sequence questions.
  • If your product needs to reach infrastructure beyond the phone in someone’s hand, the Hubble device SDK for BLE and advertising packet format docs show how the same WBA advertising primitives feed satellite and terrestrial backhaul.

Hubble Network lets your STM32WBA advertising packets reach cloud infrastructure directly, without gateways or paired phones in range. See how it works →