В данной статье хотел бы написать о своем опыте создания загрузчика для STM32 с шифрованием прошивки. Я являюсь индивидуальным разработчиком, поэтому нижеприведенный код может не соответствовать каким-либо корпоративным стандартам
В процессе работы ставились следующие задачи:
- Обеспечить обновление прошивки пользователем устройства с SD-карты.
- Обеспечить контроль целостности прошивки и исключить запись некорректной прошивки в память контроллера.
- Обеспечить шифрование прошивки для исключения клонирования устройства.
Код писался в Keil uVision с использованием библиотек stdperiph, fatFS и tinyAES. Подопытным микроконтроллером был STM32F103VET6, но код может быть легко адаптирован под другой контроллер STM. Контроль целостности обеспечивается алгоритмом CRC32, контрольная сумма расположена в последних 4 байтах файла с прошивкой.
В статье не описано создание проекта, подключение библиотек, инициализация периферии и прочие тривиальные этапы.
Для начала стоит определиться с тем, что такое загрузчик. Архитектура STM32 подразумевает плоскую адресацию памяти, когда в одном адресном пространстве находится Flash-память, RAM, регистры периферии и всё остальное. Загрузчик — это программа, которая начинает выполняться при запуске микроконтроллера, проверяет, нужно ли выполнить обновление прошивки, если нужно — выполняет его, и запускает основную программу устройства. В данной статье будет описан механизм обновления с SD-карты, но можно использовать любой другой источник.
Шифрование прошивки производится алгоритмом AES128 и реализовано при помощи библиотеки tinyAES. Она представляет из себя всего два файла, один с расширением .c, другой с расширением .h, поэтому проблем с её подключением возникнуть не должно.
После создания проекта следует определиться с размерами загрузчика и основной программы. Для удобства размеры следует выбирать кратно размеру страницы памяти микроконтроллера. В данном примере загрузчик будет занимать 64 Кб, а основная программа займет оставшиеся 448 Кб. Загрузчик будет размещаться в начале Flash-памяти, а основная программа сразу после загрузчика. Это следует указать в настройках проекта в Keil. Загрузчик у нас начинается с адреса 0x80000000 (именно с него STM32 начинает выполнение кода после запуска) и имеет размер 0x10000, указываем это в настройках.
Основная программа будет начинаться с 0x08010000 и заканчиваться на 0x08080000 для удобства сделаем define со всеми адресами:
#define MAIN_PROGRAM_START_ADDRESS 0x08010000
#define MAIN_PROGRAM_END_ADDRESS 0x08080000
Так же внесем в программу ключи шифрования и инициализационный вектор AES. Данные ключи лучше сгенерировать случайным образом.
static const uint8_t AES_FW_KEY[] = {0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF, 0xAF};
static const uint8_t AES_IV[] = {0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA, 0xFA};
В данном примере вся процедура обновления прошивки построена в виде конечного автомата. Это позволяет в процессе обновления отображать что-то на экране, сбрасывать Watchdog и выполнять любые другие действия. Для удобства сделаем define с основными состояниями автомата, чтобы не путаться в числах:
#define FW_START 5
#define FW_READ 1000
#define FW_WRITE 2000
#define FW_FINISH 10000
#define FW_ERROR 100000
После инициализации периферии нужно проверить необходимость обновления прошивки. В первом состоянии производится попытка чтения SD-карты и проверка наличия файла на ней.
uint32_t t; /* Временная переменная */
uint32_t fw_step; /* Индекс состояния конечного автомата */
uint32_t fw_buf[512]; /* Буфер для считанного блока прошивки */
uint32_t aes_buf[512]; /* Буфер для расшифрованного блока прошивки равен */
/* Буферы равны размеру страницы Flash-памяти*/
uint32_t idx; /* Текущий адрес в памяти */
char tbuf[64]; /* Временный буфер для sprintf */
FATFS FS; /* Структура библиотеки fatFS - файловая система */
FIL F; /* Структура библиотеки fatFS - файл */
case FW_READ: /* Чтение прошивки */
{
if(f_mount(&FS, "" , 0) == FR_OK) /* Пробуем смонтировать SD-карту*/
{ /* Проверяем, есть ли файл с прошивкой. */
if(f_open(&F, "FIRMWARE.BIN", FA_READ | FA_OPEN_EXISTING) == FR_OK)
{
f_lseek(&F, 0); /* Переходим в начало файла */
CRC_ResetDR(); /* Сбрасываем аппаратный счетчик CRC */
lcd_putstr("Обновление прошивки", 1, 0); /* Выводим сообщение на экран */
/* Устанавливаем адрес чтения на начало основной программы */
idx = MAIN_PROGRAM_START_ADDRESS;
fw_step = FW_READ + 10; /* Переходим к следующему состоянию */
} else {fw_step = FW_FINISH;} /* Если файла нет - завершаем загрузчик */
} else {fw_step = FW_FINISH;} /* Если нет SD-карты - завершаем загрузчик */
break;
}
Теперь нам нужно провести проверку прошивки на корректность. Здесь сначала идет код проверки контрольной суммы, выполняющийся при окончании чтения файла, а потом само чтение. Возможно, так писать не следует, напишите в комментариях что вы об этом думаете. Чтение производится по 2 Кб для удобства работы с Flash-памятью, т.к. у STM32F103VET6 размер страницы памяти 2 Кб.
case FW_READ + 10: /* Проверка корректности файла с прошивкой */
{
/* В процессе показываем на экране, сколько байт считано */
sprintf(tbuf, "Проверка: %d", idx - MAIN_PROGRAM_START_ADDRESS);
lcd_putstr(tbuf, 2, 1);
if (idx > MAIN_PROGRAM_END_ADDRESS) /* Если прочитаи весь файл прошивки */
{
f_read(&F, &t, sizeof(t), &idx); /* Считываем 4 байта контрольной суммы */
/* Записываем считанные 4 байта в регистр данных периферийного блока CRC */
CRC->DR = t;
if(CRC->DR == 0) /* Если результат 0, то файл не поврежден */
{
/* Устанавливаем адрес записи на адрес начала основной программы */
idx = MAIN_PROGRAM_START_ADDRESS;
f_lseek(&F, 0); /* Переходим в начало файла */
fw_step = FW_READ + 20; /* Переходим к следующему состоянию */
break;
} else
{
lcd_putstr("Файл поврежден", 3, 2); /* Выводим сообщение на экран */
fw_step = FW_ERROR; /* Переходим к шагу обработки ошибки обновления */
break;
}
}
f_read(&F, &fw_buf, sizeof(fw_buf), &t); /* Считываем 2 Кб из файла в буфер */
if(t != sizeof(fw_buf)) /* Если не получилось считать */
{
lcd_putstr("Ошибка чтения", 3, 2);
fw_step = FW_ERROR; /* Переходим к шагу обработки ошибки обновления */
break;
}
/* Расшифровываем считанный блок прошивки */
AES_CBC_decrypt_buffer((uint8_t*)&aes_buf, (uint8_t *)&fw_buf, sizeof(fw_buf), AES_FW_KEY, AES_IV);
for(t=0;t<NELEMS(aes_buf);t++) /* Записываем блок в регистр CRC */
{
CRC->DR = aes_buf[t]; /* Запись ведем по 4 байта */
}
idx+=sizeof(fw_buf); /* Сдвигаем адрес на следующие 2 Кб */
break;
}
Теперь, если прошивка не повреждена, то нужно её снова прочитать, но на этот раз уже записать во Flash — память.
case FW_READ + 20: // Flash Firmware
{
/* В процессе показываем на экране, сколько байт записано */
sprintf(tbuf, "Запись: %d", idx - MAIN_PROGRAM_START_ADDRESS);
lcd_putstr(tbuf, 4, 2);
if (idx > MAIN_PROGRAM_END_ADDRESS) /* Когда записали всю прошивку */
{
lcd_putstr("Готово", 7, 3); /* Выводим сообщение на экран */
f_unlink("FIRMWARE.BIN"); /* Удаляем файл прошивки с SD-карты */
fw_step = FW_FINISH; /* Завершаем загрузчик */
break;
}
f_read(&F, &fw_buf, sizeof(fw_buf), &t); /* Считываем блок 2 Кб */
if(t != sizeof(fw_buf)) /* Если не получилось считать */
{
lcd_putstr("Ошибка чтения", 3, 3); /* Выводим сообщение на экран */
fw_step = FW_ERROR; /* Переходим к шагу обработки ошибки обновления */
break;
}
/* Расшифровываем считанный блок прошивки */
AES_CBC_decrypt_buffer((uint8_t*)&aes_buf, (uint8_t *)&fw_buf, sizeof(fw_buf), AES_FW_KEY, AES_IV);
FLASH_Unlock(); /* Разблокируем FLash-память на запись */
FLASH_ErasePage(idx); /* Стираем страницу памяти */
for(t=0;t<sizeof(aes_buf);t+=4) /* Записываем прошивку по 4 байта */
{
FLASH_ProgramWord(idx+t, aes_buf[t/4]);
}
FLASH_Lock(); /* Блокируем прошивку на запись */
idx+=sizeof(fw_buf); /* Переходим к следующей странице */
break;
}
Теперь для красоты создадим состояния для обработки ошибки и успешного обновления:
case FW_ERROR:
{
/* Можно что-то сделать при ошибке обновления */
break;
}
case FW_FINISH:
{
ExecMainFW(); /* Запускаем основную программу */
/* Дальнейший код выполнен не будет */
break;
}
Функцию запуска основной программы ExecMainFW() стоит рассмотреть подробнее. Вот она:
void ExecMainFW()
{
/* Устанавливаем адрес перехода на основную программу */
/* Переход производится выполнением функции, адрес которой указывается вручную */
/* +4 байта потому, что в самом начале расположен указатель на вектор прерывания */
uint32_t jumpAddress = *(__IO uint32_t*) (MAIN_PROGRAM_START_ADDRESS + 4);
pFunction Jump_To_Application = (pFunction) jumpAddress;
/*Сбрасываем всю периферию на APB1 */
RCC->APB1RSTR = 0xFFFFFFFF; RCC->APB1RSTR = 0x0;
/*Сбрасываем всю периферию на APB2 */
RCC->APB2RSTR = 0xFFFFFFFF; RCC->APB2RSTR = 0x0;
RCC->APB1ENR = 0x0; /* Выключаем всю периферию на APB1 */
RCC->APB2ENR = 0x0; /* Выключаем всю периферию на APB2 */
RCC->AHBENR = 0x0; /* Выключаем всю периферию на AHB */
/* Сбрасываем все источники тактования по умолчанию, переходим на HSI*/
RCC_DeInit();
/* Выключаем прерывания */
__disable_irq();
/* Переносим адрес вектора прерываний */
NVIC_SetVectorTable(NVIC_VectTab_FLASH, MAIN_PROGRAM_START_ADDRESS);
/* Переносим адрес стэка */
__set_MSP(*(__IO uint32_t*) MAIN_PROGRAM_START_ADDRESS);
/* Переходим в основную программу */
Jump_To_Application();
}
Сразу после запуска startup файл все переинициализировал, поэтому основная программа должна вновь выставить указатель на вектор прерывания внутри своего адресного пространства:
__disable_irq();
NVIC_SetVectorTable(NVIC_VectTab_FLASH, MAIN_PROGRAM_START_ADDRESS);
__enable_irq();
В проекте основной программы нужно указать правильные адреса:
Вот, собственно, и вся процедура обновления. Прошивка проверяется на корректность и шифруется, все поставленные задачи выполнены. В случае потери питания в процессе обновления устройство, конечно, закирпичится, но загрузчик останется нетронутым и процедуру обновления можно будет повторить. Для особо ответственных ситуаций можно заблокировать на запись страницы, в которых находится загрузчик через Option bytes.
Однако, в случае с SD-картой можно организовать для самого себя в загрузчике одно приятное удобство. Когда тестирование и отладка новой версии прошивки завершена, можно заставить само устройство по какому-то особому условию (например, кнопка или джампер внутри) зашифровать и выгрузить на SD-карту готовую прошивку. В таком случае останется только извлечь SD-карту из устройства, вставить в компьютер и выложить прошивку в интернет на радость пользователям. Сделаем это в виде ещё двух состояний конечного автомата:
case FW_WRITE:
{
if(f_mount(&FS, "" , 0) == FR_OK) /* Пробуем смонтировать SD-карту*/
{
/* Пробуем создать файл */
if(f_open(&F, "FIRMWARE.BIN", FA_WRITE | FA_CREATE_ALWAYS) == FR_OK)
{
CRC_ResetDR(); /* Сбрасываем блок CRC */
/* Устанавливаем адрес чтения на начало основной программы */
idx = MAIN_PROGRAM_START_ADDRESS;
fw_step = FW_WRITE + 10; /* Переходим к следующему состоянию */
} else {fw_step = FW_ERROR;} /* Переходим к шагу обработки ошибки */
} else {fw_step = FW_ERROR;} /* Переходим к шагу обработки ошибки */
break;
}
case FW_WRITE + 10:
{
if (idx > MAIN_PROGRAM_END_ADDRESS) /* Если выгрузили всю прошивку */
{
t = CRC->DR;
f_write(&F, &t, sizeof(t), &idx); /* Дописываем в конец файла контрольную сумму */
f_close(&F); /* Закрываем файл, сбрасываем кэш */
fw_step = FW_FINISH; /* Завершаем зарузчик */
}
/* Считываем 2 Кб прошивки из Flash-памяти в буфер */
memcpy(&fw_buf, (uint32_t *)idx, sizeof(fw_buf));
for(t=0;t<NELEMS(fw_buf);t++) /* Вычисляем CRC для считанного блока */
{
CRC->DR = fw_buf[t];
}
/* Шифруем прошивку */
AES_CBC_encrypt_buffer((uint8_t*)&aes_buf, (uint8_t *)&fw_buf, sizeof(fw_buf), AES_FW_KEY, AES_IV);
/* Записываем зашифрованный блок в файл */
f_write(&F, &aes_buf, sizeof(aes_buf), &t);
idx+=sizeof(fw_buf); /* Сдвигаем адрес считываемого блока */
break;
}
Вот, собственно и всё, что я хотел рассказать. В завершении статьи хотел бы пожелать вам после создания подобного загрузчика не забыть включить защиту от чтения памяти микроконтроллера в Option bytes.
Ссылки
Автор: PKav