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'splatform-seeedboardsrepo 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-gcc14.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,npm1300PMIC/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 projectprj.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:
- Application source: confirmed byte-for-byte identical (diffed against the archived source of the reference build).
- 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. west/toolchain module pinning:zephyr,nrf, andmodules/hal/nordicare all pinned (perwest.yml) to commits that predate the reference build by weeks; confirmed viagit logon each module checkout. Nowest updateoccurred in between.- Toolchain binaries:
arm-zephyr-eabi-gcc.exe/ld.exe/ar.exefile modification timestamps predate the reference build (original toolchain install date); untouched since. ccache: rebuilt withCCACHE_DISABLE=1and with ccache enabled (default) — byte-identical output either way.- Shell/invocation: rebuilt via both Git Bash and native PowerShell with identical environment variables — byte-identical output.
-DNCS_TOOLCHAIN_VERSIONandZEPHYR_TOOLCHAIN_VARIANT(tested bothzephyrand the documentedzephyr/gnuvalue from the toolchain's ownenvironment.json) — no effect on output in either case.- 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. - 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:
.textis byte-for-byte identical from the start of.textup to a specific point insidemain().- From that point on,
main()(withsample_motion()inlined into it by-Os) differs in actual instruction content — not just relocated addresses. Everything compiled aftermain()in link order (SDK/driver code, the interrupt vector table, aregulator_driver_apiSTRUCT_SECTION_ITERABLElist immediately before.rodata) realigns cleanly with a constant +32-byte address offset, consistent withmain()alone being 32 bytes larger in the "bad" build. - Using the DWARF debug info (
-g -gdwarf-4) viaaddr2line, we mapped the first genuinely divergent instructions to this source line insidesample_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_ddare allint16_t;ANGLE_HYSTERESIS_DDis a small integer constant.) - For this identical line, the "good" binary encodes a compact
ite/predicated-move sequence (3 instructions); the "bad" binary encodes abgt.wbranch 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
- Is there a known issue with
arm-zephyr-eabi-gcc14.3.0 (as shipped in toolchaindcbdc366a1) 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? - 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_VERSIONand Docker-based builds for this exact class of problem, but that specific flag had no effect in our case)? - 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?
- Practically: is there a supported way to verify, before flashing, whether a given
west buildoutput 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