357 lines
12 KiB
Python
Executable File
357 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate and show flash, static RAM and ELF-section use.
|
|
|
|
Usage:
|
|
scripts/analyze-fw.py <firmware.elf> <memory.x>
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from collections import namedtuple
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
REGION = re.compile(
|
|
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"
|
|
r"ORIGIN\s*=\s*([^,]+),\s*LENGTH\s*=\s*([^\s/}]+)",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
Section = namedtuple("Section", "name size address allocated executable")
|
|
LoadSegment = namedtuple(
|
|
"LoadSegment", "virtual_address physical_address file_size memory_size"
|
|
)
|
|
|
|
|
|
def number(text):
|
|
"""Parse linker numbers such as 0x2001_0000, 176K, and 1M."""
|
|
text = text.strip().replace("_", "")
|
|
scale = {"K": 1024, "M": 1024 * 1024}.get(text[-1:].upper(), 1)
|
|
return int(text[:-1] if scale != 1 else text, 0) * scale
|
|
|
|
|
|
def run(command):
|
|
"""Run a tool and return stdout."""
|
|
return subprocess.run(command, check=True, text=True, capture_output=True).stdout
|
|
|
|
|
|
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" / name
|
|
if not tool.is_file():
|
|
raise RuntimeError(
|
|
f"{name} is unavailable; install it with: rustup component add llvm-tools-preview"
|
|
)
|
|
return tool
|
|
|
|
|
|
def regions(memory_x):
|
|
"""Return every named linker region as (name, origin, length)."""
|
|
return [
|
|
(name, number(origin), number(length))
|
|
for name, origin, length in REGION.findall(memory_x.read_text())
|
|
]
|
|
|
|
|
|
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,
|
|
"SHF_EXECINSTR" 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):
|
|
"""Return the linker region containing an address, if any."""
|
|
for name, origin, length in layout:
|
|
if origin <= address < origin + length:
|
|
return name
|
|
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 flash_operation_section_errors(layout, section_list):
|
|
"""Validate the output section that contains the F722 Flash operation."""
|
|
matches = [section for section in section_list if section.name == ".data"]
|
|
if len(matches) != 1:
|
|
return ["firmware must contain exactly one .data section"]
|
|
section = matches[0]
|
|
if not section.allocated or not section.executable or section.size == 0:
|
|
return [".data must contain executable SRAM Flash-operation code"]
|
|
if region_for_span(section.address, section.size, layout) != "RAM":
|
|
return ["executable .data must reside completely in RAM"]
|
|
return []
|
|
|
|
|
|
INSTRUCTION = re.compile(
|
|
r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$"
|
|
)
|
|
DIRECT_BRANCH = re.compile(
|
|
r"^(?:b(?:eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)?"
|
|
r"(?:\.[nw])?|blx?|bx|cbz|cbnz)$"
|
|
)
|
|
|
|
|
|
def sram_control_flow_errors(section, disassembly):
|
|
"""Reject external control flow and PC-relative loads from SRAM code."""
|
|
errors = []
|
|
instructions = 0
|
|
end = section.address + section.size
|
|
for line in disassembly.splitlines():
|
|
match = INSTRUCTION.match(line)
|
|
if match is None:
|
|
continue
|
|
instructions += 1
|
|
address = int(match.group(1), 16)
|
|
mnemonic = match.group(2).lower()
|
|
operands = match.group(3).split("@", 1)[0].strip().lower()
|
|
if not section.address <= address < end:
|
|
errors.append(f"instruction 0x{address:08X} is outside .data")
|
|
continue
|
|
|
|
if DIRECT_BRANCH.fullmatch(mnemonic):
|
|
target = re.search(r"\b0x([0-9a-f]+)\b", operands)
|
|
if target is None:
|
|
if mnemonic == "bx" and operands == "lr":
|
|
continue
|
|
errors.append(
|
|
f"0x{address:08X} uses indirect control transfer {mnemonic}"
|
|
)
|
|
continue
|
|
destination = int(target.group(1), 16)
|
|
if not section.address <= destination < end:
|
|
errors.append(
|
|
f"0x{address:08X} branches outside SRAM .data to "
|
|
f"0x{destination:08X}"
|
|
)
|
|
continue
|
|
|
|
if mnemonic in ("tbb", "tbh"):
|
|
errors.append(f"0x{address:08X} uses indirect control transfer {mnemonic}")
|
|
elif mnemonic == "pop" and re.search(r"\bpc\b", operands):
|
|
continue
|
|
elif re.match(r"^pc(?:\s|,|$)", operands):
|
|
errors.append(f"0x{address:08X} writes the program counter indirectly")
|
|
elif mnemonic.startswith("ldr") and re.search(r"\[\s*pc\b", operands):
|
|
errors.append(f"0x{address:08X} performs a PC-relative load")
|
|
|
|
if instructions == 0:
|
|
errors.append("llvm-objdump found no instructions in executable .data")
|
|
return errors
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 3:
|
|
raise ValueError(f"usage: {sys.argv[0]} <firmware.elf> <memory.x>")
|
|
|
|
elf = Path(sys.argv[1])
|
|
memory_x = Path(sys.argv[2])
|
|
if not elf.is_file():
|
|
raise ValueError(f"ELF does not exist: {elf}")
|
|
if not memory_x.is_file():
|
|
raise ValueError(f"linker layout does not exist: {memory_x}")
|
|
|
|
layout = regions(memory_x)
|
|
if not layout:
|
|
raise ValueError(f"no MEMORY regions found in: {memory_x}")
|
|
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 = region_usage(name, layout, section_list, load_segments)
|
|
remaining = length - used
|
|
print(
|
|
f" {name:<12} 0x{origin:08X} {used / 1024:>7.2f} KiB /"
|
|
f" {length / 1024:>7.2f} KiB {remaining / 1024:>7.2f} KiB"
|
|
f" ({100 * used / length:5.2f}%)"
|
|
)
|
|
|
|
print("\nELF sections:")
|
|
print(" Region Address Size Section")
|
|
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))
|
|
|
|
errors = flash_operation_section_errors(layout, section_list)
|
|
if errors:
|
|
raise ValueError("Flash-operation placement validation failed:\n " + "\n ".join(errors))
|
|
data = next(section for section in section_list if section.name == ".data")
|
|
objdump = llvm_tool("llvm-objdump")
|
|
errors = sram_control_flow_errors(
|
|
data,
|
|
run(
|
|
[
|
|
str(objdump),
|
|
"-d",
|
|
"--no-show-raw-insn",
|
|
"--section=.data",
|
|
str(elf),
|
|
]
|
|
),
|
|
)
|
|
if errors:
|
|
raise ValueError("Flash-operation control-flow validation failed:\n " + "\n ".join(errors))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (
|
|
IndexError,
|
|
KeyError,
|
|
OSError,
|
|
RuntimeError,
|
|
TypeError,
|
|
ValueError,
|
|
subprocess.CalledProcessError,
|
|
) as error:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
raise SystemExit(1) from error
|