Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions pygbl/elf.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@

GBL3_SPEC_VERSION = 3

ERASED_FLASH = b"\xff"
MAX_IMAGE_SIZE = 100 * 1024 * 1024


@dataclasses.dataclass(frozen=True)
class LoadableSegment:
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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(
Expand Down
Binary file added tests/files/yellow_bootloader.out
Binary file not shown.
Binary file added tests/files/yellow_bootloader_commander.gbl
Binary file not shown.
1 change: 1 addition & 0 deletions tests/files/yellow_bootloader_metadata.json
Original file line number Diff line number Diff line change
@@ -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"}
50 changes: 44 additions & 6 deletions tests/test_elf.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,23 @@
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"

# Representatives for the format-level assertions
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:
Expand Down Expand Up @@ -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:
Expand Down