Files
templates/python/firmware_image/core.py

374 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Переносимое ядро проверки образа прошивки STM32.
Модуль не знает ни про HTTP, ни про Modbus, ни про файловую систему, ни про
конкретный программатор: на вход подаются уже прочитанные байты, на выходе
получается проверенный план записи. Благодаря этому те же правила проверяются
host-тестом без платы и без bridge.
Основные границы:
* :func:`parse_intel_hex` — полный разбор Intel HEX с проверкой checksum каждой
записи, поддержкой расширенной адресации и поиском пересечений;
* :func:`build_raw_plan` / :func:`build_hex_plan` — план записи с проверкой
выравнивания, границ приложения и защищённых секторов;
* :func:`image_digest` — CRC32 и SHA-256 фактически записываемых байтов.
Геометрия Flash задаётся объектом :class:`FlashLayout`, поэтому ядро не привязано
к STM32F407: значения по умолчанию описывают текущую плату и берутся из
``MDK-ARM/flash_layout.sct`` и ``EEPROM_Emul/lib/flash_ring.h``.
"""
from __future__ import annotations
import hashlib
import re
import zlib
from dataclasses import dataclass, field
from typing import Iterable, Optional, Sequence
__all__ = [
"FirmwareImageError",
"FlashLayout",
"FlashRegion",
"FlashSector",
"HexSegment",
"ImagePlan",
"STM32F407VE_LAYOUT",
"RAW_EXTENSIONS",
"HEX_EXTENSIONS",
"build_hex_plan",
"build_raw_plan",
"detect_format",
"format_address",
"image_digest",
"parse_flash_address",
"parse_intel_hex",
]
class FirmwareImageError(ValueError):
"""Единственный тип ошибки ядра: сообщение пригодно для показа оператору."""
# ---------------------------------------------------------------------------
# Геометрия Flash
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FlashSector:
index: int
start: int
size: int
@property
def end(self) -> int:
"""Последний байт сектора включительно."""
return self.start + self.size - 1
@dataclass(frozen=True)
class FlashRegion:
name: str
start: int
end: int
def overlaps(self, start: int, end: int) -> bool:
return start <= self.end and end >= self.start
@dataclass(frozen=True)
class FlashLayout:
"""Полное описание внутренней Flash целевого МК."""
name: str
sectors: Sequence[FlashSector]
application: FlashRegion
reserved: Sequence[FlashRegion] = field(default_factory=tuple)
write_alignment: int = 4
min_image_bytes: int = 8
@property
def max_image_bytes(self) -> int:
return self.application.end - self.application.start + 1
def sector_at(self, address: int) -> Optional[FlashSector]:
for sector in self.sectors:
if sector.start <= address <= sector.end:
return sector
return None
def sectors_for_range(self, start: int, end: int) -> list[FlashSector]:
return [sector for sector in self.sectors if start <= sector.end and end >= sector.start]
def reserved_hit(self, start: int, end: int) -> Optional[FlashRegion]:
for region in self.reserved:
if region.overlaps(start, end):
return region
return None
# Секторы STM32F407VE: 4x16 КиБ, 64 КиБ, 3x128 КиБ.
STM32F407VE_LAYOUT = FlashLayout(
name="STM32F407VET6",
sectors=(
FlashSector(0, 0x08000000, 16 * 1024),
FlashSector(1, 0x08004000, 16 * 1024),
FlashSector(2, 0x08008000, 16 * 1024),
FlashSector(3, 0x0800C000, 16 * 1024),
FlashSector(4, 0x08010000, 64 * 1024),
FlashSector(5, 0x08020000, 128 * 1024),
FlashSector(6, 0x08040000, 128 * 1024),
FlashSector(7, 0x08060000, 128 * 1024),
),
application=FlashRegion("Приложение", 0x08000000, 0x0803FFFF),
# Секторы 6-7 отданы кольцу EEPROM: их стирание уничтожило бы настройки.
reserved=(FlashRegion("Кольцо EEPROM (секторы 6-7)", 0x08040000, 0x0807FFFF),),
)
RAW_EXTENSIONS = (".bin", ".fw")
HEX_EXTENSIONS = (".hex",)
def detect_format(name: str) -> str:
"""Формат образа по расширению; исключение для всего остального."""
lower = str(name or "").lower()
if lower.endswith(RAW_EXTENSIONS):
return "bin"
if lower.endswith(HEX_EXTENSIONS):
return "hex"
raise FirmwareImageError("Поддерживаются только .bin/.hex/.fw")
def format_address(address: int) -> str:
return f"0x{int(address):08X}"
def parse_flash_address(value: object) -> int:
"""Адрес принимается только как явный decimal или HEX с префиксом 0x."""
text = str(value if value is not None else "").strip().replace(" ", "")
if not text:
raise FirmwareImageError("Базовый адрес Flash не задан")
if not re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}|\d+", text):
raise FirmwareImageError("Адрес должен быть decimal или HEX с префиксом 0x")
address = int(text, 16 if text.lower().startswith("0x") else 10)
if not 0 <= address <= 0xFFFFFFFF:
raise FirmwareImageError("Адрес выходит за диапазон uint32")
return address
# ---------------------------------------------------------------------------
# Intel HEX
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class HexSegment:
"""Непрерывный участок данных Intel HEX."""
start: int
data: bytes
@property
def end(self) -> int:
return self.start + len(self.data) - 1
RECORD_DATA = 0x00
RECORD_EOF = 0x01
RECORD_EXTENDED_SEGMENT = 0x02
RECORD_START_SEGMENT = 0x03
RECORD_EXTENDED_LINEAR = 0x04
RECORD_START_LINEAR = 0x05
_KNOWN_RECORDS = (RECORD_DATA, RECORD_EOF, RECORD_EXTENDED_SEGMENT,
RECORD_START_SEGMENT, RECORD_EXTENDED_LINEAR, RECORD_START_LINEAR)
# Лимит защищает bridge от файла, который «раздувается» адресными записями.
MAX_HEX_RECORDS = 1_000_000
def parse_intel_hex(text: object, *, max_records: int = MAX_HEX_RECORDS) -> list[HexSegment]:
"""Validate HEX with the shared C99 parser and return contiguous segments."""
from .native import parse_segments
return parse_segments(text, max_records, FirmwareImageError, HexSegment)
def contiguous_image(text, maximum_size=2 * 1024 * 1024):
"""Convert validated segments to a bounded image, filling gaps with FF."""
segments = parse_intel_hex(text)
base, end = segments[0].start, segments[-1].end + 1
if end - base > maximum_size:
raise FirmwareImageError('Размер образа превышает %d байт' % maximum_size)
image = bytearray(b'\xff' * (end - base))
for segment in segments:
offset = segment.start - base
image[offset:offset + len(segment.data)] = segment.data
return bytes(image), base
@dataclass(frozen=True)
class ImagePlan:
"""Проверенный план: что именно и куда будет записано."""
image_format: str
start: int
end: int
payload_bytes: int
sectors: tuple[int, ...]
erased_bytes: int
segments: tuple[HexSegment, ...]
gaps: tuple[tuple[int, int], ...]
crc32: str
sha256: str
@property
def text(self) -> str:
return f"{format_address(self.start)}..{format_address(self.end)}"
def as_dict(self) -> dict:
"""Представление для JSON API и GUI."""
return {
"format": self.image_format,
"start": format_address(self.start),
"end": format_address(self.end),
"targetRange": self.text,
"payloadBytes": self.payload_bytes,
"spanBytes": self.end - self.start + 1,
"sectors": list(self.sectors),
"erasedBytes": self.erased_bytes,
"segments": [
{"start": format_address(item.start), "end": format_address(item.end),
"size": len(item.data)}
for item in self.segments
],
"gaps": [
{"start": format_address(low), "end": format_address(high), "size": high - low + 1}
for low, high in self.gaps
],
"crc32": self.crc32,
"sha256": self.sha256,
}
def image_digest(data: bytes) -> tuple[str, str]:
"""CRC32 и SHA-256 фактически записываемых байтов."""
return (
f"{zlib.crc32(bytes(data)) & 0xFFFFFFFF:08X}",
hashlib.sha256(bytes(data)).hexdigest().upper(),
)
def _check_range(layout: FlashLayout, start: int, end: int) -> None:
reserved = layout.reserved_hit(start, end)
if reserved is not None:
raise FirmwareImageError(f"Диапазон пересекает защищённую область: {reserved.name}")
if start < layout.application.start or end > layout.application.end:
raise FirmwareImageError(
"Диапазон должен помещаться в "
f"{format_address(layout.application.start)}.."
f"{format_address(layout.application.end)}"
)
def _sector_summary(layout: FlashLayout, start: int, end: int) -> tuple[tuple[int, ...], int]:
sectors = layout.sectors_for_range(start, end)
return tuple(item.index for item in sectors), sum(item.size for item in sectors)
def build_raw_plan(size: object, base_address: object, *,
layout: FlashLayout = STM32F407VE_LAYOUT,
data: Optional[bytes] = None) -> ImagePlan:
"""План для сырого образа.
Базовый адрес обязан совпадать с началом сектора: стирание STM32F4 идёт
только целыми секторами, поэтому запись с середины сектора уничтожила бы
соседние данные. ``data`` необязателен: список файлов SD знает лишь размер,
и контрольные суммы считаются только тогда, когда содержимое действительно
прочитано.
"""
try:
length = int(size)
except (TypeError, ValueError) as exc:
raise FirmwareImageError("Размер образа должен быть целым числом") from exc
if data is not None and len(data) != length:
raise FirmwareImageError("Размер образа не совпадает с числом прочитанных байт")
if length < layout.min_image_bytes:
raise FirmwareImageError(f"Размер образа меньше {layout.min_image_bytes} байт")
if length > layout.max_image_bytes:
raise FirmwareImageError(
f"Образ больше области приложения ({layout.max_image_bytes // 1024} КиБ)"
)
start = parse_flash_address(base_address)
if start % layout.write_alignment:
raise FirmwareImageError(
f"Базовый адрес должен быть выровнен на {layout.write_alignment} байта"
)
sector = layout.sector_at(start)
if sector is None:
raise FirmwareImageError(f"Базовый адрес вне внутренней Flash {layout.name}")
if sector.start != start:
raise FirmwareImageError(
f"Базовый адрес должен совпадать с началом сектора ({format_address(sector.start)})"
)
end = start + length - 1
_check_range(layout, start, end)
sectors, erased = _sector_summary(layout, start, end)
crc32, sha256 = image_digest(data) if data is not None else ("", "")
segments = (HexSegment(start, bytes(data)),) if data is not None else ()
return ImagePlan("bin", start, end, length, sectors, erased, segments, (), crc32, sha256)
def build_hex_plan(segments: Iterable[HexSegment], *,
layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan:
"""План для Intel HEX: адреса берутся из файла, оператор их не задаёт.
Проверяется каждый сегмент отдельно, поэтому файл с одним «правильным» и
одним посторонним диапазоном отклоняется целиком. Разрывы между сегментами
сохраняются в плане: при записи они заполняются 0xFF, и оператор должен
видеть, какая часть Flash будет стёрта без данных из файла.
"""
ordered = sorted(segments, key=lambda item: item.start)
if not ordered:
raise FirmwareImageError("Файл Intel HEX не содержит данных")
for segment in ordered:
if not segment.data:
raise FirmwareImageError("Сегмент Intel HEX пуст")
_check_range(layout, segment.start, segment.end)
start = ordered[0].start
end = ordered[-1].end
if start % layout.write_alignment:
raise FirmwareImageError(
f"Начальный адрес Intel HEX не выровнен на {layout.write_alignment} байта"
)
sector = layout.sector_at(start)
if sector is None or sector.start != start:
raise FirmwareImageError(
"Первый адрес Intel HEX должен совпадать с началом сектора "
f"({format_address(sector.start) if sector else 'вне Flash'})"
)
# Пустоты заполняются 0xFF — стёртым состоянием NOR Flash.
image = bytearray(b"\xFF" * (end - start + 1))
gaps: list[tuple[int, int]] = []
previous_end = None
payload = 0
for segment in ordered:
if previous_end is not None and segment.start > previous_end + 1:
gaps.append((previous_end + 1, segment.start - 1))
image[segment.start - start:segment.end - start + 1] = segment.data
payload += len(segment.data)
previous_end = segment.end
sectors, erased = _sector_summary(layout, start, end)
crc32, sha256 = image_digest(bytes(image))
return ImagePlan("hex", start, end, payload, sectors, erased,
tuple(ordered), tuple(gaps), crc32, sha256)
def build_plan(image_format: str, *, data: Optional[bytes] = None, size: Optional[int] = None,
base_address: object = None,
layout: FlashLayout = STM32F407VE_LAYOUT) -> ImagePlan:
"""Единая точка входа: выбрать ветку разбора по формату образа."""
if image_format == "hex":
if data is None:
raise FirmwareImageError("Для Intel HEX требуется содержимое файла")
return build_hex_plan(parse_intel_hex(data), layout=layout)
if image_format == "bin":
length = len(data) if data is not None else size
return build_raw_plan(length, base_address, layout=layout, data=data)
raise FirmwareImageError("Неизвестный формат образа")