2б. STM32U5 и TrustZone: чем новое поколение отличается от F4
STM32F405 — рабочая лошадь десятилетия: Cortex-M4, 168 МГц, мегабайт Flash. STM32U585 из новой линейки U5 по частоте почти такой же, но внутри другое ядро — Cortex-M33 — и у этого ядра есть то, чего у M4 нет совсем: TrustZone, аппаратное деление программы на «безопасную» и «небезопасную» части. Раздел о том, что даёт U5 по сравнению с F4 и как TrustZone устроен изнутри — с живым опытом в эмуляторе.
Цифры U585 взяты из его даташита, поведение TrustZone на U5 — из
справочного руководства по семейству, правила переходов и регистры —
из руководства по ядру Cortex-M33, параметры F405 — из описания пакета
поддержки для Keil. Опыт собран компилятором arm-none-eabi-gcc и прогнан
в QEMU на плате с Cortex-M33 (mps2-an505). Сверяет _verify/tz2b_check.py.
Как это называется в документации
Оригинал Перевод Secure / Non-secure state безопасное / небезопасное состояние Non-secure callable (NSC) область, откуда небезопасный код может войти в безопасный SAU — Security Attribution Unit блок атрибутов безопасности (настраивается программой) IDAU — Implementation Defined Attribution Unit атрибуты безопасности, заданные производителем кристалла SG — Secure Gateway команда-шлюз: вход в безопасное состояние veneer «прокладка»: шлюз из SG и перехода на настоящую функцию CMSE расширения языка C для безопасной части ( -mcmse)
2б.1 U585 против F405: что изменилось
| STM32F405RG | STM32U585CI | |
|---|---|---|
| Ядро | Cortex-M4 с FPU | Cortex-M33 с FPU и TrustZone |
| Частота | до 168 МГц | до 160 МГц, 240 DMIPS |
| Flash | 1024 КБ | 2 МБ |
| ОЗУ | 192 КБ | 786 КБ |
| Потребление в работе | 238 мкА/МГц (40 мА на 168 МГц) | 19,5 мкА/МГц при 3,3 В |
| Stop 2 (16 КБ ОЗУ сохранены) | Stop: 280 мкА, ОЗУ сохранено целиком | 4,0 мкА |
| Standby / Shutdown | 2,2 мкА / нет | 210 нА / 160 нА |
Главное здесь не частота — она почти та же, — а три вещи: вчетверо больше ОЗУ, режимы сна с потреблением в микро- и наноамперах (U — «ultra-low-power») и TrustZone. F405 выбирают, когда нужен проверенный камень с огромной базой примеров; U5 — когда устройство живёт от батареи, должно хранить ключи и обновляться по воздуху так, чтобы подменённая прошивка не добралась до секретов.
Как читать маркировку STM32U585CIU6: U — ultra-low-power,
585 — подсемейство с USB OTG и аппаратным AES, C — 48 выводов,
I — 2 МБ Flash, U — корпус UFQFPN, 6 — промышленный диапазон
−40…85 °C.
2б.2 Идея TrustZone: два мира в одном ядре
У Cortex-M33 с TrustZone каждый адрес имеет атрибут безопасности: безопасный (Secure), небезопасный (Non-secure) или безопасный с правом входа снаружи (NSC). Ядро в каждый момент находится в одном из двух состояний. В безопасном ему доступно всё; в небезопасном обращение к безопасному адресу блокируется и вызывает исключение.
Атрибут адреса собирается из двух источников:
- IDAU — его задал производитель кристалла, программа его не меняет;
- SAU — несколько областей, которые настраивает безопасная прошивка
через регистры с
0xE000EDD0.
Если источники расходятся, побеждает более строгий атрибут. Это правило в опыте ниже сработало буквально: SAU объявил окно шлюзов NSC, а IDAU платы считал его просто безопасным — и вход не состоялся.
Зачем отдельный тип NSC? Команда-шлюз SG разрешена только в нём. Если бы
её разрешили во всей безопасной памяти, любая константа, случайно
совпавшая с кодом SG, стала бы дверью внутрь. Ограничив шлюзы узким
окном, производитель прошивки точно знает, где у него двери.
2б.3 Как ходят между мирами
| Откуда | Куда переходит программа | Что происходит |
|---|---|---|
| безопасное | небезопасный адрес | переход только командой BXNS/BLXNS с младшим битом адреса 0; иначе SecureFault |
| небезопасное | безопасный адрес в окне NSC | если там стоит SG — вход в безопасное состояние; если нет — SecureFault |
| небезопасное | безопасный адрес вне NSC | SecureFault |
Обратно из безопасной функции возвращаются тоже BXNS. Никаких
системных вызовов, как в большой ОС: вызов безопасной функции — это обычный
BL, просто по адресу шлюза.
2б.4 Опыт: две прошивки в эмуляторе
Опыт — две отдельные программы, как и на настоящем U5.
- Безопасная (
s_main.c) стартует первой — ядро после сброса всегда в безопасном состоянии. Она размечает SAU (область 0 — код небезопасной части, 1 — её ОЗУ, 2 — окно шлюзов NSC), открывает небезопасной части вторые половины ОЗУ платы в её контроллерах памяти, ставит ей таблицу векторов и стек и прыгает в неё черезBLXNS. - Небезопасная (
ns_main.c) о безопасной знает только два адреса — шлюзы из файлаveneers.o, который создал компоновщик безопасной части. UART ей недоступен, поэтому печатает она через безопасную функцию.
Безопасная часть отдаёт наружу две функции:
uint32_t __attribute__((cmse_nonsecure_entry)) s_sign(uint32_t x)
{
s_calls++;
return x ^ secret_key; /* «подпись» — наружу уходит результат, не ключ */
}
void __attribute__((cmse_nonsecure_entry)) s_print(const char *msg)
{
/* указатель пришёл из небезопасного мира: читать только проверенное и только из своей копии */
char buf[33];
for (;;) {
if (!cmse_check_address_range((void *)msg, 32, CMSE_NONSECURE)) {
puts_s("s_print: отказ — адрес не небезопасный\n");
return;
}
int n = 0;
while (n < 32 && (buf[n] = msg[n]) != 0) /* копия: проверенное снаружи уже не подменить */
n++;
buf[n] = 0;
puts_s(buf);
if (n < 32)
return; /* строка кончилась внутри проверенных 32 байт */
msg += 32; /* следующие 32 байта — снова через проверку */
}
}
Прогон:
S: старт в безопасном состоянии
S: SAU и MPC настроены, BLK_MAX ssram-0 0x0000007f
S: переход в небезопасную часть
NS: запуск в небезопасном состоянии
NS: s_sign(0x12345678) = 0x17d8449f
NS: подсовываю s_print безопасный адрес 0x38000000
s_print: отказ — адрес не небезопасный
NS: читаю безопасное ОЗУ 0x38001000
SecureFault: SFSR=0x00000008 SFAR=0x00000000
done
Три вещи произошли по плану. Небезопасная часть получила «подпись»
0x17d8449f, но не ключ. Попытка подсунуть безопасной функции указатель
на безопасную память отбита самой функцией. Прямое чтение безопасного ОЗУ
остановило ядро исключением SecureFault.
Размеры: безопасная часть — 1293 байта кода, небезопасная — 482.
2б.5 Что сделал компилятор: шлюз и чистка регистров
Флаг -mcmse и атрибут cmse_nonsecure_entry заставляют компилятор
и компоновщик сделать для каждой такой функции две вещи.
Шлюз в окне NSC. По адресу 0x10100000 компоновщик положил 8 байт:
10100000 <s_sign>:
10100000: e97f e97f sg
10100004: f700 b86a b.w 100000dc <__acle_se_s_sign>
Сама функция живёт по обычному безопасному адресу 0x100000dc под именем
__acle_se_s_sign, и прыгнуть на неё напрямую из небезопасного мира
нельзя — там нет SG, и адрес не в окне NSC. Шлюзы компоновщик собирает
в секцию .gnu.sgstubs, а скрипт безопасной части ставит её в окно NSC.
Ключи -Wl,--cmse-implib -Wl,--out-implib=veneers.o пишут файл для
небезопасной части. Файл veneers.o содержит только адреса шлюзов: s_sign = 0x10100000, s_print = 0x10100008.
Чистка при выходе. Перед bxns lr функция перезаписывает r1–r3
и ip значением lr и сбрасывает флаги (msr CPSR_fs, lr). В r0 уходит
результат, а всё, что осталось в регистрах от вычислений с ключом, —
нет. Без этой чистки ключ мог бы утечь через «мусор» в регистрах.
Проверка указателей — ваша работа. Компилятор не знает, куда указывает
msg. Функция cmse_check_address_range спрашивает ядро (командой TT),
доступен ли весь диапазон небезопасному миру; без неё небезопасная часть
заставила бы безопасную прочитать для неё секрет. Проверяют ровно то, что
функция прочтёт: если проверить 32 байта, а печатать до нулевого байта,
строка без нуля у самой границы небезопасного ОЗУ уведёт печать в
безопасную память. Поэтому s_print читает кусками по 32 байта, каждый —
только после проверки, и печатает свою копию: подменить проверенную строку
из прерывания небезопасной части уже нельзя.
2б.6 SecureFault и регистр SFSR
SecureFault — отдельное исключение Cortex-M33; чтобы оно не превращалось
в HardFault, его включают битом SECUREFAULTENA (бит 19 в SHCSR). Причину
показывает SFSR по адресу 0xE000EDE4:
| Бит | Имя | Когда |
|---|---|---|
| 0 | INVEP | небезопасный код вошёл в безопасный по адресу без шлюза или вне NSC |
| 3 | AUVIOL | небезопасный код обратился к безопасному адресу |
В опыте прямое чтение дало SFSR = 0x00000008 — AUVIOL. Второй прогон —
та же прошивка, но без бита CODENSC в регистре NSCCFG платы — упал
на первом же вызове:
S: переход в небезопасную часть
SecureFault: SFSR=0x00000001 SFAR=0x00000000
Это INVEP: SAU считал окно 0x10100000 областью NSC, но IDAU платы — просто
безопасной, а побеждает более строгий. Руководство по ядру описывает эту
причину прямо: цель перехода — команда SG, но нет области SAU или IDAU
с флагом NSC.
SFAR в обоих случаях — ноль: адрес нарушения там действителен, только
когда поднят бит SFARVALID (бит 6); здесь он не поднят. Это особенность
эмулятора: на кристалле при AUVIOL на чтении данных ядро записывает в SFAR
адрес обращения и ставит SFARVALID. Поэтому сначала смотрят этот бит,
а причину определяют по битам SFSR.
2б.7 TrustZone на настоящем STM32U5
Опыт показывает механику ядра; у U5 вокруг неё своя обвязка.
- По умолчанию TrustZone выключен во всех STM32U5. Его включает бит
опций
TZENвFLASH_OPTR, и только при уровне защиты чтения RDP 0. Обратный путь дороже: снятьTZENможно только вместе с откатом защиты чтения с уровня 1 на уровень 0 — RDP сначала поднимают до 1, потом возвращают на 0, и откат стирает Flash и всё ОЗУ. С уровня 2 не откатиться вовсе, если заранее не записан ключ OEM2, да и с ним — только до уровня 1. Прежде чем ставитьTZENна отладочной плате, стоит знать, как его снимать. - После включения ядро выходит из сброса в безопасном состоянии, загрузочный адрес должен указывать в безопасную память, все прерывания NVIC и вся карта памяти через IDAU/SAU — безопасные.
- IDAU U5 делит адреса пополам: окно
0x0C00_0000–0x0FFF_FFFF— это безопасный или NSC-псевдоним той же Flash, что небезопасно видна с0x0800_0000. - Периферию и ОЗУ делит блок GTZC: контроллер TZSC назначает периферии безопасность, MPCBB — блокам ОЗУ, TZIC ловит нарушения. В опыте ту же роль играли контроллеры памяти платы (MPC).
- Пока
TZEN = 0, безопасные регистры Flash читаются нулями, а запись в них игнорируется: код, написанный под TrustZone, на выключенном камне «ничего не делает» без всяких ошибок.
Обычный путь — начать с TZEN = 0: U5 тогда работает как очень экономный
Cortex-M33 без разделения, и все примеры собираются одним проектом. Два
проекта (безопасный и небезопасный) нужны только тогда, когда TrustZone
действительно включён.
2б.8 Когда брать U5, а когда F4
| Задача | Выбор |
|---|---|
| Прибор от батарейки, годами в сне | U5: Stop 2 от 4,0 мкА, Standby 210 нА |
| Хранение ключей, защищённое обновление | U5 с TrustZone |
| Много ОЗУ под буферы, графику, модель | U5: 786 КБ |
| Готовый код и библиотеки под F4, серия идёт | F405 |
| Первые шаги с Cortex-M | F4 или F1: меньше сущностей |
Код на HAL переносится между сериями легче, чем на регистрах (раздел 5), но тактирование (раздел 6) и режимы сна (раздел 10) у U5 свои — их перечитывают заново.
2б.9 Грабли
Небезопасная часть падает на первом же вызове с INVEP (2б.6). SAU разметил NSC, а атрибут от производителя остался безопасным; побеждает строгий. На U5 шлюзы кладут в NSC-псевдоним Flash, на плате — включают соответствующий бит.
Компоновщик выкинул секцию шлюзов (2б.5). Если в .gnu.sgstubs
при разметке ещё нечего класть, ld сочтёт её пустой; символ внутри
секции её удерживает.
Безопасная функция доверилась указателю (2б.5). Всё, что пришло
из небезопасного мира, проверяют cmse_check_address_range, иначе
безопасная часть сама прочитает секрет для атакующего. Проверенный
диапазон должен совпадать с прочитанным, а работают с копией — иначе
строку без нуля или подменённую после проверки дочитают в безопасную память.
TrustZone «не работает», но и не мешает (2б.7). При TZEN = 0
безопасные регистры Flash — нули и пустая запись. Первым делом смотрят бит
опций.
Причина в SFAR — ноль (2б.6). Адрес там только при поднятом SFARVALID;
причину читают по битам SFSR.
2б.10 Итоги
- STM32U585 против F405: Cortex-M33 вместо M4, 160 МГц против 168, 2 МБ Flash и 786 КБ ОЗУ, сон от 160 нА и TrustZone.
- TrustZone делит адреса на безопасные, небезопасные и NSC; атрибут — строжайший из SAU и IDAU.
- Вход в безопасный мир — только через
SGв окне NSC, выход —BXNS. Компилятор с-mcmseделает шлюз и чистит регистры, проверку указателей пишет программист. - В опыте: подпись
0x17d8449fбез выдачи ключа, отказ на чужом указателе, SecureFault AUVIOL (0x00000008) на прямом чтении и INVEP (0x00000001) без NSC у IDAU. - На U5 TrustZone выключен, пока не поставлен бит
TZEN; периферию и ОЗУ делит GTZC.
Полные исходники
s_main.c — безопасная прошивка опыта 2б.4: разметка SAU, шлюзы NSC и переход в небезопасную часть.
/* Раздел 2б: TrustZone на Cortex-M33 — безопасная часть. QEMU mps2-an505 (ядро Cortex-M33 с TrustZone).
* Старт всегда в безопасном состоянии. Прошивка:
* 1) делит память атрибутами SAU: код и ОЗУ небезопасной части, окно шлюзов (NSC);
* 2) разрешает небезопасный доступ контроллерам памяти платы (MPC) к тем же областям;
* 3) ставит небезопасной части таблицу векторов и стек и переходит в неё командой BLXNS.
* Небезопасная часть видит только функции, помеченные cmse_nonsecure_entry, — через шлюз SG. */
#include <arm_cmse.h>
#include <stdint.h>
#define REG(a) (*(volatile uint32_t *)(a))
/* UART0 платы (CMSDK APB UART), безопасный адрес */
#define UART0 0x50200000u
#define UART_DATA REG(UART0 + 0x00)
#define UART_STATE REG(UART0 + 0x04)
#define UART_CTRL REG(UART0 + 0x08)
#define UART_BAUDDIV REG(UART0 + 0x10)
/* SAU — блок атрибутов безопасности ядра */
#define SAU_CTRL REG(0xE000EDD0)
#define SAU_RNR REG(0xE000EDD8)
#define SAU_RBAR REG(0xE000EDDC)
#define SAU_RLAR REG(0xE000EDE0)
#define SFSR REG(0xE000EDE4)
#define SFAR REG(0xE000EDE8)
#define SHCSR REG(0xE000ED24)
#define VTOR_NS REG(0xE002ED08) /* VTOR небезопасного состояния — через его псевдоним */
#define NS_CODE 0x00200000u /* небезопасный код: вторая половина ssram-0, небезопасный псевдоним */
#define NS_RAM 0x28100000u /* небезопасное ОЗУ: вторая половина ssram-1 */
static void putc_s(char c)
{
while (UART_STATE & 1u) {
}
UART_DATA = (uint32_t)c;
}
static void puts_s(const char *s)
{
while (*s)
putc_s(*s++);
}
static void puthex_s(uint32_t v)
{
static const char h[] = "0123456789abcdef";
puts_s("0x");
for (int i = 28; i >= 0; i -= 4)
putc_s(h[(v >> i) & 0xF]);
}
/* ── функции, которые небезопасная часть может вызвать ── */
static uint32_t secret_key = 0x5EC12E7u; /* живёт только в безопасной части, наружу не отдаётся */
volatile uint32_t s_calls;
uint32_t __attribute__((cmse_nonsecure_entry)) s_sign(uint32_t x)
{
s_calls++;
return x ^ secret_key; /* «подпись» — наружу уходит результат, не ключ */
}
void __attribute__((cmse_nonsecure_entry)) s_print(const char *msg)
{
/* указатель пришёл из небезопасного мира: читать только проверенное и только из своей копии */
char buf[33];
for (;;) {
if (!cmse_check_address_range((void *)msg, 32, CMSE_NONSECURE)) {
puts_s("s_print: отказ — адрес не небезопасный\n");
return;
}
int n = 0;
while (n < 32 && (buf[n] = msg[n]) != 0) /* копия: проверенное снаружи уже не подменить */
n++;
buf[n] = 0;
puts_s(buf);
if (n < 32)
return; /* строка кончилась внутри проверенных 32 байт */
msg += 32; /* следующие 32 байта — снова через проверку */
}
}
/* SecureFault: небезопасная часть полезла в безопасную память */
void SecureFault_Handler(void)
{
puts_s("SecureFault: SFSR=");
puthex_s(SFSR);
puts_s(" SFAR=");
puthex_s(SFAR);
puts_s("\ndone\n");
for (;;) {
}
}
static void mpc_all_ns(uint32_t mpc, uint32_t from_blk)
{
/* MPC платы: BLK_MAX — число слов таблицы, BLK_IDX — номер слова, BLK_LUT — по биту на блок (1 — небезопасный) */
uint32_t max = REG(mpc + 0x10);
for (uint32_t i = from_blk; i < max; i++) {
REG(mpc + 0x18) = i;
REG(mpc + 0x1C) = 0xFFFFFFFFu;
}
}
int main(void)
{
UART_BAUDDIV = 16;
UART_CTRL = 1; /* TX */
puts_s("S: старт в безопасном состоянии\n");
/* SAU: 0 — код небезопасной части, 1 — её ОЗУ, 2 — окно шлюзов (NSC) */
SAU_RNR = 0; SAU_RBAR = NS_CODE; SAU_RLAR = (NS_CODE + 0x1FFFE0u) | 1u;
SAU_RNR = 1; SAU_RBAR = NS_RAM; SAU_RLAR = (NS_RAM + 0xFFFE0u) | 1u;
SAU_RNR = 2; SAU_RBAR = 0x10100000u; SAU_RLAR = (0x10100000u + 0xFE0u) | (1u << 1) | 1u; /* NSC */
SAU_CTRL = 1u;
/* IDAU платы разрешает NSC в области кода 0x1xxxxxxx только битом CODENSC регистра NSCCFG;
* атрибут адреса — строжайший из двух: SAU говорит «NSC», IDAU — тоже должен */
#ifndef NO_CODENSC
REG(0x50080014u) |= 1u; /* NSCCFG.CODENSC */
#endif
/* MPC платы для ssram-0 и ssram-1: вторые половины — небезопасные */
uint32_t b0 = REG(0x58007000u + 0x10), b1 = REG(0x58008000u + 0x10);
mpc_all_ns(0x58007000u, b0 / 2);
mpc_all_ns(0x58008000u, b1 / 2);
puts_s("S: SAU и MPC настроены, BLK_MAX ssram-0 ");
puthex_s(b0);
puts_s("\n");
SHCSR |= 1u << 19; /* SECUREFAULTENA — отдельный обработчик, а не HardFault */
const uint32_t *ns_vec = (const uint32_t *)NS_CODE;
VTOR_NS = NS_CODE;
__asm volatile("msr msp_ns, %0" ::"r"(ns_vec[0]));
puts_s("S: переход в небезопасную часть\n");
typedef void __attribute__((cmse_nonsecure_call)) ns_fn(void);
ns_fn *ns_reset = (ns_fn *)cmse_nsfptr_create(ns_vec[1]); /* младший бит 0 — переход в небезопасное состояние */
ns_reset();
for (;;) {
}
}
/* ── старт ── */
extern uint32_t _estack;
void Reset_Handler(void)
{
main();
}
void Default_Handler(void)
{
puts_s("S: исключение без обработчика\n");
for (;;) {
}
}
__attribute__((section(".isr_vector"), used)) const void *const vectors[16] = {
&_estack, Reset_Handler, Default_Handler, Default_Handler, Default_Handler, Default_Handler, Default_Handler, SecureFault_Handler,
};
Дальше: раздел 2в «STM8: восьмибитный родственник — что взять, а что нет» — чем восьмибитное ядро ST похоже на STM32 и где оно всё ещё к месту.