From ceb21343920422029c4d2d4fc52130af20835c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sat, 22 Aug 2026 18:38:06 +0300 Subject: [PATCH] build(f722): centralize linker layout --- firmware/boards/geprc-f722-v2/build.rs | 10 +- firmware/boards/geprc-f722-v2/memory.x | 19 -- firmware/boards/kakute-f7-mini-v3/build.rs | 10 +- firmware/boards/kakute-f7-mini-v3/memory.x | 19 -- firmware/platforms/stm32f7/link/f722/memory.x | 33 +++ firmware/scripts/analyze-fw.py | 189 +++++++++++++++--- firmware/scripts/create-dfu-image.sh | 19 +- firmware/scripts/test-analyze-fw.py | 136 +++++++++++++ 8 files changed, 358 insertions(+), 77 deletions(-) delete mode 100644 firmware/boards/geprc-f722-v2/memory.x delete mode 100644 firmware/boards/kakute-f7-mini-v3/memory.x create mode 100644 firmware/platforms/stm32f7/link/f722/memory.x create mode 100644 firmware/scripts/test-analyze-fw.py diff --git a/firmware/boards/geprc-f722-v2/build.rs b/firmware/boards/geprc-f722-v2/build.rs index 51a6630..9c7b7fc 100644 --- a/firmware/boards/geprc-f722-v2/build.rs +++ b/firmware/boards/geprc-f722-v2/build.rs @@ -1,9 +1,11 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=memory.x"); - println!("cargo:rustc-link-arg=-Tlink.x"); + let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("../../platforms/stm32f7/link/f722"); println!( - "cargo:rustc-link-search={}", - std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR") + "cargo:rerun-if-changed={}", + layout.join("memory.x").display() ); + println!("cargo:rustc-link-arg=-Tlink.x"); + println!("cargo:rustc-link-search={}", layout.display()); } diff --git a/firmware/boards/geprc-f722-v2/memory.x b/firmware/boards/geprc-f722-v2/memory.x deleted file mode 100644 index 39d4362..0000000 --- a/firmware/boards/geprc-f722-v2/memory.x +++ /dev/null @@ -1,19 +0,0 @@ -MEMORY -{ - FLASH : ORIGIN = 0x08000000, LENGTH = 512K - /* - * STM32F722 RAM totals 256 KiB: - * - DTCM: 64 KiB at 0x2000_0000 - * - SRAM1: 176 KiB at 0x2001_0000 - * - SRAM2: 16 KiB at 0x2003_C000 - * - * SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2. - * They form one 192 KiB default RAM region from 0x2001_0000 through - * 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit - * placement policy rather than mixing CPU-critical and general allocations. - * - * TODO: Utilize DTCM. - * - */ - RAM : ORIGIN = 0x20010000, LENGTH = 192K -} diff --git a/firmware/boards/kakute-f7-mini-v3/build.rs b/firmware/boards/kakute-f7-mini-v3/build.rs index 51a6630..9c7b7fc 100644 --- a/firmware/boards/kakute-f7-mini-v3/build.rs +++ b/firmware/boards/kakute-f7-mini-v3/build.rs @@ -1,9 +1,11 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=memory.x"); - println!("cargo:rustc-link-arg=-Tlink.x"); + let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("../../platforms/stm32f7/link/f722"); println!( - "cargo:rustc-link-search={}", - std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR") + "cargo:rerun-if-changed={}", + layout.join("memory.x").display() ); + println!("cargo:rustc-link-arg=-Tlink.x"); + println!("cargo:rustc-link-search={}", layout.display()); } diff --git a/firmware/boards/kakute-f7-mini-v3/memory.x b/firmware/boards/kakute-f7-mini-v3/memory.x deleted file mode 100644 index 39d4362..0000000 --- a/firmware/boards/kakute-f7-mini-v3/memory.x +++ /dev/null @@ -1,19 +0,0 @@ -MEMORY -{ - FLASH : ORIGIN = 0x08000000, LENGTH = 512K - /* - * STM32F722 RAM totals 256 KiB: - * - DTCM: 64 KiB at 0x2000_0000 - * - SRAM1: 176 KiB at 0x2001_0000 - * - SRAM2: 16 KiB at 0x2003_C000 - * - * SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2. - * They form one 192 KiB default RAM region from 0x2001_0000 through - * 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit - * placement policy rather than mixing CPU-critical and general allocations. - * - * TODO: Utilize DTCM. - * - */ - RAM : ORIGIN = 0x20010000, LENGTH = 192K -} diff --git a/firmware/platforms/stm32f7/link/f722/memory.x b/firmware/platforms/stm32f7/link/f722/memory.x new file mode 100644 index 0000000..8089c5b --- /dev/null +++ b/firmware/platforms/stm32f7/link/f722/memory.x @@ -0,0 +1,33 @@ +MEMORY +{ + /* + * STM32F72xx flash sectors 0-5 form the 256 KiB application region. + * Sectors 6 and 7 are two independently erasable 128 KiB configuration + * journal slots at 0x0804_0000 and 0x0806_0000. They are deliberately not + * linker regions, so application sections cannot overlap persistent data. + */ + FLASH : ORIGIN = 0x08000000, LENGTH = 256K + + /* + * STM32F722 RAM totals 256 KiB: + * - DTCM: 64 KiB at 0x2000_0000 + * - SRAM1: 176 KiB at 0x2001_0000 + * - SRAM2: 16 KiB at 0x2003_C000 + * + * SRAM1 and SRAM2 are contiguous and DMA-accessible. DTCM remains separate + * for a future explicit placement policy. + */ + RAM : ORIGIN = 0x20010000, LENGTH = 192K +} + +/* Absolute symbols are the single source of truth for the Rust NOR adapter. */ +__kaptan_config_slot0_start = 0x08040000; +__kaptan_config_slot1_start = 0x08060000; +__kaptan_config_journal_end = 0x08080000; + +ASSERT(ORIGIN(FLASH) + LENGTH(FLASH) == __kaptan_config_slot0_start, + "application FLASH must end at configuration slot 0") +ASSERT(__kaptan_config_slot1_start - __kaptan_config_slot0_start == 128K, + "configuration slot 0 must be one F722 128 KiB sector") +ASSERT(__kaptan_config_journal_end - __kaptan_config_slot1_start == 128K, + "configuration slot 1 must be one F722 128 KiB sector") diff --git a/firmware/scripts/analyze-fw.py b/firmware/scripts/analyze-fw.py index b7ea454..e189369 100755 --- a/firmware/scripts/analyze-fw.py +++ b/firmware/scripts/analyze-fw.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -"""Show flash, static RAM and ELF-section use for embedded firmware. +"""Validate and show flash, static RAM and ELF-section use. Usage: scripts/analyze-fw.py """ +import json import re +from collections import namedtuple from pathlib import Path import subprocess import sys @@ -17,6 +19,11 @@ REGION = re.compile( re.MULTILINE, ) +Section = namedtuple("Section", "name size address allocated") +LoadSegment = namedtuple( + "LoadSegment", "virtual_address physical_address file_size memory_size" +) + def number(text): """Parse linker numbers such as 0x2001_0000, 176K, and 1M.""" @@ -30,18 +37,18 @@ def run(command): return subprocess.run(command, check=True, text=True, capture_output=True).stdout -def llvm_size(): - """Find the llvm-size installed by the active Rust toolchain.""" +def llvm_tool(name): + """Find one LLVM binary installed by the active Rust toolchain.""" sysroot = run(["rustc", "--print", "sysroot"]).strip() host = next( line.split(": ", 1)[1] for line in run(["rustc", "-vV"]).splitlines() if line.startswith("host: ") ) - tool = Path(sysroot) / "lib" / "rustlib" / host / "bin" / "llvm-size" + tool = Path(sysroot) / "lib" / "rustlib" / host / "bin" / name if not tool.is_file(): raise RuntimeError( - "llvm-size is unavailable; install it with: rustup component add llvm-tools-preview" + f"{name} is unavailable; install it with: rustup component add llvm-tools-preview" ) return tool @@ -54,14 +61,39 @@ def regions(memory_x): ] -def sections(output): - """Return llvm-size sections as (name, bytes, address).""" - result = [] - for line in output.splitlines(): - fields = line.split() - if len(fields) == 3 and fields[0].startswith("."): - result.append((fields[0], int(fields[1], 0), int(fields[2], 0))) - return result +def elf_layout(output): + """Return allocated-section and load-segment metadata from llvm-readobj JSON.""" + documents = json.loads(output) + if len(documents) != 1: + raise ValueError("llvm-readobj returned no unique ELF document") + document = documents[0] + + section_list = [] + for wrapped in document.get("Sections", []): + section = wrapped["Section"] + flags = {flag["Name"] for flag in section["Flags"]["Flags"]} + section_list.append( + Section( + section["Name"]["Name"], + section["Size"], + section["Address"], + "SHF_ALLOC" in flags, + ) + ) + + load_segments = [] + for wrapped in document.get("ProgramHeaders", []): + segment = wrapped["ProgramHeader"] + if segment["Type"]["Name"] == "PT_LOAD": + load_segments.append( + LoadSegment( + segment["VirtualAddress"], + segment["PhysicalAddress"], + segment["FileSize"], + segment["MemSize"], + ) + ) + return section_list, load_segments def region_for(address, layout): @@ -72,6 +104,88 @@ def region_for(address, layout): return "outside" +def region_for_span(address, size, layout): + """Return the one linker region containing the complete nonempty span.""" + end = address + size + for name, origin, length in layout: + if origin <= address and end <= origin + length: + return name + return None + + +def merged_span_size(spans): + """Return bytes covered by the union of half-open address spans.""" + merged = [] + for start, end in sorted(spans): + if not merged or start > merged[-1][1]: + merged.append([start, end]) + else: + merged[-1][1] = max(merged[-1][1], end) + return sum(end - start for start, end in merged) + + +def region_usage(name, layout, section_list, load_segments): + """Return runtime or load-image bytes occupying one linker region. + + Allocated-section VMAs account for runtime RAM, while PT_LOAD physical file + spans account for initializers stored in flash (notably a future `.data`). + Taking their union avoids counting ordinary flash sections twice. + """ + spans = [ + (section.address, section.address + section.size) + for section in section_list + if section.allocated + and section.size + and region_for_span(section.address, section.size, layout) == name + ] + spans.extend( + ( + segment.physical_address, + segment.physical_address + segment.file_size, + ) + for segment in load_segments + if segment.file_size + and region_for_span(segment.physical_address, segment.file_size, layout) == name + ) + return merged_span_size(spans) + + +def validation_errors(layout, section_list, load_segments): + """Return every placement error that makes a raw firmware image unsafe.""" + errors = [] + for section in section_list: + if not section.allocated or section.size == 0: + continue + region = region_for_span(section.address, section.size, layout) + if region is None: + errors.append( + f"allocated section {section.name} spans " + f"0x{section.address:08X}..0x{section.address + section.size:08X} " + "outside declared memory" + ) + + for index, segment in enumerate(load_segments): + if segment.memory_size and region_for_span( + segment.virtual_address, segment.memory_size, layout + ) is None: + errors.append( + f"PT_LOAD[{index}] virtual span " + f"0x{segment.virtual_address:08X}.." + f"0x{segment.virtual_address + segment.memory_size:08X} " + "is outside declared memory" + ) + if segment.file_size and region_for_span( + segment.physical_address, segment.file_size, layout + ) is None: + errors.append( + f"PT_LOAD[{index}] physical span " + f"0x{segment.physical_address:08X}.." + f"0x{segment.physical_address + segment.file_size:08X} " + "is outside declared memory" + ) + return errors + + def main(): if len(sys.argv) != 3: raise ValueError(f"usage: {sys.argv[0]} ") @@ -86,20 +200,29 @@ def main(): layout = regions(memory_x) if not layout: raise ValueError(f"no MEMORY regions found in: {memory_x}") - section_list = sections(run([str(llvm_size()), "-A", str(elf)])) + readobj = llvm_tool("llvm-readobj") + section_list, load_segments = elf_layout( + run( + [ + str(readobj), + "--sections", + "--program-headers", + "--elf-output-style=JSON", + str(elf), + ] + ) + ) if not section_list: raise ValueError(f"no sections found in: {elf}") + if not load_segments: + raise ValueError(f"no loadable segments found in: {elf}") print(f"\nFirmware: {elf}") print(f"ELF file size: {elf.stat().st_size:,} bytes") print("\nLinker regions:") print(" Region Origin Used / Capacity Remaining") for name, origin, length in layout: - used = sum( - size - for _, size, address in section_list - if size and origin <= address < origin + length - ) + used = region_usage(name, layout, section_list, load_segments) remaining = length - used print( f" {name:<12} 0x{origin:08X} {used / 1024:>7.2f} KiB /" @@ -109,14 +232,34 @@ def main(): print("\nELF sections:") print(" Region Address Size Section") - for name, size, address in sorted(section_list, key=lambda section: section[2]): - if size: - print(f" {region_for(address, layout):<12} 0x{address:08X} {size:>10,} {name}") + for section in sorted(section_list, key=lambda item: item.address): + if section.size: + location = ( + region_for(section.address, layout) + if section.allocated + else "non-alloc" + ) + print( + f" {location:<12} " + f"0x{section.address:08X} {section.size:>10,} {section.name}" + ) + + errors = validation_errors(layout, section_list, load_segments) + if errors: + raise ValueError("firmware memory validation failed:\n " + "\n ".join(errors)) if __name__ == "__main__": try: main() - except (IndexError, OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: + except ( + IndexError, + KeyError, + OSError, + RuntimeError, + TypeError, + ValueError, + subprocess.CalledProcessError, + ) as error: print(f"error: {error}", file=sys.stderr) raise SystemExit(1) from error diff --git a/firmware/scripts/create-dfu-image.sh b/firmware/scripts/create-dfu-image.sh index f8372d7..9d4a3c8 100755 --- a/firmware/scripts/create-dfu-image.sh +++ b/firmware/scripts/create-dfu-image.sh @@ -1,18 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -ne 1 ]]; then - printf 'Usage: %s \n' "$0" >&2 +if [[ $# -lt 1 || $# -gt 2 ]]; then + printf 'Usage: %s [memory.x]\n' "$0" >&2 exit 2 fi readonly input_elf="$1" -readonly firmware_name="$(basename "${input_elf%.elf}")" -readonly board_name="${firmware_name#kaptan-}" readonly output_image="${input_elf%.elf}.bin" readonly script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly workspace_root="$(cd -- "$script_directory/.." && pwd)" -readonly memory_x="$workspace_root/boards/$board_name/memory.x" +readonly memory_x="${2:-$workspace_root/platforms/stm32f7/link/f722/memory.x}" readonly host="$(rustc -vV | sed -n 's/^host: //p')" readonly llvm_objcopy="$(rustc --print sysroot)/lib/rustlib/$host/bin/llvm-objcopy" @@ -22,7 +20,7 @@ if [[ ! -f "$input_elf" ]]; then fi if [[ ! -f "$memory_x" ]]; then - printf 'cannot find the linker layout for board %q: %s\n' "$board_name" "$memory_x" >&2 + printf 'cannot find linker layout: %s\n' "$memory_x" >&2 exit 1 fi @@ -31,6 +29,11 @@ if [[ ! -x "$llvm_objcopy" ]]; then exit 1 fi -"$llvm_objcopy" -O binary "$input_elf" "$output_image" -printf 'DFU image: %s\n' "$output_image" "$script_directory/analyze-fw.py" "$input_elf" "$memory_x" + +temporary_image="$(mktemp "${output_image}.tmp.XXXXXX")" +trap 'rm -f -- "$temporary_image"' EXIT +"$llvm_objcopy" -O binary "$input_elf" "$temporary_image" +mv -f -- "$temporary_image" "$output_image" +trap - EXIT +printf 'DFU image: %s\n' "$output_image" diff --git a/firmware/scripts/test-analyze-fw.py b/firmware/scripts/test-analyze-fw.py new file mode 100644 index 0000000..1fe3e2d --- /dev/null +++ b/firmware/scripts/test-analyze-fw.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Synthetic regression tests for strict firmware-memory validation.""" + +import importlib.util +import json +import unittest + + +def load_analyzer_module(): + spec = importlib.util.spec_from_file_location("analyze_fw", "scripts/analyze-fw.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +ANALYZER = load_analyzer_module() +LAYOUT = [("FLASH", 0x08000000, 0x100), ("RAM", 0x20000000, 0x80)] + + +class FirmwareMemoryValidationTests(unittest.TestCase): + def test_elf_layout_uses_alloc_flag_and_only_load_segments(self): + document = [ + { + "Sections": [ + { + "Section": { + "Name": {"Name": ".text"}, + "Size": 8, + "Address": 0x08000000, + "Flags": {"Flags": [{"Name": "SHF_ALLOC"}]}, + } + }, + { + "Section": { + "Name": {"Name": ".debug_info"}, + "Size": 100, + "Address": 0, + "Flags": {"Flags": []}, + } + }, + ], + "ProgramHeaders": [ + { + "ProgramHeader": { + "Type": {"Name": "PT_LOAD"}, + "VirtualAddress": 0x08000000, + "PhysicalAddress": 0x08000000, + "FileSize": 8, + "MemSize": 8, + } + }, + { + "ProgramHeader": { + "Type": {"Name": "PT_ARM_EXIDX"}, + "VirtualAddress": 0, + "PhysicalAddress": 0, + "FileSize": 0, + "MemSize": 0, + } + }, + ], + } + ] + + sections, segments = ANALYZER.elf_layout(json.dumps(document)) + + self.assertEqual( + sections, + [ + ANALYZER.Section(".text", 8, 0x08000000, True), + ANALYZER.Section(".debug_info", 100, 0, False), + ], + ) + self.assertEqual( + segments, + [ANALYZER.LoadSegment(0x08000000, 0x08000000, 8, 8)], + ) + + def test_accepts_allocated_memory_and_ignores_debug_sections(self): + sections = [ + ANALYZER.Section(".text", 0x80, 0x08000000, True), + ANALYZER.Section(".bss", 0x40, 0x20000000, True), + ANALYZER.Section(".debug_info", 0x1000, 0, False), + ANALYZER.Section(".empty", 0, 0xDEADBEEF, True), + ] + segments = [ + ANALYZER.LoadSegment(0x08000000, 0x08000000, 0x80, 0x80), + ANALYZER.LoadSegment(0x20000000, 0x20000000, 0, 0x40), + ] + + self.assertEqual(ANALYZER.validation_errors(LAYOUT, sections, segments), []) + + def test_rejects_allocated_sections_outside_or_crossing_a_region(self): + outside = [ANALYZER.Section(".text", 4, 0x08000200, True)] + crossing = [ANALYZER.Section(".text", 2, 0x080000FF, True)] + + self.assertIn( + "allocated section .text", + ANALYZER.validation_errors(LAYOUT, outside, [])[0], + ) + self.assertIn( + "allocated section .text", + ANALYZER.validation_errors(LAYOUT, crossing, [])[0], + ) + + def test_usage_counts_data_load_image_without_double_counting_text(self): + sections = [ + ANALYZER.Section(".text", 0x40, 0x08000000, True), + ANALYZER.Section(".data", 0x10, 0x20000000, True), + ] + segments = [ + ANALYZER.LoadSegment(0x08000000, 0x08000000, 0x40, 0x40), + ANALYZER.LoadSegment(0x20000000, 0x08000040, 0x10, 0x10), + ] + + self.assertEqual( + ANALYZER.region_usage("FLASH", LAYOUT, sections, segments), 0x50 + ) + self.assertEqual( + ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10 + ) + + def test_rejects_loadable_virtual_and_physical_spans_outside_memory(self): + segments = [ + ANALYZER.LoadSegment(0x09000000, 0x08000000, 4, 4), + ANALYZER.LoadSegment(0x20000000, 0x09000000, 4, 4), + ] + + errors = ANALYZER.validation_errors(LAYOUT, [], segments) + self.assertTrue(any("virtual span" in error for error in errors)) + self.assertTrue(any("physical span" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main()