Введение
Всем добра и здравия.
Понадобилось мне безопасно обновлять прошивки на коммерческих устройствах, используя 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