diff --git a/pygbl/elf.py b/pygbl/elf.py index 77996e3..53df6cf 100644 --- a/pygbl/elf.py +++ b/pygbl/elf.py @@ -30,6 +30,9 @@ GBL3_SPEC_VERSION = 3 +ERASED_FLASH = b"\xff" +MAX_IMAGE_SIZE = 100 * 1024 * 1024 + @dataclasses.dataclass(frozen=True) class LoadableSegment: @@ -62,6 +65,26 @@ def _loadable_segments(elf: ELFFile) -> list[LoadableSegment]: return merged +def _flatten(segments: list[LoadableSegment]) -> LoadableSegment: + """Collapse segments into the single run they occupy in flash.""" + address = min(segment.address for segment in segments) + end = max(segment.address + len(segment.data) for segment in segments) + + if end - address > MAX_IMAGE_SIZE: + raise ParseError( + f"Segments span {end - address} bytes, more than the" + f" {MAX_IMAGE_SIZE} byte limit" + ) + + image = bytearray(ERASED_FLASH * (end - address)) + + for segment in segments: + offset = segment.address - address + image[offset : offset + len(segment.data)] = segment.data + + return LoadableSegment(address=address, data=bytes(image)) + + def _application_properties(elf: ELFFile) -> bytes: symtab = elf.get_section_by_name(".symtab") @@ -151,14 +174,10 @@ def build_bootloader_gbl3( ) -> GBL3Image: """Build a bootloader GBL from a linked ELF.""" elf = ELFFile(elf_file) - segments = _loadable_segments(elf) - - if len(segments) != 1: - raise ParseError( - f"Expected one contiguous bootloader segment, got {len(segments)}" - ) - segment = segments[0] + # Unlike an application, a bootloader is programmed as one flash image, so the + # alignment holes between segments are filled rather than kept as separate runs. + segment = _flatten(_loadable_segments(elf)) tags: list[GBL3TagBase] = [ GBL3Header(version=GBL3_SPEC_VERSION, type=GBL3Type.NONE), GBL3Bootloader( diff --git a/tests/files/yellow_bootloader.out b/tests/files/yellow_bootloader.out new file mode 100755 index 0000000..60be935 Binary files /dev/null and b/tests/files/yellow_bootloader.out differ diff --git a/tests/files/yellow_bootloader_commander.gbl b/tests/files/yellow_bootloader_commander.gbl new file mode 100644 index 0000000..ff09590 Binary files /dev/null and b/tests/files/yellow_bootloader_commander.gbl differ diff --git a/tests/files/yellow_bootloader_metadata.json b/tests/files/yellow_bootloader_metadata.json new file mode 100644 index 0000000..6fbe52c --- /dev/null +++ b/tests/files/yellow_bootloader_metadata.json @@ -0,0 +1 @@ +{"baudrate": 115200, "fw_type": "gecko-bootloader", "fw_variant": null, "gecko_bootloader_version": "3.2.0", "metadata_version": 2, "sdk_version": "2026.6.1"} \ No newline at end of file diff --git a/tests/test_elf.py b/tests/test_elf.py index e0415a8..39be04c 100644 --- a/tests/test_elf.py +++ b/tests/test_elf.py @@ -37,7 +37,13 @@ read_encryption_key, read_loadable_segments, ) -from pygbl.elf import APP_PROPERTIES_MAGIC +from pygbl.elf import ( + APP_PROPERTIES_MAGIC, + ERASED_FLASH, + MAX_IMAGE_SIZE, + LoadableSegment, + _flatten, +) FILES = pathlib.Path(__file__).parent / "files" @@ -45,6 +51,9 @@ APPLICATION = "yellow_openthread_rcp" BOOTLOADER = "zbt2_bootloader" +# MG21 aligns `.text` past the 308 byte vector table, leaving a four byte hole +SPLIT_BOOTLOADER = "yellow_bootloader" + ELVES = sorted(p.stem for p in FILES.glob("*.out")) if not ELVES: @@ -238,11 +247,40 @@ def test_bootloader_payload_carries_its_own_crc() -> None: assert bootloader.address == segments[0].address -def test_bootloader_builder_rejects_split_images() -> None: - """An application ELF has several separate runs, so it is not a bootloader.""" - with elf_path(APPLICATION).open("rb") as f: - with pytest.raises(ParseError, match="one contiguous bootloader segment"): - build_bootloader_gbl3(f) +def test_bootloader_holes_are_filled_with_erased_flash() -> None: + """A bootloader is one flash image, so gaps between segments become `0xFF`.""" + with elf_path(SPLIT_BOOTLOADER).open("rb") as f: + segments = read_loadable_segments(f) + + with elf_path(SPLIT_BOOTLOADER).open("rb") as f: + bootloader = build_bootloader_gbl3(f).get_first_tag(GBL3Bootloader) + + assert len(segments) > 1 + + payload = bootloader.data[:-4] + hole = slice( + segments[0].address + len(segments[0].data) - bootloader.address, + segments[1].address - bootloader.address, + ) + + assert hole.stop > hole.start + assert payload[hole] == ERASED_FLASH * (hole.stop - hole.start) + + # Everything else is the segments themselves, laid down at their own addresses + for segment in segments: + offset = segment.address - bootloader.address + assert payload[offset : offset + len(segment.data)] == segment.data + + +def test_flatten_rejects_an_absurd_span() -> None: + """Bail out before allocating the buffer, not after.""" + segments = [ + LoadableSegment(address=0, data=b"\x00"), + LoadableSegment(address=MAX_IMAGE_SIZE, data=b"\x00"), + ] + + with pytest.raises(ParseError, match="more than the"): + _flatten(segments) def test_rejects_non_elf_input() -> None: