Введение
Всем добра и здравия.
Понадобилось мне безопасно обновлять прошивки на коммерческих устройствах, используя CAN шину. Нужно спроектировать сам адаптер, который будет связывать ПК с устройством используя CAN, так же нужно добавить в устройство логику, которая сможет переписать прошивку или конфигурацию в самом себе.
Загрузчики до этого не писал, статьи на хабре не нашел, а хотелось. Вернее нашел, но только вводный ликбез, без практики)
Поэтому было решено разбить задачу на мелкие и начать с минимального примера. Подопытным будет BluePill на stm32f103c8t6.
В соответствии с декомпозицией задачи, у меня получилось так:
-
Учимся собирать основное приложение со "смещением".
-
Учимся прыгать из загрузчика в основную программу.
-
Учимся менять конфигурацию из загрузчика в основной программе.
-
Учимся писать файл с ПК в память по Virtual COM порт(VCP), передавая по 8 байт(имитация CAN пакета, адаптера то еще нет).
-
Добавляем шифрование и CRC.
-
Дописываем десктопное приложение.
-
Рефакторим целевое приложение.
В этой статье будут первые 3 итерации. Было решено рассматривать их на примере мигания светодиодом(на чем же еще?). Идея следующая: для основного приложения настраиваем таймер, который будет генерировать прерывание с заданным периодом. Мигаем светодиодом в прерывании. Так мы понимаем, что после прыжка из загрузчика все в порядке с настройкой таблицы векторов прерываний и прыгнули мы успешно.
Далее выносим период прерывания, как значение конфигурации, меняем его из загрузчика и прыгаем в основное приложение, смотрим изменился ли период мигания.
Сразу оговорюсь: все проверки в примере реализованы на минимально необходимом уровне для академического(не коммерческого) примера. Они достаточны для корректной демонстрации механизма загрузчика. Полноценная обработка ошибок, контроль целостности и защита флеша в реальном устройстве зависит от проекта и метода взаимодействия пользователя с устройством. Цель статьи: показать по шагам, как сделать минимальный каркас загрузчика.
Начнем с основного приложения:
Основное приложение и его смещение
#define TIM2_CLOCK_HZ 72000000
#define TIM2_IRQ_PERIOD_MS 1000;
#define TIM2_IRQ_PRIORITY 0
#include "stm32f1xx.h"
#include "rcc.h"
#include "tim.h"
#include "gpio.h"
#include <stdint.h>
int main(void){
__enable_irq();
RCC_Conf_72MHz_From_8HSE();
TIM_GenPurp_InitPeriodicIRQ_ms(TIM2,
TIM2_CLOCK_HZ,
TIM2_IRQ_PERIOD_MS,
TIM2_IRQ_PRIORITY);
GPIO_Conf();
while(1){
}
}
void TIM2_IRQHandler(void){
static uint8_t led_flag = 0;
if (led_flag == 0){
GPIOB->BSRR |= GPIO_BSRR_BS2;
led_flag = 1;
}else{
GPIOB->BSRR |= GPIO_BSRR_BR2;
led_flag = 0;
}
TIM2 -> SR &= ~ TIM_SR_UIF;
}
По умолчанию, до будущего изменения из загрузчика, переключение светодиода происходит раз в секунду(#define TIM2_IRQ_PERIOD_MS 1000), получаем период моргания 2 секунды.
Загружаем в МК - светодиод моргает, как задумывалось. Основное приложение работает.
Так, как загрузчик будет лежать выше в памяти МК, чем основное приложение, нам нужно "сместить" основное приложение на размер загрузчика. Делается это в файле линковщика STM32F103C8TX_FLASH.ld.
На данный момент таблица векторов прерываний кладется в самое начало, FLASH=0x08000000. В скрипте линковщика сейчас:
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 64K
}
.isr_vector :
{
KEEP(*(.isr_vector))
} >FLASH
Но если приложение загрузчика находится выше в памяти, то таблица векторов основного приложения должна начинаться от начала основного приложения, а не от начала флеша, для этого в скрипте линковщика нужно сдвинуть базовый адрес флеша:
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
FLASH (rx) : ORIGIN = 0x8000C00, LENGTH = 64K - 3K
}
Я решил отступить 3 страницы по 1К. Для других семейств, память может быть разбита на страницы большего размера, это важно уточнить в Reference_Manual раздел embedded flash memory.
Отступать нужно страницами, а не на произвольное количество памяти. Так как когда
мы будем переписывать из загрузчика какие-то данные, которые касаются основного приложения, стирать мы будем всю страницу, по другому stm32f103 не умеет, но об устройстве памяти и работе с ней позже.
После того, как файл линковщика отредактирован, можно делать сборку проекта - основное приложение со смещением готово.
Переходим к загрузчику.
Прыжок из загрузчика в основное приложение
Сначала теория по старту МК после ресета:
После сброса ядро Cortex-M3 делает три шага:
-
Читает первое слово по адресу 0x08000000 (или другой базовый адрес флеша). Это значение загружается в MSP (Main Stack Pointer).
-
Читает второе слово (адрес
0x08000004). Это адрес функции Reset_Handler. -
Переходит на выполнение по этому адресу. Эти два первых слова и последующие обработчики прерываний образуют таблицу векторов.
Можем посмотреть, как теория реализуется на практике:
Идем в .map файл и видим, что второе слово отличается от адреса Reset_Handler на 1
Разница связана с тем, что в .map указан фактический адрес расположения, а в прошивке хранится указатель на функцию с установленным младшим битом, это нужно ядру для понимания Thumb режима. Сам процессор отбрасывает младший бит и переходит по фактическому адресу:
Вернемся к загрузчику.
У загрузчика и основного приложения разные таблицы векторов. Поэтому перед запуском основного приложения необходимо, чтобы ядро видело правильную таблицу и корректно начало выполнение. Для этого нужно установить регистр SCB->VTOR (Vector Table Offset Register) на адрес таблицы основного приложения.
Таким образом для прыжка в основное приложение нужно сделать следующие шаги:
-
Загрузить в MPS значение из первого слова основного приложения (т.е. фактическое значение верхушки стека, которое хранится по адресу 0x08000C00).
-
Считать адрес Reset_Handler из второго слова основного приложения (т.е. фактический адрес функции, хранящийся по адресу 0x08000C04).
-
Установить SCB->VTOR = 0x08000C00, чтобы ядро использовало таблицу векторов основного приложения (сам адрес начала таблицы, а не значение, хранящееся по нему).
-
Вызвать Reset_Handler основного приложения для старта его выполнения.
Получаем такую функцию:
void JumpToApp(uint32_t app_addr){
typedef void (*pFunction)(void);
uint32_t app_stack = *(volatile uint32_t*)app_addr;
uint32_t app_reset_handler_addr = *(volatile uint32_t*)(app_addr + 4);
pFunction jump_to_app;
if (((app_stack < 0x20000000) || (app_stack > 0x20005000))){
return;
}
__disable_irq();
DeInitPeriph();
__set_MSP(app_stack);
SCB->VTOR = app_addr;
jump_to_app = (pFunction)app_reset_handler_addr;
jump_to_app();
}
Разбираем построчно:
typedef void (*pFunction)(void);
Вводим псевдоним для указателя типа void (*)(void) - указатель на функцию,
которая ничего не принимает и не возвращает, в нашем случае это Reset_Handler
uint32_t app_stack = *(volatile uint32_t*)app_addr;
Получаем адрес верхушки стека основного приложения или по другому: читаем значение, которое лежит по адресу app_addr(первое слово прошивки основного приложения)
uint32_t app_reset_handler_addr = *(volatile uint32_t*)(app_addr + 4);
Получаем адрес Reset_Handler основного приложения или по другому: читаем значение, которое лежит по адресу (app_addr+4)(второе слово прошивки основного приложения)
pFunction jump_to_app;
Объявляем переменную, которая является указателем на функцию(в нашем случае Reset_Handler). При вызове jump_to_app(); процессор начинает выполнять функцию Reset_Handler основного приложения
if (((app_stack < 0x20000000) || (app_stack > 0x20005000))){
return;
}
Проверяем, что MSP лежит в диапазоне RAM, если нет остаемся в загрузчике и как-то обрабатываем эту ситуацию.
__disable_irq();
DeInitPeriph();
Запрещаем все прерывания (не забывайте включить прерывания в основном приложении __enable_irq(), а то я так полтора часа тупил и ничего понять не мог). Деинитим всю периферию, которую настроили в загрузчике.
Я пробовал этого не делать и на маленьком конфиге с лампочкой все работает, но делать так не надо, потому, что во время прыжка может сработать прерывание и все обрушится.
__set_MSP(app_stack);
Устанавливаем начальное значение указателя стека основного приложения(стандартная функция CMSIS)
SCB->VTOR = app_addr;
Устанавливаем адрес начала таблицы векторов прерываний основного приложения.
SCB - System Control Block
VTOR (Vector Table Offset Register) — регистр, который задаёт адрес начала таблицы векторов прерываний)
jump_to_app = (pFunction)app_reset_handler_addr;
(pFunction)app_reset_handler_addr - преобразуем число app_reset_handler_addr(адрес Reset_Handler) в указатель типа void (*)void и сохраняем его в переменную jump_to_app
jump_to_app();
вызываем функцию Reset_Handler основного приложения(прыгаем из загрузчика в основное приложение)
main.c загрузчика выглядит так:
#define APP_ADDR 0x08000C00
#include "stm32f1xx.h"
#include "rcc.h"
#include "bootloader.h"
int main(void)
{
RCC_Conf_72MHz_From_8HSE();
JumpToApp(APP_ADDR);
while(1){
}
}
Запускаем сборку и прошиваем.
Загрузка в МК двух прошивок
Делается все просто. Для прошивки загрузчика стартовый адрес оставляем, как есть 0x8000000. Как видим, стирает он сектора(страницы памяти), только те, которые нужны, в нашем случае 2К.
Для прошивки основного приложение, ну��но изменить стартовый адрес на тот, куда вы его сдвинули в линковщике, в моем случае 0x8000C00, как видим стирает он от базового сегмента, в нашем случае, это 3 страница памяти, так что загрузчик выше остается в памяти.
На этом прошивка завершена.
Изменение конфигурации основного приложения из загрузчика
Выделение отдельной секции памяти для конфигурации основного приложения
Задач у меня две: либо обновить всю прошивку, либо изменить конфигурацию.
Под конфигурацией основного приложения, я понимаю переменные, которые определяют поведение приложения, инициализируются при сборке прошивки и могут быть изменены пользователем через какой-либо интерфейс так, что при отключении питания они останутся измененными.
В нашем примере с миганием светодиода таким параметром будет TIM2_IRQ_PERIOD_MS.
Чтобы линковщик гарантированно положил наши переменные по заданному адресу, нужно в скрипте линковщика выделить отдельную секцию. Делается это так:
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
FLASH (rx) : ORIGIN = 0x8000C00, LENGTH = 64K - 3K
CONFIG (rx) : ORIGIN = 0x08001400, LENGTH = 1K
}
Начальный адрес секции и длина выбирается на ваше усмотрение, главное, чтобы адрес был выровнен по страницам памяти и не пересекался с вашим приложением и не выходил за границы флеша.
Ниже по скрипту в описании секций нужно добавить:
SECTIONS
{
.
.
.
.
.config :
{
KEEP(*(.config*))
} > CONFIG
}
Файл линковщика готов.
Так как в будущем параметров будет больше, вынесем их в отдельный файл app_config.c, здесь для наглядности сделаем 4 параметра конфига(использовать будем только один)
#include <stdint.h>
__attribute__((section(".config"), used, aligned(2)))
volatile uint16_t TIM2_IRQ_PERIOD_MS = 1000;
__attribute__((section(".config"), used, aligned(2)))
volatile uint16_t TIM2_IRQ_PERIOD_MS_2 = 500;
__attribute__((section(".config"), used, aligned(2)))
volatile uint16_t TIM2_IRQ_PERIOD_MS_3 = 1;
__attribute__((section(".config"), used, aligned(2)))
volatile uint16_t TIM2_IRQ_PERIOD_MS_4 = 2;
Используются эти параметры через extern в main.c
main.c основного приложения с конфигурацией
#define TIM2_CLOCK_HZ 72000000
#define TIM2_IRQ_PRIORITY 0
#include "stm32f1xx.h"
#include "rcc.h"
#include "tim.h"
#include "gpio.h"
#include <stdint.h>
void GPIO_Conf(void);
extern volatile uint16_t TIM2_IRQ_PERIOD_MS;
int main(void){
__enable_irq();
RCC_Conf_72MHz_From_8HSE();
TIM_GenPurp_InitPeriodicIRQ_ms(TIM2,
TIM2_CLOCK_HZ,
TIM2_IRQ_PERIOD_MS,
TIM2_IRQ_PRIORITY);
GPIO_Conf();
while(1){
}
}
void TIM2_IRQHandler(void){
static uint8_t led_flag = 0;
if (led_flag == 0){
GPIOB->BSRR |= GPIO_BSRR_BS2;
led_flag = 1;
}else{
GPIOB->BSRR |= GPIO_BSRR_BR2;
led_flag = 0;
}
TIM2 -> SR &= ~ TIM_SR_UIF;
}
Собираем проект и идем смотреть, как наши данные выглядят в прошивке:
Видим, что наши переменные лежат по адресу 0x08000800, в то время, как мы указывали 0x08001400. Дело в том что ST-Link Utility при записи отображает прошивку не с 0x08000000, а с 0x00000000(начало бинарного файла), смещение у нас 0x08000C00 + 0x08000800(конец бинарного файла) = 0x08001400. Когда прочитаем память прошитого МК вместе с загрузчиком, все встанет на свои места. Таким образом, лежит у нас все верно, можно переходить в программу загрузчика и писать функцию стирания и записи флеш.
Стирание одной страницы памяти
Немного теории по записи/стиранию флеша. Теория описывается документом PM0042 STM32F10xxx Flash programming, но один важный момент там не дублируется из reference manual'а: HSI осциллятор долен быть включен для работы с флешом.
Алгоритм стирания страницы следующий:
-
Разблокировать контроллер записи/стирания
-
Дождаться завершения предыдущей операции
-
Включить режим стирания
-
Записать адрес начала стираемой страницы
-
Запустить стирание
-
Дождаться завершения стирания
-
Выключить режим стирания
Получаем следующую функцию:
uint8_t Flash_ErasePage(uint32_t page_address){
if (page_address < FLASH_BASE_ADDR || page_address >= FLASH_BASE_ADDR + 64*1024){
return 1;
}
if (FLASH->CR & FLASH_CR_LOCK){
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB;
}
while (FLASH->SR & FLASH_SR_BSY);
FLASH->CR |= FLASH_CR_PER;
FLASH->AR = page_address;
FLASH->CR |= FLASH_CR_STRT;
while (FLASH->SR & FLASH_SR_BSY);
FLASH->CR &= ~FLASH_CR_PER;
if (FLASH->SR & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)){
return 2;
}
return 0;
}
Разберем построчно:
if (page_address < FLASH_BASE_ADDR || page_address >= FLASH_BASE_ADDR + 64*1024)
return 1;
Проверяем, что адрес страницы в пределах флеш памяти
if (FLASH->CR & FLASH_CR_LOCK){
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB;
}
Разблокируем контроллер записи/стирания(Flash program and erase controller (FPEC)). После ресета он заблокирован. Разблокировка производится двумя циклами записи ключей в регистр FLASH->KEYR. Ключи указаны в PM0042 STM32F10xxx Flash programming.
while (FLASH->SR & FLASH_SR_BSY);
Ожидаем завершение операции
FLASH->CR |= FLASH_CR_PER;
Включаем режим стирания
FLASH->AR = page_address;
Указываем адрес стираемой страницы
FLASH->CR |= FLASH_CR_STRT;
Стартуем стирание страницы
while (FLASH->SR & FLASH_SR_BSY);
Ожидаем завершение стирания
FLASH->CR &= ~FLASH_CR_PER;
Выключаем режим стирания
if (FLASH->SR & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)){
return 2;
}
Проверяем на ошибки.
Запись во флеш 16и бит
Писать во флеш можно только 16 бит за раз, это особенность stm32f103. Алгоритм похож на стирание:
-
Разблокировать контроллер записи/стирания
-
Дождаться завершения предыдущей операции
-
Включить режим программирования
-
Записать данные по заданному адресу
-
Запустить программирование флеш
-
Дождаться завершения записи
-
Выключить режим записи
-
Верифицировать записанные данные
Функцию получаем следующую(в моем случае только беззнаковые параметры, поэтому uint16_t data):
uint8_t Flash_Write16(uint32_t address, uint16_t data){
if (address % 2 != 0){
return 1;
}
volatile uint16_t* ptr = (uint16_t*)address;
if (FLASH->CR & FLASH_CR_LOCK){
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB;
}
while (FLASH->SR & FLASH_SR_BSY);
FLASH->CR |= FLASH_CR_PG;
*ptr = data;
while (FLASH->SR & FLASH_SR_BSY);
FLASH->CR &= ~FLASH_CR_PG;
if (FLASH->SR & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)){
return 2;
}
if (*ptr != data){
return 3;
}
return 0;
}
Разбираем построчно:
if (address % 2 != 0){
return 1;
}
Проверяем выравнивание адреса по 2 байта
volatile uint16_t* ptr = (uint16_t*)address;
Объявляем указатель на адрес, куда будем писать
if (FLASH->CR & FLASH_CR_LOCK){
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB;
}
Разблокируем флеш
while (FLASH->SR & FLASH_SR_BSY);
Ожидаем завершение операции
FLASH->CR |= FLASH_CR_PG;
Включаем режим программирования
*ptr = data;
Записываем данные
while (FLASH->SR & FLASH_SR_BSY);
Ожидаем завершения операции
FLASH->CR &= ~FLASH_CR_PG;
Выключаем режим программирования
if (FLASH->SR & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)){
return 2;
}
Проверяем на ошибки
if (*ptr != data){
return 3;
}
Верифицируем записанные данные
Теперь можно тестировать, как наш загрузчик изменит конфигурацию основного приложения.
Изменение конфигурации основного приложения из загрузчика
Переписываем main.c загрузчика
#define APP_ADDR 0x08000C00 // начальный адрес приложения
#define APP_CONFIG_ADDR 0x08001400 // начальный адрес секции конфигурации основного приложения
#include "stm32f1xx.h"
#include "rcc.h"
#include "bootloader.h"
int main(void)
{
RCC_Conf_72MHz_From_8HSE();
Flash_ErasePage(APP_CONFIG_ADDR);
Flash_Write16(APP_CONFIG_ADDR, (uint16_t)500);
JumpToApp(APP_ADDR);
while(1){
}
}
После прошивки и старта устройства, при чтении памяти, мы должны увидеть, что из наших 4х параметров конфигурации останется только один и изменится c 1000(0x3E8) на 500(0x1F4). Так же наш светодиод должен начать моргать с периодом 1с вместо начально заложенных 2с.
Как видим, так как мы записали только 1 параметр все остальное стерто, поэтому, если вы делаете подобную операцию, переписывайте все значения конфига, иначе они будут утеряны.
Светодиод ведет себя так, как должен. В памяти тоже все так, как мы задумывали.
Сделаем еще один тест для наглядности, запишем еще один параметр.
#define APP_ADDR 0x08000C00 // начальный адрес приложения
#define APP_CONFIG_ADDR 0x08001400 // начальный адресс секции конфигурации основного приложения
#include "stm32f1xx.h"
#include "rcc.h"
#include "bootloader.h"
int main(void)
{
RCC_Conf_72MHz_From_8HSE();
Flash_ErasePage(APP_CONFIG_ADDR);
Flash_Write16(APP_CONFIG_ADDR, (uint16_t)500);
Flash_Write16(APP_CONFIG_ADDR + 2, (uint16_t)3);
JumpToApp(APP_ADDR);
while(1){
}
}
Читаем память после перезапуска и работы загрузчика
Видим, что мы записали уже 2 параметра в секцию конфигурации.
Эпилог
Таким образом, на примере простого мигания светодиодом, мы написали минимальный каркас загрузчика и поняли, как работают основные механизмы. Далее, на основе получившихся функций, можно собирать более сложные: обновлять всю прошивку, добавлять свои степени защиты, проверок, верификации и тд. Прыжок из приложения назад в загрузчик выполняется аналогично.
Надеюсь, кому-нибудь пригодится.
Литература
-
RM0008 Reference manual STM32F101xx, STM32F102xx, STM32F103xx, STM32F105xx and STM32F107xx advanced Arm®-based 32-bit MCUs
-
PM0042 Programming manual STM32F10xxx Flash programming
-
The Definitive Guide to the ARM Cortex-M3. Joseph Yiu
Автор: Egor_Rad
