I needed a microSD card on a Control Unit board running QP/C active objects over FreeRTOS. The architecture rule on that project is absolute: no blocking calls inside an AO’s dispatch handler. The SDMMC middleware does not care about my rules — SD_CardInit() blocks on an OSA semaphore no matter what the Kconfig says.

That was the easy problem. The hard one showed up later: card detection worked for a while, then broke unpredictably after a few insert/remove cycles, and once an init failed it never recovered. On removal, the debugger showed the card-detect callback firing twicefalse, then immediately true.

The board was reporting a card that was not there, and it was doing it to itself.

In this article I show how the SdCard AO is wired — schematic to pin mux to worker task to state machine — and then how the detection was actually fixed, because the first version was architecturally correct and still unreliable.

MCUXpresso Config Tools routing table for BOARD_InitSdCard: USDHC1 DATA0-3 and CMD with 100K pull-up and Pull selected, CLK with Keeper, CD_B and RESET_B on dedicated alt-functions

The routing table that matters. Note CD_B and RESET_B are on dedicated USDHC1 alt-functions, not GPIO — that single fact shapes the whole design.

TL;DR

Card detect and card power are dedicated USDHC1 alt-functions on this board, not GPIO, so detection is hardware-driven and power must go through USDHC_AssertHardwareReset(). The card-level SDMMC APIs block regardless of the “non-blocking” Kconfig option, so they live on a dedicated worker task and never in AO dispatch. And on a card-detect line that is capacitively coupled to the power-switch net, the interrupt tells you when to look — never what is true.

Step 1 — Read the schematic before believing the pin mux

Hardware: microSD socket on USDHC1.

SignalPadFunctionNote
CMD / CLK / DATA0-3J3 / K3 / K1, L1, J2, K2USDHC1 cmd/clk/data0-3Short on-board traces to the socket
CD (card detect)L11 (GPIO_AD_B1_02)USDHC1_CD_B — dedicated alt-function, not GPIOHardware CD; the USDHC1 controller reads this pin itself
RESET / power switchM14 (GPIO_AD_B0_00)USDHC1_RESET_B — dedicated alt-function, not GPIODrives the transistor pair that gates 3V3MEM → VSD_3V3

The discovery that shaped the whole design

CD and RESET are not generic GPIO here. BOARD_InitSdCard() in pin_mux.c muxes them as IOMUXC_GPIO_AD_B1_02_USDHC1_CD_B and IOMUXC_GPIO_AD_B0_00_USDHC1_RESET_B. Two consequences:

  • Card detection is fully hardware-driven (kSD_DetectCardByHostCD) — no GPIO IRQ or software debounce needed in the BSP. (Or so I thought. See step 6.)
  • Power control must go through USDHC_AssertHardwareReset(), not GPIO_PinWrite(). Once a pad is muxed to a peripheral, it belongs to that peripheral, not to GPIO.

Pull direction was checked against NXP’s own evkbmimxrt1060/sdmmc_examples reference pin_mux.c before any driver code was written — the SD spec requires pull-ups, not pull-downs, on CMD/DATA/CD for the open-drain identification phase. The field-by-field reasoning is in the companion article on pin config: pull/keeper, drive strength and slew rate.

Step 2 — Middleware setup, and one build-breaking export

In MCUXpresso Config Tools → Component Configuration → Middleware → sdmmc:

  • SD card (middleware.sdmmc.sd)
  • uSDHC host controller (middleware.sdmmc.host.usdhc) — not SDHC or SDIF, those are for other NXP families
  • Transfer mode: non-blocking
  • OSA selection: confirm it resolved to FreeRTOS (component.osa_free_rtos), not bare-metal

Build-breaking gotcha

The Config Tool writes several CONFIG_MCUX_COMPONENT_middleware.sdmmc.*=y lines straight into prj.conf for symbols that have no Kconfig prompt (.common, .osa, .usdhc.template). Those are select-only targets, and strict Kconfig rejects assigning them directly:

error: MCUX_COMPONENT_middleware.sdmmc.common ... is assigned in a
configuration file, but is not directly user-configurable (has no prompt)

Fix: delete the generated lines. They are already pulled in transitively — .sd selects .common, and .host.usdhc selects .osa and .usdhc.template.

What survives in prj.conf:

CONFIG_MCUX_COMPONENT_middleware.sdmmc.sd=y
CONFIG_MCUX_COMPONENT_middleware.sdmmc.host.usdhc=y
CONFIG_MCUX_COMPONENT_middleware.sdmmc.host.usdhc.non_blocking=y

Step 3 — Board SDMMC config

There was no sdmmc_config.h in this repo yet. The SDK’s middleware.sdmmc.usdhc.template component ships only a generic stub at middleware/sdmmc/template/usdhc/sdmmc_config.h, which has to be copied in and filled with board values — the same way pin_mux.c is generated once and then hand-edited.

#define BOARD_SDMMC_SD_HOST_BASEADDR USDHC1
#define BOARD_SDMMC_SD_HOST_IRQ      USDHC1_IRQn
#define BOARD_SDMMC_SD_CD_TYPE       kSD_DetectCardByHostCD   /* hardware CD_B */
#define BOARD_SDMMC_SD_POWER_RESET_ACTIVE_HIGH (1)            /* RESET_B HIGH = power ON */

BOARD_SD_Config() wires the card descriptor’s usrParam.pwr callback to a function calling USDHC_AssertHardwareReset(USDHC1, enable). That is what actually switches the transistor pair and powers the card, since GPIO writes do nothing on a pin muxed to a peripheral alt-function.

The build system picks up a project-provided sdmmc_config.c and says so during CMake configure:

-- Will use custom config file .../bsp/app/sdmmc_config.c from project

Step 4 — The blocking-API problem, and the worker-task pattern

Non-blocking does not mean what you want it to mean

The SDMMC middleware’s card-level APIs — SD_HostInit, SD_CardInit, SD_ReadBlocks, SD_WriteBlocksblock internally on OSA semaphores while a transfer is in flight, even with host.usdhc.non_blocking=y. That option only affects the low-level single-transfer API. The higher-level card functions are still synchronous.

That conflicts directly with “no blocking calls inside AO event handlers”. The resolution is to never call these APIs from the AO’s dispatch function at all. bsp_sdcard.c owns a dedicated FreeRTOS worker task that:

  1. Calls BOARD_SD_Config() and SD_HostInit() once at startup.
  2. Waits on a binary semaphore, given by the hardware CD_B interrupt callback. That callback is ISR-safe by construction — it does xSemaphoreGiveFromISR and nothing else.
  3. On card insertion, power-cycles via SD_SetCardPower off/on, then calls the blocking SD_CardInit().
  4. Posts a plain QACTIVE_POST() — task context, not _FROM_ISR — back to the SdCard AO with the result.

The AO’s state machine never touches a blocking call. It only reacts to posted result signals.

Step 5 — The active object

SdCard is a singleton AO. The state machine is deliberately small:

stateTop
  stateDisabled                 <- waits for SM_SDCARD_ENABLE_SIG
    -> BSP_SdCard_Init(ao, initOkSig, initFailSig, removedSig)
  stateEnabled
    stateNoCard                 <- initial substate
      HW_SD_CARD_INIT_OK_SIG    -> publish SD_CARD_MOUNTED_SIG,   -> stateMounted
      HW_SD_CARD_INIT_FAIL_SIG  -> publish SD_CARD_ERROR_SIG,     stay in stateNoCard
    stateMounted
      HW_SD_CARD_REMOVED_SIG    -> publish SD_CARD_UNMOUNTED_SIG, -> stateNoCard
    SM_SDCARD_DISABLE_SIG       -> BSP_SdCard_Deinit(),           -> stateDisabled

Other AOs subscribe to SD_CARD_MOUNTED_SIG, SD_CARD_UNMOUNTED_SIG and SD_CARD_ERROR_SIG. They never talk to the BSP or the worker task directly — no shared data between AOs.

A SystemManager_stateConfigureAndEnableSdCard state slots into the boot chain between Io and Rte, publishing SM_SDCARD_ENABLE_SIG. No separate configure phase is needed because init is already non-blocking from the AO’s point of view.

On priority: I first placed the AO at Io+1, then deliberately lowered it to Blinky+1. Its dispatch is trivial — few events, no blocking — and the real work lives on a separate low-priority worker task. There is no reason for it to out-rank I/O-critical AOs like Io, I2CBusController or TouchController. Size AO priority to actual urgency, not to habit.

Step 6 — Where it went wrong

Everything above is architecturally correct, and it was not reliable in practice. It took an extended debugging session to find out why, and the answer is worth writing down because the lesson generalises.

Symptoms

  • Detection worked for a while, then broke unpredictably after a few insert/remove cycles.
  • After an SD_CardInit() failure, detection corrupted and never recovered.
  • The HMI display went black while touch kept working — the SD path was starving the FlexIO-eDMA display.
  • In the debugger, the CD callback fired twice on removal: false, then immediately true.

Root causes, stacked

  1. SD_HostDoReset() on removal disabled all USDHC interrupt signals, and nothing re-armed them. Detection silently died.
  2. Boot-with-card gap in the SDK. If a card is already inserted at power-on, the SDK arms no CD interrupt at all.
  3. RESET_B ↔ CD_B coupling, in hardware. The card power switch sits next to the card-detect line. Toggling it dumps a transient onto CD_B. With a card inserted, the card mechanically holds CD_B low and is immune. With the card removed, CD_B floats on just its 10 kΩ pull-up and the transient flips it — a fake insertion immediately after a real removal.
  4. Trusting the ISR-reported state. Acting on the value the CD interrupt reported, on a line this noisy, propagated every glitch straight into the state machine.
  5. Retry livelock. A persistent init failure, retried on every wake, power-cycled the card each time. Each power-cycle self-glitched another CD interrupt. The resulting SD_CardInit/DMA/interrupt churn is what starved the display.

The fix — four measures, and you need all four

Defense in depth: never trust a single signal on a coupled, bouncing line. The CD interrupt becomes a “something changed, go check” trigger. The live hardware pin is the source of truth.

  1. Arm the removal interrupt once at boot if a card is already present, closing the SDK’s boot-with-card gap. One write, during single-threaded init — never in the steady-state loop.
  2. Debounce and re-read the hardware on every edge. On a reported change, wait ~50 ms, then read the actual pin with SDMMCHOST_CardDetectStatus(), not the ISR flag. If it matches the last acted-on state, it was a false trigger: drain the semaphore and ignore it.
  3. Power-cycle on insertion, then re-sync and drain. After SD_CardInit(), re-read the live pin, reset the tracked state to it, and drain pending semaphore gives — absorbing the fake CD interrupts the power-cycle itself induces through RESET_B coupling.
  4. Never power off on removal. The card is already gone; toggling RESET_B only glitches the now-floating CD line. The next insertion power-cycles cleanly anyway. This also matches the SDK examples, which only power-cycle on insertion.

The retry livelock disappears as a consequence. Acting only on a debounced, hardware-confirmed change of state means a persistent init failure is reported once and then waits for a genuine remove and re-insert. No churn, no display starvation.

Hardware recommendation for the next spin

Add a 100 nF capacitor from the card-detect net to ground. With the existing 10 kΩ pull-up that forms a ~1 ms RC low-pass, absorbing contact bounce and any coupled RESET_B transient at the source. Keep hysteresis enabled on the CD pad — correct for a mechanical-switch input, and worth checking against your routing table, since the export above has it disabled. The software defense-in-depth then becomes belt-and-braces rather than load-bearing.

Step 7 — Verifying it

  • Host unit tests (CppUTest) cover the full lifecycle: disabled → enabled, mount, unmount, error paths, disable-while-mounted, re-enable, plus two Q_ASSERT tests for BSP init and deinit failure. The BSP is mocked, so the worker task and hardware are never touched in host tests.
  • Firmware build gates: both the flexspi_nor_sdram_debug_qspy and flexspi_nor_sdram_debug_trace presets must build clean.
  • On hardware, via QSpy: open QView, set the current object and AO object to l_SdCard, filter for the matching AO IDs, and enable the global SM group (all) plus QS_QF_PUBLISH. Add BSP_logMsgWithUint() calls at each blocking-call boundary in the worker task — the trace then shows driver status codes rather than just “it failed”.

Cheat sheet — a new SDMMC-backed AO on this platform

StepWatch out for
Check schematic for CD/RESET routingIf muxed to a dedicated USDHC alt-function rather than GPIO, card detect is hardware-driven and power control must use USDHC_AssertHardwareReset(), not GPIO_PinWrite()
Enable middleware.sdmmc.sd + a host controllerPick the host matching your MCU’s actual peripheral (uSDHC on RT106x) — enabling SD card alone does not auto-select a host
Pick transfer modeNon-blocking, to match the AO rule — but know it only covers the low-level transfer, not SD_CardInit and friends
Verify OSA resolved to FreeRTOSIt is a Kconfig choice — confirm component.osa_free_rtos, not _bm
Write board sdmmc_config.h/.cNo template exists until you make one — copy the SDK’s generic template, fill in CD type, power-reset macros, IRQ priority
Isolate blocking SDMMC callsThey block on OSA semaphores regardless of the “non-blocking” Kconfig — run them on a dedicated worker task, never in AO dispatch
ISR-safe hand-offThe hardware CD callback runs in USDHC ISR context — give a semaphore there and nothing else
prj.conf sanityDo not let generated CONFIG_MCUX_COMPONENT_* lines assign non-prompted, select-only symbols
AO prioritySize it to actual urgency — an AO with no consumers and no blocking work belongs near the bottom
Card detectDebounce and re-read the pin. Do not act on the ISR-reported level

Summary

What works now: the SdCard AO mounts and unmounts reliably across repeated insert/remove cycles, a failed init is reported once instead of livelocking, and the display no longer starves. What is still open: the 100 nF cap on the card-detect net is a schematic change for the next board spin, not something software can do.

Three things I would carry to any board:

  1. The interrupt tells you when to look, not what is true. On a coupled or bouncing line, debounce and re-read the hardware.
  2. A self-induced action can generate its own spurious interrupts. Power-cycling the card glitched the very line that reports the card. Re-sync and drain afterwards.
  3. Match the SDK structure, but not blindly. The SDK examples do not debounce because their reference boards do not have this RESET_B-to-CD_B coupling. Board-specific hardware realities need board-specific software on top of the SDK pattern.

And the one that cost the most time: fix all the root causes at once. Each of the four measures addresses a different failure. Removing any one of them brought a symptom back.


💬 Questions, corrections, or your own war story with card detect? Leave a comment below — I read and answer all of them.