Fix nRF52 Bluetooth DFU stack handoff

This commit is contained in:
mikecarper committed 2026-10-01 16:15:59 -07:00
1 parent 3e3c4bdd4e
commit 65a8cfd721
5 files changed
+483

No files matched your search

+3
View File
@@ -194,10 +194,13 @@ jobs:
- name: Verify nRF52 SoftDevice LTO and persistent BLE subscriptions
env:
MESHCORE_REQUIRE_ARM_COMPILER: '1'
MESHCORE_REQUIRE_DFU_EMULATOR: '1'
run: |
pio pkg install --global --tool 'platformio/toolchain-gccarmnoneeabi@1.140201.0'
python3 -B test/test_nrf52_svc_compat.py -v
python3 -B test/test_nrf52_ble_cccd_fix.py -v
pip install unicorn==2.1.4
python3 -B test/test_nrf52_ble_dfu_fix.py -v
- name: Verify WiFi, ESP-NOW and combined wireless controls
run: |
+2
View File
@@ -156,10 +156,12 @@ extra_scripts = create-uf2.py
pre:scripts/nrf52_usb_power_fix.py
pre:scripts/nrf52_internal_flash_fix.py
pre:scripts/nrf52_ble_cccd_fix.py
pre:scripts/nrf52_ble_dfu_fix.py
pre:scripts/nrf52_internal_bootloader_link.py
; Nordic's builder otherwise overwrites the LTO-aware archive tools.
pre:scripts/nrf52_lto_toolchain.py
post:scripts/check_firmware_ram.py
post:scripts/check_nrf52_ble_dfu_handoff.py
; Nordic's framework defaults to -Ofast. Prefer the size-oriented profile for
; nRF52 images, where a smaller binary preserves OTA and filesystem headroom.
build_unflags =
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Reject nRF52 ELFs with an unsafe compiled buttonless Bluetooth DFU handoff."""
import argparse
from pathlib import Path
import struct
import sys
SCRIPT_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path("scripts").resolve()
sys.path.insert(0, str(SCRIPT_DIR))
from firmware_elf import FirmwareElf
HELPER_NAME = "mesh_nrf52_dfu_jump"
# Assembled Cortex-M4/Thumb helper. These instructions are entirely local: no
# relocation or compiler-generated C stack access can change with image layout.
# The only post-transition stack writes construct the explicit handler-mode
# exception frame. The normal FreeRTOS thread path performs no stack access.
HELPER_CODE = bytes.fromhex(
"72b6eff305830168426881f30888002080f3148880f3118880f31388"
"bff34f8fbff36f8f002b03d16ff0000e62b6104788b0009001900290"
"039004906ff00000059006924ff0045007906ff0060e62b67047"
)
def branch_targets(code, start):
"""Decode direct Thumb-2 BL/B.W targets without depending on objdump."""
for offset in range(0, len(code) - 3, 2):
first, second = struct.unpack_from("<HH", code, offset)
if first & 0xF800 != 0xF000 or second & 0xD000 not in (0x9000, 0xD000):
continue
sign = (first >> 10) & 1
i1 = 1 ^ ((second >> 13) & 1) ^ sign
i2 = 1 ^ ((second >> 11) & 1) ^ sign
immediate = (sign << 24) | (i1 << 23) | (i2 << 22)
immediate |= (first & 0x3FF) << 12 | (second & 0x7FF) << 1
if sign:
immediate -= 1 << 25
yield start + offset + 4 + immediate
def writes_control(code):
for offset in range(0, len(code) - 3, 2):
first, second = struct.unpack_from("<HH", code, offset)
if first & 0xFFF0 == 0xF380 and second == 0x8814:
return True
return False
def check_firmware(path):
elf = FirmwareElf(path)
callbacks = [name for name in elf.symbols
if "bledfu_control_wr_authorize_cb" in name]
if not callbacks and HELPER_NAME not in elf.symbols:
return False # A firmware without the DFU service has no handoff to gate.
if HELPER_NAME not in elf.symbols:
raise ValueError("Bluetooth DFU callback lacks the safe stack-transition helper")
address, size, _ = elf.symbols[HELPER_NAME]
address &= ~1 # ELF Thumb function values carry the low bit.
if size != len(HELPER_CODE) or elf.read(address, size) != HELPER_CODE:
raise ValueError("Bluetooth DFU helper differs from the qualified assembly")
if not callbacks:
raise ValueError("Bluetooth DFU helper is present without its control callback")
for name in callbacks:
start, size, _ = elf.symbols[name]
start &= ~1
if not size:
raise ValueError("Bluetooth DFU callback has no inspectable machine code")
code = elf.read(start, size)
if writes_control(code):
raise ValueError("Bluetooth DFU callback changes CONTROL inside a C stack frame")
if address not in branch_targets(code, start):
raise ValueError("Bluetooth DFU callback does not branch to the safe helper")
return True
def register_platformio(env):
def check(source, target, env):
path = Path(env.subst("$BUILD_DIR/${PROGNAME}.elf"))
try:
enabled = check_firmware(path)
except (OSError, ValueError, KeyError, struct.error) as error:
print(f"nRF52 Bluetooth DFU qualification failed: {error}", file=sys.stderr)
return 2
print("nRF52 Bluetooth DFU: " + ("safe compiled handoff PASS" if enabled
else "service not linked"))
return 0
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", check)
for alias in ("checkprogsize", "upload"):
env.AddPreAction(env.Alias(alias), check)
for suffix in ("bin", "hex", "uf2", "zip"):
env.AddPreAction("$BUILD_DIR/${PROGNAME}." + suffix, check)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("elf", type=Path)
args = parser.parse_args()
try:
enabled = check_firmware(args.elf)
except (OSError, ValueError, KeyError, struct.error) as error:
parser.exit(1, f"nRF52 Bluetooth DFU qualification failed: {error}\n")
print("nRF52 Bluetooth DFU: " + ("safe compiled handoff PASS" if enabled
else "service not linked"))
try:
Import("env") # noqa: F821 -- PlatformIO/SCons
except NameError:
if __name__ == "__main__":
main()
else:
register_platformio(env) # noqa: F821
+119
View File
@@ -0,0 +1,119 @@
"""Give the pinned Bluefruit DFU service a safe FreeRTOS bootloader handoff.
The original callback selects MSP while its C frame is still on PSP. GCC 14
then emits a stack restore on MSP before reaching the bootloader jump, outside
the nRF52840 SRAM boundary. Keep the legacy B1/retained-peer protocol, but move
the stack transition into a naked, nonreturning assembly helper. Patch a private
build copy; never modify PlatformIO's shared framework cache.
"""
from pathlib import Path
OLD_HELPER = 'extern "C" void bootloader_util_app_start(uint32_t start_addr);\n'
HELPER_NAME = "mesh_nrf52_dfu_jump"
HELPER = r'''// Keep all stack operations inside this assembly-only transition. In particular,
// the callback must return neither through its PSP frame nor through a C helper
// after CONTROL selects MSP. Preserve handler-mode entry supported by BLEDfu.
extern "C" __attribute__((naked, noinline, noreturn, used))
void mesh_nrf52_dfu_jump(uint32_t start_addr)
{
__asm volatile (
"cpsid i\n"
"mrs r3, ipsr\n"
"ldr r1, [r0]\n"
"ldr r2, [r0, #4]\n"
"msr msp, r1\n"
"movs r0, #0\n"
"msr control, r0\n"
"msr basepri, r0\n"
"msr faultmask, r0\n"
"dsb\n"
"isb\n"
"cmp r3, #0\n"
"bne 1f\n"
"mvn lr, #0\n"
"cpsie i\n"
"bx r2\n"
"1:\n"
"sub sp, sp, #32\n"
"str r0, [sp, #0]\n"
"str r0, [sp, #4]\n"
"str r0, [sp, #8]\n"
"str r0, [sp, #12]\n"
"str r0, [sp, #16]\n"
"mvn r0, #0\n"
"str r0, [sp, #20]\n"
"str r2, [sp, #24]\n"
"mov r0, #0x21000000\n"
"str r0, [sp, #28]\n"
"mvn lr, #6\n"
"cpsie i\n"
"bx lr\n"
);
}
'''
OLD_INTERRUPTS = ''' // Disable all interrupts
NVIC->ICER[0]=0xFFFFFFFF;
NVIC->ICPR[0]=0xFFFFFFFF;
#if defined(__NRF_NVIC_ISER_COUNT) && __NRF_NVIC_ISER_COUNT == 2
NVIC->ICER[1]=0xFFFFFFFF;
NVIC->ICPR[1]=0xFFFFFFFF;
#endif
'''
INTERRUPTS = ''' // nRF52 has external IRQs in both banks. FreeRTOS system exceptions
// need separate cleanup: they are not covered by NVIC->ICER/ICPR.
SysTick->CTRL = 0;
SysTick->LOAD = 0;
SysTick->VAL = 0;
SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk | SCB_ICSR_PENDSVCLR_Msk;
NVIC->ICER[0] = 0xFFFFFFFF;
NVIC->ICER[1] = 0xFFFFFFFF;
NVIC->ICPR[0] = 0xFFFFFFFF;
NVIC->ICPR[1] = 0xFFFFFFFF;
__DSB();
__ISB();
'''
OLD_JUMP = ''' __set_CONTROL(0); // switch to MSP, required if using FreeRTOS
bootloader_util_app_start(NRF_UICR->NRFFW[0]);
'''
JUMP = ''' // The vector-table SVC above must complete before the helper masks
// interrupts. B1 and the retained peer record preserve legacy DFU entry.
mesh_nrf52_dfu_jump(NRF_UICR->NRFFW[0]);
'''
def patched_source(source):
source = source.replace("\r\n", "\n")
replacements = ((OLD_HELPER, HELPER), (OLD_INTERRUPTS, INTERRUPTS),
(OLD_JUMP, JUMP))
if all(new in source and old not in source for old, new in replacements):
return source
if any(source.count(old) != 1 for old, _ in replacements):
raise RuntimeError(
"nRF52 BLE DFU handoff: unrecognized framework source; "
"review the framework update before building"
)
for old, new in replacements:
source = source.replace(old, new)
return source
def replace_framework_source(build_env, node):
source = Path(node.srcnode().get_abspath())
if source.name != "BLEDfu.cpp" or source.parent.name != "services":
return node
patched = patched_source(source.read_text(encoding="utf-8"))
build_env.AppendUnique(CPPPATH=[str(source.parent), str(source.parent.parent)])
destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-ble" / source.name
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists() or destination.read_text(encoding="utf-8") != patched:
destination.write_text(patched, encoding="utf-8")
print("nRF52 BLE: private FreeRTOS-safe DFU handoff")
return build_env.File(str(destination))
if "Import" in globals():
Import("env")
env.AddBuildMiddleware(replace_framework_source, "*BLEDfu.cpp")
+244
View File
@@ -0,0 +1,244 @@
"""Compile and execute the Bluetooth DFU stack-transition regression."""
from importlib.util import module_from_spec, spec_from_file_location
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from check_nrf52_ble_dfu_handoff import (HELPER_CODE, HELPER_NAME, check_firmware,
writes_control)
from firmware_elf import FirmwareElf
spec = spec_from_file_location("nrf52_ble_dfu_fix", ROOT / "scripts/nrf52_ble_dfu_fix.py")
patch = module_from_spec(spec)
spec.loader.exec_module(patch)
try:
import unicorn
from unicorn import arm_const
except ImportError:
unicorn = None
FIXTURE_BODY = r'''
extern "C" void bledfu_control_wr_authorize_cb_fixture(uint32_t address) {
volatile uint32_t frame[26];
// The real callback saves these registers while preparing peer/bond data.
// Keep that C epilogue so the old path reproduces its invalid MSP pop.
__asm volatile ("" ::: "r4", "r5", "r6", "r7", "r8");
frame[0] = address;
uint32_t bootloader = frame[0];
HANDOFF
}
'''
BROKEN_HELPER = r'''
extern "C" __attribute__((noinline, used))
void bootloader_util_app_start(uint32_t address) {
__asm volatile ("ldr r1, [%0]\n"
"msr msp, r1\n"
"ldr r1, [%0, #4]\n"
"bx r1\n" :: "r" (address) : "r1");
}
'''
def arm_compiler():
core = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio"))
candidates = [os.environ.get("MESHCORE_ARM_GXX", ""),
str(core / "packages/toolchain-gccarmnoneeabi/bin/arm-none-eabi-g++"),
str(Path.home() / ".local/opt/arm-gnu-toolchain-14.2.rel1-x86_64-arm-none-eabi/bin/arm-none-eabi-g++"),
shutil.which("arm-none-eabi-g++") or ""]
return next((candidate for candidate in candidates if candidate and Path(candidate).is_file()), None)
class DfuSourcePatchTest(unittest.TestCase):
def fixture(self):
return (patch.OLD_HELPER + "// retained peer preparation and B1 stay here\n"
+ patch.OLD_INTERRUPTS
+ " VERIFY_STATUS( sd_softdevice_vector_table_base_set(NRF_UICR->NRFFW[0]), );\n"
+ patch.OLD_JUMP)
def test_patch_is_idempotent_and_preserves_handoff_protocol(self):
fixed = patch.patched_source(self.fixture())
self.assertEqual(patch.patched_source(fixed), fixed)
self.assertIn("retained peer preparation and B1 stay here", fixed)
self.assertNotIn("__set_CONTROL(0)", fixed)
self.assertIn("sd_softdevice_vector_table_base_set", fixed)
self.assertLess(fixed.index("NVIC->ICER[1]"),
fixed.index("sd_softdevice_vector_table_base_set"))
self.assertLess(fixed.index("SCB_ICSR_PENDSVCLR_Msk"),
fixed.index("sd_softdevice_vector_table_base_set"))
self.assertLess(fixed.index("sd_softdevice_vector_table_base_set"),
fixed.index("mesh_nrf52_dfu_jump(NRF_UICR"))
def test_unknown_or_partially_patched_framework_fails_closed(self):
for original in ("unrecognized framework", self.fixture().replace(
patch.OLD_JUMP, patch.JUMP), self.fixture().replace(
"NVIC->ICPR[0]=0xFFFFFFFF;", "NVIC->ICPR[0]=0;")):
with self.subTest(original=original), self.assertRaises(RuntimeError):
patch.patched_source(original)
def test_every_nrf52_build_uses_patch_and_machine_gate(self):
ini = (ROOT / "platformio.ini").read_text()
base = ini.split("[nrf52_base]", 1)[1].split("\n[", 1)[0]
self.assertIn("pre:scripts/nrf52_ble_dfu_fix.py", base)
self.assertIn("post:scripts/check_nrf52_ble_dfu_handoff.py", base)
class DfuCompiledHandoffTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = arm_compiler()
if cls.compiler is None:
if os.environ.get("MESHCORE_REQUIRE_ARM_COMPILER") == "1":
raise RuntimeError("ARM compiler required for the DFU regression")
raise unittest.SkipTest("ARM compiler required for the DFU regression")
cls.folder = tempfile.TemporaryDirectory(prefix="meshcore-dfu-handoff-")
cls.path = Path(cls.folder.name)
cls.images = {}
for optimize, lto in (("-Os", False), ("-Oz", False), ("-Oz", True)):
for fixed in (False, True):
name = f"{'fixed' if fixed else 'broken'}-{optimize[1:]}-{'lto' if lto else 'plain'}"
source, elf = cls.path / (name + ".cpp"), cls.path / (name + ".elf")
handoff = ("mesh_nrf52_dfu_jump(bootloader);" if fixed else
'__asm volatile ("movs r1, #0\\nmsr control, r1\\n" ::: "r1");\n'
" bootloader_util_app_start(bootloader);")
source.write_text("#include <stdint.h>\n" + (patch.HELPER if fixed else BROKEN_HELPER)
+ FIXTURE_BODY.replace("HANDOFF", handoff))
command = [cls.compiler, "-mcpu=cortex-m4", "-mthumb", optimize,
"-ffunction-sections", "-fdata-sections", "-nostdlib",
"-Wl,--entry=bledfu_control_wr_authorize_cb_fixture,-Ttext=0x26000",
str(source), "-o", str(elf)]
if lto:
command[1:1] = ["-flto", "-fipa-pta", "-fno-inline-small-functions"]
subprocess.run(command, check=True, capture_output=True, text=True)
cls.images[(optimize, lto, fixed)] = elf
@classmethod
def tearDownClass(cls):
cls.folder.cleanup()
def test_machine_gate_accepts_fixed_and_rejects_old_release_sequence(self):
for key, path in self.images.items():
with self.subTest(build=key):
if key[2]:
self.assertTrue(check_firmware(path))
else:
with self.assertRaisesRegex(ValueError, "safe stack-transition helper"):
check_firmware(path)
elf = FirmwareElf(path)
address, size, _ = elf.symbols["bledfu_control_wr_authorize_cb_fixture"]
self.assertTrue(writes_control(elf.read(address & ~1, size)))
def test_machine_gate_rejects_helper_prologue_or_modified_instruction(self):
path = self.images[("-Oz", True, True)]
original = path.read_bytes()
self.assertEqual(original.count(HELPER_CODE), 1)
damaged = self.path / "changed-helper.elf"
damaged.write_bytes(original.replace(HELPER_CODE, b"\x00\xb5" + HELPER_CODE[2:]))
with self.assertRaisesRegex(ValueError, "qualified assembly"):
check_firmware(damaged)
def test_machine_gate_rejects_control_write_in_callback_with_valid_helper(self):
path = self.images[("-Oz", True, True)]
elf = FirmwareElf(path)
address, size, _ = elf.symbols["bledfu_control_wr_authorize_cb_fixture"]
code = elf.read(address & ~1, size)
original = path.read_bytes()
self.assertEqual(original.count(code), 1)
damaged = self.path / "changed-callback.elf"
damaged.write_bytes(original.replace(code, b"\x80\xf3\x14\x88" + code[4:]))
with self.assertRaisesRegex(ValueError, "inside a C stack frame"):
check_firmware(damaged)
def test_machine_gate_rejects_callback_which_bypasses_valid_helper(self):
path = self.images[("-Oz", True, True)]
elf = FirmwareElf(path)
address, size, _ = elf.symbols["bledfu_control_wr_authorize_cb_fixture"]
code = elf.read(address & ~1, size)
original = path.read_bytes()
self.assertEqual(original.count(code), 1)
damaged = self.path / "bypassed-helper.elf"
# BL to the following instruction replaces the final nonreturning call.
damaged.write_bytes(original.replace(code, code[:-4] + b"\x00\xf0\x00\xf8"))
with self.assertRaisesRegex(ValueError, "does not branch"):
check_firmware(damaged)
def emulate(self, path, psp=True, handler=False):
if unicorn is None:
if os.environ.get("MESHCORE_REQUIRE_DFU_EMULATOR") == "1":
self.fail("Unicorn required for the Cortex-M DFU regression")
self.skipTest("Unicorn required for the Cortex-M DFU regression")
elf = FirmwareElf(path)
machine = unicorn.Uc(unicorn.UC_ARCH_ARM, unicorn.UC_MODE_THUMB | unicorn.UC_MODE_MCLASS)
machine.mem_map(0x20000, 0xE0000)
machine.mem_map(0x20000000, 0x40000)
for section in elf.sections:
if section[1] != 8 and section[2] & 4 and section[5]:
machine.mem_write(section[3], elf.section_bytes(section))
machine.mem_write(0xF4000, struct.pack("<II", 0x20030000, 0xF4101))
peer = bytes(range(62))
machine.mem_write(0x20007F80, peer)
reg = arm_const
machine.reg_write(reg.UC_ARM_REG_MSP, 0x20040000)
machine.reg_write(reg.UC_ARM_REG_PSP, 0x20002000)
machine.reg_write(reg.UC_ARM_REG_CONTROL, 2 if psp else 0)
machine.reg_write(reg.UC_ARM_REG_BASEPRI, 0x80)
machine.reg_write(reg.UC_ARM_REG_FAULTMASK, 1)
machine.reg_write(reg.UC_ARM_REG_R0, 0xF4000)
if handler:
machine.reg_write(reg.UC_ARM_REG_IPSR, 16)
entry = elf.address(HELPER_NAME)
else:
entry = elf.address("bledfu_control_wr_authorize_cb_fixture")
stop = ((elf.address(HELPER_NAME) & ~1) + len(HELPER_CODE) - 2
if handler else 0xF4100)
def stop_at_branch(uc, address, size, context):
if address == stop:
uc.emu_stop()
machine.hook_add(unicorn.UC_HOOK_CODE, stop_at_branch)
machine.emu_start(entry | 1, 0, count=100)
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_PC), stop)
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_CONTROL), 0)
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_PRIMASK), 0)
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_BASEPRI), 0)
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_FAULTMASK), 0)
self.assertEqual(bytes(machine.mem_read(0x20007F80, 62)), peer)
return machine
def test_freertos_psp_and_msp_callers_reach_bootloader_without_fault(self):
for key, path in self.images.items():
if not key[2]:
continue
for psp in (False, True):
with self.subTest(build=key, psp=psp):
machine = self.emulate(path, psp=psp)
self.assertEqual(machine.reg_read(arm_const.UC_ARM_REG_MSP), 0x20030000)
def test_original_callback_faults_outside_sram_before_bootloader(self):
if unicorn is None:
self.skipTest("Unicorn required for the Cortex-M DFU regression")
for key, path in self.images.items():
if not key[2]:
with self.subTest(build=key), self.assertRaises(unicorn.UcError):
self.emulate(path)
def test_handler_path_constructs_valid_exception_return_frame(self):
machine = self.emulate(self.images[("-Oz", True, True)], handler=True)
reg = arm_const
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_MSP), 0x20030000 - 32)
self.assertEqual(machine.reg_read(reg.UC_ARM_REG_LR), 0xFFFFFFF9)
self.assertEqual(struct.unpack("<8I", machine.mem_read(0x20030000 - 32, 32)),
(0, 0, 0, 0, 0, 0xFFFFFFFF, 0xF4101, 0x21000000))
if __name__ == "__main__":
unittest.main()