Добавить идентификацию прошивки и чтение EEPROM Бальзам-167

This commit is contained in:
2026-10-05 17:48:23 +03:00
parent 0e3d89e1ee
commit 90046de7fd
28 changed files with 924 additions and 22 deletions
+275
View File
@@ -0,0 +1,275 @@
# Версия работающей прошивки и SHA-256 файла: TMS2812, TMS28335, STM32
## 1. Какие задачи решает механизм
Механизм продолжает контракт Climate: устройство сообщает SemVer, дату/время
сборки и Git Build ID в 12 регистрах. Компьютер после сборки вычисляет SHA-256
готового файла и сохраняет паспорт `*.manifest.json` и файл `*.sha256`.
Это три разных идентификатора:
| Идентификатор | Что определяет | Где хранится |
|---|---|---|
| `major.minor.patch` | Назначенный разработчиком номер версии | В образе и паспорте |
| Git Build ID | Исходный коммит; `+` отмечает локальные изменения | В образе и паспорте |
| SHA-256 | Точные байты конкретного BIN/HEX, включая заголовки | Рядом с образом |
SHA Git-коммита и SHA-256 файла прошивки не взаимозаменяемы. Один коммит может
давать разные бинарные файлы при смене компилятора, опций, времени сборки или
зависимостей. Полные коммиты приложения и templates записываются в паспорт.
Контроллер не вычисляет SHA своей памяти. Ответ версии подтверждает, какие
метаданные содержит работающий образ; SHA файла проверяет файл на компьютере.
Для проверки фактической записи нужна отдельная штатная проверка программатора
или чтение памяти назад. SHA рядом с файлом не является цифровой подписью.
## 2. Слои и файлы
```text
firmware_identity.json проекта + Git приложения + Git templates
|
tools/identity.py prepare (до компиляции)
|
generated/firmware_identity.h + .json
|
src/firmware_info_static.c (общий C-код)
|
12 неизменяемых слов -> порт транспорта -> клиент
линковка -> преобразование BIN -> проверка размера -> identity.py seal
|
BIN + BIN.manifest.json + BIN.sha256
```
| Файл | Задача и зависимости |
|---|---|
| `include/firmware_info_static.h` | Контракт компактного порта и упаковка слов, только `stdint.h` |
| `src/firmware_info_static.c` | Константная таблица и чтение одного слова; нужен generated header |
| `include/firmware_info.h`, `src/firmware_info.c` | Существующий динамический API Climate, валидация и сериализация |
| `src/firmware_info_port.c` | Динамический адаптер конфигурации; поддерживает новый UTC stamp |
| `ports/tms320f2812`, `ports/tms320f28335`, `ports/stm32` | Примеры конфигурации для нового генератора |
| `tools/identity.py` | Git, JSON, SHA-256 файла; Python 3.10+ и Git, без pip-зависимостей |
| `tests/test_static.c` | Совпадение компактного и динамического контрактов, чтение RS/CAN, запрет записи |
| `tests/test_identity.py` | Генерация, dirty-зависимость, отсутствие Git, порча файла |
Ядро не знает HAL, CMSIS, XINTF, EEPROM, UART, CAN-регистры и адрес устройства.
`firmware_info_static_read(unsigned index)` принимает индекс **0..11**, возвращает
`uint16_t`; вне диапазона возвращает 0. Проверять диапазон запроса и запрещать
запись обязан транспорт. Нулевую версию контракта клиент показывает как
«версия неизвестна», а не подставляет версию из выбранного файла.
## 3. Конфигурация проекта и генерация
В репозитории приложения создайте `firmware_identity.json`:
```json
{
"product": "MyController",
"target": "TMS320F2812",
"version": [1, 0, 0]
}
```
`product` и `target` нужны для паспорта файла. В контракт v1 они не входят:
тип подключённого устройства по-прежнему определяет его собственный протокол.
Не используйте совпадение версии как разрешение залить образ другого изделия.
До каждой компиляции выполните (пути приведены для checkout в `lib/templates`):
```powershell
python lib/templates/c/firmware-info/tools/identity.py prepare `
--repository . --templates lib/templates `
--config firmware_identity.json --output build/generated
```
Добавьте `build/generated` и `lib/templates/c/firmware-info/include` в include
paths. Скомпилируйте **один экземпляр**
`lib/templates/c/firmware-info/src/firmware_info_static.c`. Если проект включает
этот файл через агрегирующий `.c`, отдельно добавлять его в сборку нельзя.
Генератор намеренно переписывает header при каждом запуске: зависимый объект
должен пересобраться. Отсутствие header — ошибка компиляции, а не `LOCALDEV`.
Нужна корректная обработка зависимостей сборщиком; после изменения настроек IDE
выполните полную пересборку. Не редактируйте исходники во время сборки.
Для разных конфигураций и параллельных сборок используйте разные `generated`
каталоги. Не запускайте две сборки с одним output одновременно.
Время в новом пути — **UTC**, снято перед компиляцией. Если задан
`SOURCE_DATE_EPOCH`, используется этот Unix timestamp; это помогает повторяемым
сборкам, но не делает весь toolchain автоматически воспроизводимым.
### Значения Build ID
| Значение | Смысл |
|---|---|
| `a1b2c3d4` | Приложение и templates доступны в Git и оба дерева чистые |
| `a1b2c3d+` | Изменено приложение или templates, включая неотслеживаемые файлы |
| `NOGIT000` | Не удалось определить хотя бы один из двух Git-репозиториев |
| `LOCALDEV` | Только старый динамический путь без generated header |
Полные commit SHA находятся в `source.commit` и `templates.commit` паспорта.
`+` не идентифицирует содержимое локальных правок. Точный файл определяет его
SHA-256; для воспроизводимого выпуска нужны сохранённые исходники и настройки.
Добавляйте generated/build каталоги в `.gitignore`, иначе они будут честно
делать следующую сборку dirty. Намеренно не игнорируйте новые исходные файлы.
## 4. Формат: одинаковые 12 слов на всех МК
| Индекс | Значение |
|---:|---|
| 0 | Версия контракта: 1 |
| 1 | Major, 0..255 |
| 2 | Minor, 0..255 |
| 3 | Patch, 0..999 |
| 4 | Год, 2000..2199 |
| 5 | Месяц в старшем октете, день в младшем |
| 6 | Час в старшем октете, минута в младшем |
| 7 | Секунда |
| 8 | ASCII Build ID: символы 0 и 1, первый в старшем октете |
| 9 | Символы 2 и 3 |
| 10 | Символы 4 и 5 |
| 11 | Символы 6 и 7 |
Например, `abcdef0+` даёт `0x6162, 0x6364, 0x6566, 0x302B` в словах 8..11.
Modbus передаёт каждое слово старшим октетом вперёд. Старый
`firmware_info_to_le_bytes()` передаёт слова little-endian для соответствующих
byte-протоколов; эти два представления нельзя путать.
На TMS320F2812/F28335 `CHAR_BIT == 16`, `sizeof(uint16_t) == 1`, тип `uint8_t`
отсутствует. В динамическом API используется `firmware_info_octet`, основанный
на `uint_least8_t`: на STM32 это прежние 8 бит, на C28x — контейнер 16 бит.
Размеры byte-API означают число **элементов-октетов**, не физический `sizeof`
переданного массива. На проводе всё равно 24 восьмибитных октета.
## 5. Порты и включение в транспорт
### TMS320F28335 / ПМ35
В 167 уже подключено через `Source/Internal/shared_ump.c`. Макрос
`UMP_FIRMWARE_INFO_READ` связывает общий порт UMP с
`firmware_info_static_read`. Диапазон `0x1200..0x120B` обслуживается до
обращения к регистратору. Окно параметров `0..127` и лог не расширяются.
RS: функция 03, начальный адрес `0x1200` (4608), количество 12.
Разрешены частичные чтения, не выходящие за конец окна. Функция 06 возвращает
исключение 02, не изменяя ни версию, ни настройки регистратора.
Служебный CAN ПМ35: тот же UMP-запрос функции 03 на `0x1200`, количество 12,
ненулевой token. Ответ — шесть фрагментов по два слова с тем же token.
Частичные CAN-чтения этого окна отвергаются. Используйте существующие UMP
сборщики/парсеры templates, не создавайте второй кодек в GUI.
### TMS320F2812 / ПМ67
Добавьте тот же `firmware_info_static.c` и include paths. Компактный порт не
требует FPU и не использует регистры F28335. Конфигурация-пример лежит в
`ports/tms320f2812/firmware_info_config.template.h`.
Выберите свободное окно в карте **конкретного ПМ67**. В обработчике чтения
отдавайте `firmware_info_static_read(address - base)` после проверки диапазона.
Не переносите автоматически CAN ID и UMP ПМ35: у ПМ67 отдельный протокол.
Это поставляемый переносимый порт, а не заявление, что конкретная прошивка
ПМ67 уже изменена или испытана на устройстве.
### STM32 / Modbus или SET
Компактный порт одинаков для F1/F4/G4. Для HAL он не требует ни инициализации,
ни callback. Пример адаптации чтения **после проверки диапазона транспортом**:
```c
#include "firmware_info_static.h"
/* FW_BASE выбирает проект; соответствие адресов проверяет Modbus/SET handler. */
uint16_t app_firmware_word(uint16_t address)
{
unsigned index = (unsigned)(address - FW_BASE);
return firmware_info_static_read(index);
}
```
Для STM32 UMP-периферии можно использовать уже существующий
`set-protocol/ports/stm32f407-periph` и подставить этот reader в `ump_backend`.
Для обычного Modbus используйте собственное свободное input/holding read-only
окно, сохраняя 12 слов контракта. В Climate адрес был 1960; это **не** адрес
167. Если удобнее заполнить RAM-регистры при старте, оставьте динамический API:
`firmware_info_port_describe()` и `firmware_info_to_registers()`.
## 6. SHA-256 и паспорт после сборки
Сначала завершите линковку и преобразование в файл, который реально отправляется
загрузчику. Затем проверьте формат и допустимый размер. Только после успеха:
```powershell
python lib/templates/c/firmware-info/tools/identity.py seal `
--image build/controller.bin --metadata build/generated/firmware_identity.json
```
Результат:
```text
controller.bin
controller.bin.manifest.json
controller.bin.sha256
```
Паспорт содержит product, target, version, build_id, built_at_utc, полные commit
SHA и dirty-состояния двух репозиториев, имя файла, размер и SHA-256. Передавайте
эти три файла вместе. `seal` не извлекает версию из произвольного бинарника:
metadata должен быть из **той же сборки**. Нельзя перепривязать старый BIN к
новому pre-build JSON. Проверка версии на работающей плате замыкает этот цикл.
Проверка точного файла:
```powershell
python lib/templates/c/firmware-info/tools/identity.py verify `
--image build/controller.bin --manifest build/controller.bin.manifest.json
```
При изменении даже одного байта команда завершится ненулевым кодом. SHA считается
для всего файла: для SCI8 — вместе с boot table, для HEX — для текстового HEX,
а не для нормализованного содержимого Flash. Выбирайте BIN для однозначного
сравнения с бинарным файлом программатора.
## 7. Выпуск и проверка на устройстве
1. Назначьте SemVer в конфигурации приложения.
2. Сохраните изменения templates отдельным коммитом и закрепите именно эту
зависимость в потребителе. Затем сохраните исходники приложения.
3. Убедитесь, что оба дерева чистые; соберите в отдельном output.
4. Проверьте ограничения образа, наличие паспорта и результат `verify`.
5. Загрузите именно этот файл штатным загрузчиком и перезапустите устройство.
6. Прочитайте версию с устройства заново, отключив кэш клиента.
7. Сравните все 12 слов с SemVer, UTC stamp и Build ID паспорта.
8. Сохраните BIN, паспорт, настройки компилятора и результаты проверки вместе.
Изменение SemVer не должно менять `contract_version`: последняя меняется только
при несовместимом изменении формата. При несовпадении даты/Build ID сначала
проверьте выбранный BIN, конфигурацию IDE, generated include path и полную
пересборку, затем адрес и кэш клиента.
## 8. Автоматические проверки
```powershell
cmake -S c/firmware-info -B build/firmware-info
cmake --build build/firmware-info
ctest --test-dir build/firmware-info --output-on-failure
python -m unittest discover -s c/firmware-info/tests -p test_identity.py
```
Дополнительно компилируйте core/static через TI C2000 (`-v28 -ml`) и ARM
toolchain. Host-тест не заменяет проверку C28x ABI и фактического обмена с платой.
Для 167 есть отдельный `tests/firmware_identity/port_test.c`: он проверяет
реальную привязку reader, RTU CRC, CAN prepare и сохранение окна регистратора.
## 9. Совместимость с существующими проектами
Старый `make_build_id.ps1`, конфигурации STM32F1/F4/G4 и динамический API остаются.
Новый генератор не подключается к другим проектам сам: добавьте pre/post-build
и generated include path по этой инструкции. На STM32 размеры публичных полей
и сигнатура byte-буфера остаются прежними благодаря `uint_least8_t`.
Изменения сделаны в checkout templates, который уже использует 167:
`newProject/SETGUI/third_party/templates`. Другие checkout/submodule не следует
обновлять копированием отдельных `.c`: перенесите проверенный коммит templates
и обновите ссылку зависимости. ПМ67 и ПМ35 сохраняют разные транспортные порты.
+5
View File
@@ -10,3 +10,8 @@ add_executable(test_firmware_info tests/test_firmware_info.c)
target_link_libraries(test_firmware_info PRIVATE firmware_info)
enable_testing()
add_test(NAME firmware_info COMMAND test_firmware_info)
add_executable(test_firmware_info_static tests/test_static.c src/firmware_info_static.c
../set-protocol/src/ump_protocol.c)
target_include_directories(test_firmware_info_static PRIVATE tests/fixtures ../set-protocol/include)
target_link_libraries(test_firmware_info_static PRIVATE firmware_info)
add_test(NAME firmware_info_static COMMAND test_firmware_info_static)
+4
View File
@@ -1,5 +1,9 @@
# Портирование firmware-info
Для компактного пути без парсера даты, TMS320F2812/F28335 и STM32, а также
SHA-256/паспортов сборки используйте [BUILD_IDENTITY.md](BUILD_IDENTITY.md).
Ниже сохранена инструкция для совместимого динамического API.
## Назначение и границы
Это C-библиотека описания работающего образа: SemVer, дата/время компиляции,
+4
View File
@@ -1,5 +1,9 @@
# Firmware Info
Компактный вариант для TMS320F2812, TMS320F28335 и STM32, общий pre/post-build,
SHA-256 файла и паспорт сборки: [подробная инструкция](BUILD_IDENTITY.md).
Он сохраняет 12-словный контракт Climate и не требует парсера даты на MCU.
Переносимая библиотека версии реально запущенной прошивки. Повторяет контракт
`FirmwareInfo` из `john103C6T6NewVer`, но оформлена как template: ядро не знает
ни о HAL, ни о конкретном протоколе, ни о модели МК.
+10 -6
View File
@@ -10,6 +10,10 @@
#include <stddef.h>
#include <stdint.h>
/* Least-width octet storage preserves STM ABI, and compiles on C28x where
* CHAR_BIT=16 and uint8_t does not exist. Values sent on wire are masked to 8 bits. */
typedef uint_least8_t firmware_info_octet;
#ifdef __cplusplus
extern "C" {
#endif
@@ -24,11 +28,11 @@ typedef struct {
uint16_t minor;
uint16_t patch;
uint16_t year;
uint8_t month;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t second;
firmware_info_octet month;
firmware_info_octet day;
firmware_info_octet hour;
firmware_info_octet minute;
firmware_info_octet second;
char build_id[FIRMWARE_INFO_BUILD_ID_SIZE + 1U];
} firmware_info_t;
@@ -56,7 +60,7 @@ firmware_info_status_t firmware_info_set_build_id(
firmware_info_status_t firmware_info_to_registers(
const firmware_info_t *info, uint16_t *registers, size_t count);
firmware_info_status_t firmware_info_to_le_bytes(
const firmware_info_t *info, uint8_t *payload, size_t size);
const firmware_info_t *info, firmware_info_octet *payload, size_t size);
#ifdef __cplusplus
}
@@ -0,0 +1,22 @@
/* Small, immutable firmware identity for targets with little code space.
* The build tool supplies scalar constants; the C core owns register packing.
* No startup parser, RAM copy, heap, HAL or runtime SHA implementation is needed.
* Include firmware_identity.h (generated BEFORE compilation) before this file.
*/
#ifndef FIRMWARE_INFO_STATIC_H
#define FIRMWARE_INFO_STATIC_H
#include <stdint.h>
#define FIRMWARE_INFO_STATIC_COUNT 12U
/* Cast BEFORE shifting: an int is only 16 bits on both C28x targets. */
#define FI_PAIR(high, low) ((uint16_t)(((uint16_t)(high) << 8U) | (uint16_t)(low)))
#define FIRMWARE_INFO_STATIC_INITIALIZER { \
1U, FIRMWARE_VERSION_MAJOR, FIRMWARE_VERSION_MINOR, FIRMWARE_VERSION_PATCH, \
FI_YEAR, FI_PAIR(FI_MONTH, FI_DAY), FI_PAIR(FI_HOUR, FI_MINUTE), FI_SECOND, \
FI_PAIR(FI_ID_0, FI_ID_1), FI_PAIR(FI_ID_2, FI_ID_3), \
FI_PAIR(FI_ID_4, FI_ID_5), FI_PAIR(FI_ID_6, FI_ID_7) }
/* Return zero outside the contract. The transport must reject invalid ranges
* before calling this function; zero is NOT an invented firmware version. */
uint16_t firmware_info_static_read(unsigned index);
#endif
@@ -0,0 +1,8 @@
#ifndef FIRMWARE_INFO_CONFIG_H
#define FIRMWARE_INFO_CONFIG_H
/* Family-independent STM32 static port: no HAL/CMSIS dependency.
* F1/F4/G4 applications may use this instead of the runtime date parser.
* Generate the header before compilation, then compile firmware_info_static.c.
* Modbus sends each returned word high octet first; SET uses its own encoder. */
#include "firmware_identity.h"
#endif
@@ -0,0 +1,7 @@
#ifndef FIRMWARE_INFO_CONFIG_H
#define FIRMWARE_INFO_CONFIG_H
/* Same C28x word representation as F28335, but transport remains PM67-specific.
* Do not route PM67 commands through the PM35 logger/CAN adapter.
* Compile firmware_info_static.c and expose its 12 words in a read-only window. */
#include "firmware_identity.h"
#endif
@@ -0,0 +1,7 @@
#ifndef FIRMWARE_INFO_CONFIG_H
#define FIRMWARE_INFO_CONFIG_H
/* Generate before EVERY build. C28x has 16-bit char and no uint8_t.
* For the compact port compile firmware_info_static.c, not the date parser.
* The same generated header can also configure firmware_info_port.c. */
#include "firmware_identity.h"
#endif
+15 -15
View File
@@ -13,20 +13,20 @@ static const char months[12][4] = {
static int is_digit(char value) { return (value >= '0') && (value <= '9'); }
static int two_digits(const char *text, uint8_t *result)
static int two_digits(const char *text, firmware_info_octet *result)
{
char high = text[0];
char low = text[1];
if (high == ' ') high = '0';
if (!is_digit(high) || !is_digit(low)) return 0;
*result = (uint8_t)(((uint8_t)(high - '0') * 10U) + (uint8_t)(low - '0'));
*result = (firmware_info_octet)(((firmware_info_octet)(high - '0') * 10U) + (firmware_info_octet)(low - '0'));
return 1;
}
static int four_digits(const char *text, uint16_t *result)
{
uint16_t value = 0U;
uint8_t i;
firmware_info_octet i;
for (i = 0U; i < 4U; ++i) {
if (!is_digit(text[i])) return 0;
value = (uint16_t)(value * 10U + (uint16_t)(text[i] - '0'));
@@ -37,7 +37,7 @@ static int four_digits(const char *text, uint16_t *result)
firmware_info_status_t firmware_info_validate(const firmware_info_t *info)
{
uint8_t i;
firmware_info_octet i;
if (info == NULL) return FIRMWARE_INFO_INVALID_ARGUMENT;
if ((info->major > 255U) || (info->minor > 255U) || (info->patch > 999U)
|| (info->year < 2000U) || (info->year > 2199U)
@@ -56,7 +56,7 @@ firmware_info_status_t firmware_info_validate(const firmware_info_t *info)
firmware_info_status_t firmware_info_set_build_id(firmware_info_t *info,
const char *build_id)
{
uint8_t i;
firmware_info_octet i;
if ((info == NULL) || (build_id == NULL)) return FIRMWARE_INFO_INVALID_ARGUMENT;
for (i = 0U; i < FIRMWARE_INFO_BUILD_ID_SIZE && build_id[i] != '\0'; ++i) {
if ((build_id[i] < 0x20) || (build_id[i] > 0x7e))
@@ -71,8 +71,8 @@ firmware_info_status_t firmware_info_set_build_id(firmware_info_t *info,
firmware_info_status_t firmware_info_parse_build_stamp(
const char *date_text, const char *time_text, firmware_info_t *info)
{
uint8_t month;
uint8_t found = 0U;
firmware_info_octet month;
firmware_info_octet found = 0U;
if ((date_text == NULL) || (time_text == NULL) || (info == NULL))
return FIRMWARE_INFO_INVALID_ARGUMENT;
for (month = 0U; month < 12U; ++month) {
@@ -84,14 +84,14 @@ firmware_info_status_t firmware_info_parse_build_stamp(
|| !two_digits(&time_text[0], &info->hour)
|| !two_digits(&time_text[3], &info->minute)
|| !two_digits(&time_text[6], &info->second)) return FIRMWARE_INFO_OUT_OF_RANGE;
info->month = (uint8_t)(month + 1U);
info->month = (firmware_info_octet)(month + 1U);
return FIRMWARE_INFO_OK;
}
firmware_info_status_t firmware_info_to_registers(
const firmware_info_t *info, uint16_t *registers, size_t count)
{
uint8_t i;
firmware_info_octet i;
firmware_info_status_t status;
if ((registers == NULL) || (count < FIRMWARE_INFO_REGISTER_COUNT))
return FIRMWARE_INFO_INVALID_ARGUMENT;
@@ -104,24 +104,24 @@ firmware_info_status_t firmware_info_to_registers(
registers[6] = (uint16_t)(((uint16_t)info->hour << 8U) | info->minute);
registers[7] = info->second;
for (i = 0U; i < 4U; ++i)
registers[8U + i] = (uint16_t)(((uint16_t)(uint8_t)info->build_id[i * 2U] << 8U)
| (uint8_t)info->build_id[i * 2U + 1U]);
registers[8U + i] = (uint16_t)(((uint16_t)(firmware_info_octet)info->build_id[i * 2U] << 8U)
| (firmware_info_octet)info->build_id[i * 2U + 1U]);
return FIRMWARE_INFO_OK;
}
firmware_info_status_t firmware_info_to_le_bytes(
const firmware_info_t *info, uint8_t *payload, size_t size)
const firmware_info_t *info, firmware_info_octet *payload, size_t size)
{
uint16_t words[FIRMWARE_INFO_REGISTER_COUNT];
uint8_t i;
firmware_info_octet i;
firmware_info_status_t status;
if ((payload == NULL) || (size < FIRMWARE_INFO_PAYLOAD_SIZE))
return FIRMWARE_INFO_INVALID_ARGUMENT;
status = firmware_info_to_registers(info, words, FIRMWARE_INFO_REGISTER_COUNT);
if (status != FIRMWARE_INFO_OK) return status;
for (i = 0U; i < FIRMWARE_INFO_REGISTER_COUNT; ++i) {
payload[i * 2U] = (uint8_t)words[i];
payload[i * 2U + 1U] = (uint8_t)(words[i] >> 8U);
payload[i * 2U] = (firmware_info_octet)(words[i] & 0xffU);
payload[i * 2U + 1U] = (firmware_info_octet)(words[i] >> 8U);
}
return FIRMWARE_INFO_OK;
}
+8
View File
@@ -15,5 +15,13 @@ firmware_info_status_t firmware_info_port_describe(firmware_info_t *info)
info->patch = (uint16_t)FIRMWARE_VERSION_PATCH;
status = firmware_info_set_build_id(info, FIRMWARE_BUILD_ID);
if (status != FIRMWARE_INFO_OK) return status;
#ifdef FI_YEAR
/* New build pipeline: use the same UTC stamp as the static port/manifest.
* Existing consumers of make_build_id.ps1 keep their compiler-local stamp. */
info->year = FI_YEAR; info->month = FI_MONTH; info->day = FI_DAY;
info->hour = FI_HOUR; info->minute = FI_MINUTE; info->second = FI_SECOND;
return firmware_info_validate(info);
#else
return firmware_info_parse_build_stamp(__DATE__, __TIME__, info);
#endif
}
@@ -0,0 +1,15 @@
/* A required generated include deliberately makes a missing pre-build fail.
* Silently using LOCALDEV here could disguise an obsolete linked object. */
#include "firmware_identity.h"
#include "firmware_info_static.h"
/* On STM these 12 words occupy 24 bytes; on C28x sizeof reports 12 addressable
* 16-bit units, which are still 24 wire octets. Never use sizeof as a wire size.
* const keeps the identity inside the firmware, away from saved parameters. */
static const uint16_t firmware_identity_words[FIRMWARE_INFO_STATIC_COUNT] =
FIRMWARE_INFO_STATIC_INITIALIZER;
uint16_t firmware_info_static_read(unsigned index)
{
return index < FIRMWARE_INFO_STATIC_COUNT ? firmware_identity_words[index] : 0U;
}
+19
View File
@@ -0,0 +1,19 @@
/* Deliberately fixed vector, not production identity. Used only by host tests. */
#define FIRMWARE_VERSION_MAJOR 1U
#define FIRMWARE_VERSION_MINOR 2U
#define FIRMWARE_VERSION_PATCH 3U
#define FI_YEAR 2026U
#define FI_MONTH 10U
#define FI_DAY 5U
#define FI_HOUR 12U
#define FI_MINUTE 34U
#define FI_SECOND 56U
#define FIRMWARE_BUILD_ID "abcdef0+"
#define FI_ID_0 'a'
#define FI_ID_1 'b'
#define FI_ID_2 'c'
#define FI_ID_3 'd'
#define FI_ID_4 'e'
#define FI_ID_5 'f'
#define FI_ID_6 '0'
#define FI_ID_7 '+'
+74
View File
@@ -0,0 +1,74 @@
"""Host lifecycle tests: dirty dependencies, reproducible stamps, file integrity.
Run: python -m unittest discover -s c/firmware-info/tests -p test_identity.py
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
TOOL = Path(__file__).resolve().parents[1] / "tools/identity.py"
spec = importlib.util.spec_from_file_location("identity", TOOL)
identity = importlib.util.module_from_spec(spec)
spec.loader.exec_module(identity)
class IdentityTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.config = self.root / "config.json"
self.config.write_text(json.dumps({"product":"test", "target":"host", "version":[1,2,3]}))
def prepare(self, states):
with patch.object(identity, "git_state", side_effect=states), patch.dict(os.environ, {"SOURCE_DATE_EPOCH":"1791194400"}):
return identity.prepare(self.root, self.root, self.config, self.root)
def test_clean_and_dirty_dependency(self):
clean = {"commit":"abcdef0123456789", "dirty":False}
meta = self.prepare([clean,clean])
self.assertEqual(meta["build_id"], "abcdef01")
header = (self.root / "firmware_identity.h").read_text()
self.assertIn("#define FIRMWARE_VERSION_PATCH 3U",header)
dirty = dict(clean, dirty=True)
self.assertEqual(self.prepare([clean,dirty])["build_id"], "abcdef0+")
self.assertEqual(self.prepare([dirty,clean])["build_id"], "abcdef0+")
def test_missing_git_is_explicit(self):
unknown = {"commit":None,"dirty":None}
self.assertEqual(self.prepare([unknown,unknown])["build_id"], "NOGIT000")
def test_invalid_version(self):
self.config.write_text(json.dumps({"product":"x","target":"x","version":[1,2,1000]}))
with self.assertRaises(ValueError):
identity.prepare(self.root,self.root,self.config,self.root)
def test_untracked_source_is_dirty(self):
def git(*args):
subprocess.run(["git","-C",str(self.root),*args],check=True,capture_output=True)
git("init")
git("-c","user.name=Test","-c","user.email=test@example.invalid","commit","--allow-empty","-m","test")
# config.json is untracked and must prevent a clean-looking build ID.
self.assertTrue(identity.git_state(self.root)["dirty"])
def test_cli_seal_verify_and_corruption(self):
clean = {"commit":"abcdef0123456789", "dirty":False}
self.prepare([clean,clean])
image = self.root / "firmware.bin"
image.write_bytes(b"abc")
import sys
subprocess.run([sys.executable,str(TOOL),"seal","--image",str(image),"--metadata",str(self.root/"firmware_identity.json")],check=True,capture_output=True)
manifest = image.with_suffix(".bin.manifest.json")
meta = identity.verify(image,manifest)
self.assertEqual(meta["sha256"],"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
image.write_bytes(b"abd")
with self.assertRaises(ValueError): identity.verify(image,manifest)
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
/* One set of vectors checks static metadata against Climate's runtime encoder.
* This catches word order and ASCII order changes on the host, before MCU use. */
#include "firmware_identity.h"
#include "firmware_info.h"
#include "firmware_info_static.h"
#include "ump_protocol.h"
#include <assert.h>
static unsigned writes;
static uint16_t read_identity(void *context, uint16_t address)
{
(void)context;
return firmware_info_static_read(address - UMP_FIRMWARE_INFO);
}
static int write_identity(void *context, uint16_t address, uint16_t value)
{
(void)context; (void)address; (void)value;
writes++;
return 1;
}
int main(void)
{
firmware_info_t info;
uint16_t expected[12], reply[UMP_MAX_WORDS];
ump_octet frame[UMP_RTU_CAPACITY];
ump_backend backend = {0, read_identity, write_identity};
unsigned i, error;
info.major = FIRMWARE_VERSION_MAJOR; info.minor = FIRMWARE_VERSION_MINOR;
info.patch = FIRMWARE_VERSION_PATCH; info.year = FI_YEAR;
info.month = FI_MONTH; info.day = FI_DAY; info.hour = FI_HOUR;
info.minute = FI_MINUTE; info.second = FI_SECOND;
assert(firmware_info_set_build_id(&info, FIRMWARE_BUILD_ID) == FIRMWARE_INFO_OK);
assert(firmware_info_to_registers(&info, expected, 12) == FIRMWARE_INFO_OK);
for (i=0; i<12; ++i) assert(firmware_info_static_read(i) == expected[i]);
assert(firmware_info_static_read(12) == 0);
assert(ump_prepare(&backend, 3, UMP_FIRMWARE_INFO, 12, 1, reply, &error) == 12 && !error);
for (i=0; i<12; ++i) assert(reply[i] == expected[i]);
for (i=0; i<6; ++i) {
assert(ump_can_fragment(reply,12,i,42,0,frame) == 8);
assert(frame[0] == expected[2*i] >> 8 && frame[1] == (expected[2*i] & 255));
assert(frame[6] == 42 && frame[7] == 0);
}
assert(ump_rtu_response(&backend, 1, 3, UMP_FIRMWARE_INFO, 12, frame, sizeof(frame)/sizeof(frame[0])) == 29);
assert(frame[2] == 24 && ump_crc(frame,29) == 0);
assert(ump_validate(3, UMP_FIRMWARE_INFO+11, 1, 0) == 0);
assert(ump_validate(3, UMP_FIRMWARE_INFO+11, 2, 0) == 2);
assert(ump_validate(3, UMP_FIRMWARE_INFO, 0, 0) == 2);
assert(ump_validate(3, UMP_FIRMWARE_INFO, 0xffffU, 0) == 2);
assert(ump_validate(3, UMP_FIRMWARE_INFO+1, 11, 1) == 2);
ump_prepare(&backend,6,UMP_FIRMWARE_INFO,1,0,reply,&error);
assert(error == 2 && writes == 0);
return 0;
}
+124
View File
@@ -0,0 +1,124 @@
"""Build provenance and exact-file SHA-256, shared by CCS, Keil and CLI builds.
prepare runs BEFORE compiling; seal runs AFTER binary conversion and size checks.
The digest is external to the image, avoiding the self-hashing paradox. No MCU
protocol encoding lives here: the generated header contains scalar constants.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import subprocess
def git_state(repository: Path) -> dict:
"""Record full commits, and flag untracked sources as well as tracked edits.
Generated outputs must be ignored in the repository. Unlike Climate's older
helper, a new, untracked source file must not look like a clean release.
"""
def git(*args):
return subprocess.check_output(
["git", "-C", str(repository), *args], stderr=subprocess.DEVNULL,
text=True, encoding="utf-8").strip()
try:
commit = git("rev-parse", "HEAD")
dirty = bool(git("status", "--porcelain", "--untracked-files=normal"))
return {"commit": commit, "dirty": dirty}
except (OSError, subprocess.CalledProcessError):
return {"commit": None, "dirty": None}
def write_atomic(path: Path, text: str):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp")
temporary.write_text(text, encoding="utf-8", newline="\n")
temporary.replace(path)
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict:
settings = json.loads(config.read_text(encoding="utf-8"))
version = settings["version"]
if (len(version) != 3 or any(type(v) is not int for v in version)
or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))):
raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]")
for field in ("product", "target"):
if not isinstance(settings.get(field), str) or not settings[field].strip():
raise ValueError(f"missing {field}")
source, dependency = git_state(repository), git_state(templates)
# A clean application built with a modified templates checkout is dirty too.
known = source["commit"] is not None and dependency["commit"] is not None
dirty = bool(source["dirty"] or dependency["dirty"])
build_id = (source["commit"][:7] + "+" if dirty else source["commit"][:8]) if known else "NOGIT000"
# UTC has an explicit interpretation across build PCs; SOURCE_DATE_EPOCH is
# supported for reproducible release builds, not silently inferred from Git.
epoch = os.environ.get("SOURCE_DATE_EPOCH")
stamp = datetime.fromtimestamp(int(epoch), timezone.utc) if epoch else datetime.now(timezone.utc)
stamp = stamp.replace(microsecond=0)
if not 2000 <= stamp.year <= 2199:
raise ValueError("build year outside firmware-info v1 range")
metadata = {"schema": 1, "product": settings["product"], "target": settings["target"],
"version": version, "build_id": build_id, "built_at_utc": stamp.isoformat(),
"source": source, "templates": dependency,
"identity_contract": 1, "identity_register_count": 12}
constants = dict(zip(("FIRMWARE_VERSION_MAJOR", "FIRMWARE_VERSION_MINOR", "FIRMWARE_VERSION_PATCH"), version))
constants.update(dict(zip(("FI_YEAR", "FI_MONTH", "FI_DAY", "FI_HOUR", "FI_MINUTE", "FI_SECOND"),
(stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second))))
constants.update({f"FI_ID_{i}": ord(c) for i, c in enumerate(build_id)})
header = "/* Generated before compilation. Do not edit or commit. UTC stamp. */\n#ifndef FIRMWARE_IDENTITY_H\n#define FIRMWARE_IDENTITY_H\n"
header += f'#define FIRMWARE_BUILD_ID "{build_id}"\n'
header += "".join(f"#define {key} {value}U\n" for key, value in constants.items())
header += "#endif\n"
# Rewriting the header forces the identity compilation unit to be rebuilt.
# Sharing this directory between concurrent configurations is unsupported.
write_atomic(output / "firmware_identity.h", header)
write_atomic(output / "firmware_identity.json", json.dumps(metadata, indent=2) + "\n")
return metadata
def seal(image: Path, metadata_path: Path) -> dict:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
data = image.read_bytes()
if not data:
raise ValueError("empty firmware image")
# Seal records the exact file sent to the programmer, including SCI8 headers.
# It does not claim that the device has measured RAM/Flash or authenticated it.
metadata.update({"file": image.name, "size_bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest()})
write_atomic(image.with_suffix(image.suffix + ".manifest.json"), json.dumps(metadata, indent=2) + "\n")
write_atomic(image.with_suffix(image.suffix + ".sha256"), f'{metadata["sha256"]} {image.name}\n')
return metadata
def verify(image: Path, manifest: Path) -> dict:
metadata = json.loads(manifest.read_text(encoding="utf-8"))
data = image.read_bytes()
if len(data) != metadata["size_bytes"] or hashlib.sha256(data).hexdigest() != metadata["sha256"]:
raise ValueError("firmware size/SHA-256 does not match manifest")
return metadata
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("prepare")
for name in ("repository", "templates", "config", "output"):
p.add_argument("--" + name, type=Path, required=True)
p = sub.add_parser("seal")
p.add_argument("--image", type=Path, required=True)
p.add_argument("--metadata", dest="metadata_path", type=Path, required=True)
p = sub.add_parser("verify")
p.add_argument("--image", type=Path, required=True)
p.add_argument("--manifest", type=Path, required=True)
args = vars(parser.parse_args())
command = args.pop("command")
result = {"prepare": prepare, "seal": seal, "verify": verify}[command](**args)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()