Non-reproducible west build output for identical source/Kconfig on nRF54LM20A (NCS v3.4.0)

Hello,

We are seeing a west build reproducibility issue on nRF Connect SDK v3.4.0 that we have narrowed down, through extensive elimination, to the compiler's code generation for a specific, simple C expression — and this difference has a measurable, repeatable effect on idle current consumption (roughly +10 to +16 µA, i.e. a 50–70% increase from our ~20–22 µA baseline). We are not asking for consumption-tuning help; we are asking about build determinism, because we can no longer safely rebuild our firmware from source without risking an unexplained regression that we cannot detect except by physically re-measuring every unit.

Environment

  • SoC / board: nRF54LM20A, Seeed Studio XIAO Nordic-Support-Ticket-2026-09-19.zip(xiao_nrf54lm20a/nrf54lm20a/cpuapp; board files from Seeed's platform-seeedboards repo via -DBOARD_ROOT, since the board is not yet in-tree for this NCS version)
  • NCS version: v3.4.0 (LTS)
  • Toolchain: NCS Toolchain Manager, toolchain hash dcbdc366a1; arm-zephyr-eabi-gcc 14.3.0
  • Build invocation (unchanged from our documented standard procedure):
    west build -b xiao_nrf54lm20a/nrf54lm20a/cpuapp -d build --pristine -- -DBOARD_ROOT="<path>/platform-seeedboards/zephyr"
    
  • Application: a small battery-powered BLE broadcaster (BTHome v2) with periodic IMU sampling once per second, System ON IDLE architecture (GRTC + retained RAM + BLE + nordic,npm1300 PMIC/regulator). CONFIG_PM=y, CONFIG_PM_DEVICE=y, CONFIG_PM_DEVICE_RUNTIME=y, CONFIG_RAM_POWER_DOWN_LIBRARY=y, CONFIG_REGULATOR=y, CONFIG_REGULATOR_NPM13XX=y, CONFIG_LSM6DSL=y. No custom Kconfig fragments beyond a single project prj.conf.

What we observe

We have a reference binary (zephyr.bin, 117396 bytes) that was built from a known-good commit of our application and physically verified on hardware with a Nordic PPK2 at ~20–23 µA average current (60–90 s capture windows, System ON IDLE idle current between periodic ~40 ms IMU sampling bursts).

Rebuilding the exact same committed source (byte-for-byte diffed, confirmed identical) with the exact same prj.conf/board overlay/devicetree (also byte-for-byte identical) produces a zephyr.bin of 117428 bytes (32 bytes larger) which measures ~30–38 µA average on the same physical unit, using the same PPK2 session, immediately before/after reflashing the reference binary (which reliably returns to ~20–23 µA). This rules out the measurement setup and the specific hardware unit as variables.

Elimination performed before reaching out

We did not want to raise this without first ruling out everything under our own control:

  1. Application source: confirmed byte-for-byte identical (diffed against the archived source of the reference build).
  2. Kconfig resolution: confirmed byte-for-byte identical — full autoconf.h (723 lines) diffed against an intermediate build artifact preserved from the same period as the reference build; zero differences.
  3. west/toolchain module pinning: zephyr, nrf, and modules/hal/nordic are all pinned (per west.yml) to commits that predate the reference build by weeks; confirmed via git log on each module checkout. No west update occurred in between.
  4. Toolchain binaries: arm-zephyr-eabi-gcc.exe/ld.exe/ar.exe file modification timestamps predate the reference build (original toolchain install date); untouched since.
  5. ccache: rebuilt with CCACHE_DISABLE=1 and with ccache enabled (default) — byte-identical output either way.
  6. Shell/invocation: rebuilt via both Git Bash and native PowerShell with identical environment variables — byte-identical output.
  7. -DNCS_TOOLCHAIN_VERSION and ZEPHYR_TOOLCHAIN_VARIANT (tested both zephyr and the documented zephyr/gnu value from the toolchain's own environment.json) — no effect on output in either case.
  8. Build determinism: rebuilt the same source/config three separate times today (varying the above knobs); all three produced a byte-identical zephyr.bin. The regression is fully deterministic and reproducible today — it just does not match what was archived weeks ago from (as far as we can tell) an equivalent build.
  9. PMIC/hardware state: full power removal (several minutes, both USB and PPK2 disconnected) before re-measurement did not change the result, and — as noted above — reflashing the archived reference binary onto the same unit immediately restores the ~20–23 µA reading.

Binary-level finding

Comparing the two .bin files directly:

  • .text is byte-for-byte identical from the start of .text up to a specific point inside main().
  • From that point on, main() (with sample_motion() inlined into it by -Os) differs in actual instruction content — not just relocated addresses. Everything compiled after main() in link order (SDK/driver code, the interrupt vector table, a regulator_driver_api STRUCT_SECTION_ITERABLE list immediately before .rodata) realigns cleanly with a constant +32-byte address offset, consistent with main() alone being 32 bytes larger in the "bad" build.
  • Using the DWARF debug info (-g -gdwarf-4) via addr2line, we mapped the first genuinely divergent instructions to this source line inside sample_motion():
    out->angle_crossed =
        (abs(out->pitch_dd - retained.last_sent_pitch_dd) > ANGLE_HYSTERESIS_DD) ||
        (abs(out->roll_dd  - retained.last_sent_roll_dd)  > ANGLE_HYSTERESIS_DD);
    
    (pitch_dd/roll_dd/last_sent_pitch_dd/last_sent_roll_dd are all int16_t; ANGLE_HYSTERESIS_DD is a small integer constant.)
  • For this identical line, the "good" binary encodes a compact ite/predicated-move sequence (3 instructions); the "bad" binary encodes a bgt.w branch followed by roughly 9 additional instructions (loads/stores/byte extraction) for what should be equivalent logic.
  • This function runs unconditionally on every iteration of our main loop (once per second, indefinitely), so this is squarely in the always-executed duty-cycle path, not a rarely-hit branch — which is consistent with the magnitude of the measured current increase.
  • As a control, we tried rewriting the same comparison using explicit intermediate variables (int16_t dpitch = ...; int16_t droll = ...; before the comparison) to see if it would nudge GCC toward the compact form. It did not — it produced an even larger encoding (117444 bytes). This suggests the code-generation choice here is sensitive to compiler-internal state/context that isn't straightforwardly controllable from equivalent C source.

Our questions

  1. Is there a known issue with arm-zephyr-eabi-gcc 14.3.0 (as shipped in toolchain dcbdc366a1) around non-deterministic or context-sensitive instruction selection (if-conversion / Thumb-2 IT-block usage) for otherwise-identical comparison expressions, across builds where nothing we control appears to differ?
  2. Is west build/this toolchain expected to produce bit-reproducible output for identical source + Kconfig + devicetree, given our elimination above? If not, is there a documented, Nordic-recommended way to guarantee reproducible builds (we found a DevZone thread referencing -DNCS_TOOLCHAIN_VERSION and Docker-based builds for this exact class of problem, but that specific flag had no effect in our case)?
  3. Does NCS v3.4.1 (released 2026-09-17) contain any toolchain, GCC, or build-system changes relevant to build reproducibility that might explain or resolve this?
  4. Practically: is there a supported way to verify, before flashing, whether a given west build output will match a previously-verified reference — beyond physically re-measuring current on hardware every time?

We can provide both .bin/.hex files, the full compile_commands.json entry for the affected file, and the complete autoconf.h from both builds if that would help.

Thank you for your time — happy to jump on a call if that's easier than back-and-forth here.

Best regards, Thiery

Related