Subject: Non-reproducible `west build` output for identical source/Kconfig on nRF54LM20A (NCS v3.4.0) — measurable idle-current regression traced to a specific compiled function

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 nRF54LM20A Sense (`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 — see attached `reference-good-23uA/`) 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 — see attached `rebuild-bad-33uA/`) 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. (Attached: `kconfig-comparison/`.)
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() (see attached `disassembly-evidence/source_snippet_sample_motion.c` and `disassembly-evidence/addr2line_mapping.txt`):

    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. Full disassembly of the affected function is attached (`disassembly-evidence/main_function_disasm_rebuild.txt`).
- 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?

Attached: reference-good-23uA/ (verified-good .bin/.hex), rebuild-bad-33uA/ (rebuilt .bin/.hex/.elf, autoconf.h, and the exact compile_commands.json entry for main.c), kconfig-comparison/ (two full autoconf.h files proving identical Kconfig resolution), disassembly-evidence/ (source snippet, full disassembly of the affected function, and addr2line mapping).

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

Best regards,
Thiery
