From 91db37489ac7e0725c44c1ce072487ef6cf2307e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 30 Sep 2026 23:01:37 -0700 Subject: [PATCH] Preserve SoftDevice memory side effects under nRF52 LTO --- scripts/nrf52_lto_toolchain.py | 8 +++ src/helpers/nrf52/SoftDeviceSvcCompat.h | 28 ++++++++++ test/test_nrf52_svc_compat.py | 74 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/helpers/nrf52/SoftDeviceSvcCompat.h create mode 100644 test/test_nrf52_svc_compat.py diff --git a/scripts/nrf52_lto_toolchain.py b/scripts/nrf52_lto_toolchain.py index 14f5c696..6b18e168 100644 --- a/scripts/nrf52_lto_toolchain.py +++ b/scripts/nrf52_lto_toolchain.py @@ -1,5 +1,13 @@ Import("env") +# Apply the SoftDevice ABI guard to framework code as well as the application. +# A normal application include cannot protect Bluefruit's pairing and GATT +# calls, which are compiled in separate framework translation units. +env.AppendUnique(CCFLAGS=[ + "-include", + env.subst("$PROJECT_DIR/src/helpers/nrf52/SoftDeviceSvcCompat.h"), +]) + # This script is loaded as a ``pre:`` extra script. The Nordic platform's # builder selects binutils ar/ranlib later while it creates the library build # actions. Those tools do not generate the plugin index required by GNU LTO, diff --git a/src/helpers/nrf52/SoftDeviceSvcCompat.h b/src/helpers/nrf52/SoftDeviceSvcCompat.h new file mode 100644 index 00000000..7d05f01c --- /dev/null +++ b/src/helpers/nrf52/SoftDeviceSvcCompat.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// Nordic's SDK SVC wrappers contain assembly without a memory clobber. With +// whole-program optimization, GCC can infer that the wrappers neither read +// nor write the structures passed to the SoftDevice. That assumption is false +// for GAP pairing keys, GATT notifications, and many other supervisor calls. +// Keep each SVC opaque to its callers and declare its memory side effects. +// This header is force-included in nRF52 C and C++ compilation, including the +// framework, before nrf_svc.h defines SVCALL. +#if defined(NRF52_PLATFORM) && defined(__GNUC__) \ + && !defined(SVCALL_AS_NORMAL_FUNCTION) && !defined(SVCALL) +#if __GNUC__ >= 9 +#define MESHCORE_SVC_ATTRIBUTES __attribute__((naked, unused, noipa)) +#else +#define MESHCORE_SVC_ATTRIBUTES __attribute__((naked, unused, noinline)) +#endif + +#define SVCALL(number, return_type, signature) \ + _Pragma("GCC diagnostic push") \ + _Pragma("GCC diagnostic ignored \"-Wreturn-type\"") \ + MESHCORE_SVC_ATTRIBUTES static return_type signature { \ + __asm volatile ("svc %0\n" "bx r14" : : "I" ((uint16_t)(number)) \ + : "r0", "memory"); \ + } \ + _Pragma("GCC diagnostic pop") +#endif diff --git a/test/test_nrf52_svc_compat.py b/test/test_nrf52_svc_compat.py new file mode 100644 index 00000000..a64a7515 --- /dev/null +++ b/test/test_nrf52_svc_compat.py @@ -0,0 +1,74 @@ +"""Regression for pointer side effects hidden by Nordic's SVC assembly.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HEADER = ROOT / "src/helpers/nrf52/SoftDeviceSvcCompat.h" +CORE = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio")) +BIN = CORE / "packages/toolchain-gccarmnoneeabi/bin" +PROBE = r''' +#include +SVCALL(0x80, uint32_t, system_call(uint32_t *value)); +__attribute__((used, noinline)) uint32_t read_output(void) { + uint32_t value = 0; + system_call(&value); + return value; +} +__attribute__((used, noinline)) uint32_t write_input(void) { + uint32_t value = 0x12345678; + return system_call(&value); +} +''' + + +class Nrf52SvcCompatTest(unittest.TestCase): + def test_framework_and_application_receive_the_guard(self): + script = (ROOT / "scripts/nrf52_lto_toolchain.py").read_text() + self.assertIn('env.AppendUnique(CCFLAGS=[', script) + self.assertIn('"-include"', script) + self.assertIn('SoftDeviceSvcCompat.h', script) + self.assertIn('pre:scripts/nrf52_lto_toolchain.py', + (ROOT / "platformio.ini").read_text()) + + def test_lto_preserves_supervisor_input_and_output_memory(self): + cc = shutil.which("arm-none-eabi-gcc") or str(BIN / "arm-none-eabi-gcc") + dump = shutil.which("arm-none-eabi-objdump") or str(BIN / "arm-none-eabi-objdump") + if not Path(cc).is_file() or not Path(dump).is_file(): + self.skipTest("ARM compiler required for the LTO regression") + with tempfile.TemporaryDirectory(prefix="meshcore-svc-lto-") as temp: + folder = Path(temp) + probe = folder / "probe.c" + probe.write_text(PROBE) + # Reproduce the SDK wrapper's missing IPA and memory protections. + broken = folder / "broken.h" + broken.write_text(HEADER.read_text().replace(", noipa", "") + .replace(', "memory"', "")) + assemblies = {} + for name, header in (("fixed", HEADER), ("broken", broken)): + elf = folder / (name + ".elf") + result = subprocess.run([ + cc, "-DNRF52_PLATFORM", "-include", str(header), + "-mcpu=cortex-m4", "-mthumb", "-Os", "-flto", + "-fipa-pta", "-fmerge-all-constants", "-nostdlib", + "-Wl,-e,read_output", str(probe), "-o", str(elf), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + assemblies[name] = subprocess.check_output([ + dump, "-d", "--disassemble=read_output", str(elf), + ], text=True) + # The broken wrapper returns a compile-time zero after the SVC; + # the fixed wrapper reloads the value written by the supervisor. + self.assertRegex(assemblies["fixed"], r"\bldr\s+r0,\s*\[sp") + self.assertNotRegex(assemblies["broken"], r"\bldr\s+r0,\s*\[sp") + self.assertRegex(assemblies["fixed"], r"\bstr\s+r\d+,\s*\[sp") + self.assertNotRegex(assemblies["broken"], r"\bstr\s+r\d+,\s*\[sp") + + +if __name__ == "__main__": + unittest.main()