Загрузчик

Программисты тоже люди, а им свойственно допускать ошибки. Часть из этих ошибок может быть обнаружена и исправлена в ходе тестирования, однако это не исключает возможность упустить некоторые из них и выпустить на рынок устройство с «глючной» прошивкой и багами (англ. bug, жук). Какие-то будут не критичными, а другие резко понизят потребительские свойства. Разумеется, партию можно отозвать, перепрошить на заводе и отправить обратно. Такой подход практикуется: компания Toyota отзывала целые партии автомобилей. Это сильно бьет по бюджету и репутации. Некоторые компании и вовсе выпускают устройство с заведомо недоделанной прошивкой. Например, портретный режим в iPhone 7 был анонсирован на презентации, но в действительности эта функция стала доступна спустя пару месяцев с обновлением операционной системы.

Этот термин, по одной из версий, вошёл в обиход после обнаружения Грейс Хоппер обгоревшего мотылька между замкнувшими контактами одной из плат компьютера Harvard Mark II (9 сентября 1946 года) при попытке найти ошибку в программе.

Конечно, у конечного пользователя вряд ли окажется под рукой программатор, да и вверять ему такой критический процесс опасно. По закону Мёрфи в руках неопытного пользователя гарантированно что-то пойдет не так. Плюс ко всему, прошивку придется поместить в открытый доступ, а значит, недобросовестные конкуренты смогут просто скопировать устройство.

Как правило, крупные компании предоставляют прошивку своих устройств сервисным центрам по запросу.

Для упрощения процесса перепрошивки используется загрузчик (англ. bootloader, от bootstrap, петля сзади ботинка, + loader, загрузчик). С его помощью устройство способно само себя прошить, зачастую без разбора корпуса и даже без подключения каких-либо проводов, по воздуху. Загрузчик — это не что иное, как программа, которая загружается раньше основного приложения и принимает решение о необходимости обновляться (или чего-то еще, например, произвести диагностику оборудования). Прошивку он может брать из разных источников: из памяти или по интерфейсу передачи данных (UART, SPI, I2C, CAN, USB и т.д.) от другого устройства или узла. В большинстве микроконтроллеров реализация загрузчика уже имеется — например, в Arduino (используется микроконтроллер AVR от компании Atmel) загрузчик позволяет заливать образ прошивки через UART, лишая при этом возможности отлаживать программу, т.е. вы не можете остановить выполнение программы и посмотреть состояние регистров, значение переменных и т.д. Микроконтроллеры STM32 могут загружаться из трех разных мест, в зависимости от логических уровней на ножках boot0 и boot1. Если boot0 подтянута к земле, то загрузка начинается из флеш-памяти, начиная с адреса 0x08000000 (обычный режим работы). Если boot1 подтянута к земле, а boot0 к питанию, то загрузка начинается из системной области памяти, которую нельзя изменить. Там хранится записанный на заводе UART-загрузчик. Если обе ножки подтянуты к питанию, микроконтроллер попробует запуститься, считывая прошивку из оперативной памяти, начиная с 0x20000000.

Каждый раз включая свой компьютер, запускается BIOS, который начинает так называемю процедуру Power-On Self-Test или сокр. POST, т.е. самотестирование при включении. Вашему устройству наличие самопроверки при старте может быть тоже необходимо. Если вы управляете плавильной печью, то запускаться при неработающем термодатчике может быть опасно.

При наличии FMC (Flexible Memory Controller) из пяти: FMC NVM, FMC SDRAM.

В идеале, загрузчик должен находиться в недоступной для записи области, так как его главная задача — обновить и/или запустить пользовательское приложение. (Сам загрузчик обычно не обновляется, так как это чревато превращением устройства в кирпич.) С учетом способов загрузки выбор невелик: самописный загрузчик придется записывать и запускать как обычную программу, а уже затем передавать управление приложению. По сути, придется создавать два приложения.

На крайнем левом рисунке изображено классическое приложение, т.е. из таких, которые мы реализовывали до этого в главах про машину состояний и ОСРВ. Карта памяти посередине иллюстрирует работу с системным загрузчиком, который находится в области памяти, доступной только для чтения. И последняя картинка, справа, это тот случай, когда в программе используется самописный загрузчик.

Так как загрузчик располагается в памяти до самой прошивки, то его стоит делать настолько маленьким, насколько это возможно. Стоит, однако, обратить внимание: во флеш-памяти нельзя стереть произвольный бит. Особенности реализации позволяют стирать только всю страницу (англ. page) целиком, и в нашем конкретном случае это 1 Кб памяти, а в stm32l4, например, — это 2 Кб. Таким образом, минимальный размер загрузчика 1 Кб: даже если вы уместите его в 100 байт, остальные 924 байта вы использовать не сможете, не затерев при этом загрузчик. В случае если он не поместился в 1024 и занял 1025 байт, под загрузчик придется отвести уже 2 страницы.

Концептуально всё очень просто, но что бы написать загрузчик, придётся разобраться в процессе запуска микроконтроллера чуть детальнее. Перечитайте подглаву «Компоновщик», дабы освежить память. Как мы уже там упомянули, хоть формально точкой входа в программу служит функция main(), но на самом деле до неё выполняется обработчик сброса, Reset_Handler. Он выставляет адрес стека (MSP), _estack берётся из скрипта компоновщика, и передаёт управление функции LoopCopyDataInit.

    .section	.text.Reset_Handler
	.weak	Reset_Handler
	.type	Reset_Handler, %function
Reset_Handler:
  ldr   sp, =_estack    /* Set stack pointer */

/* Copy the data segment initializers from flash to SRAM */
  movs	r1, #0
  b	LoopCopyDataInit

LoopCopyDataInit инициализирует секцию .data и передаёт управление LoopFillZerobss.

LoopCopyDataInit:
	ldr	r0, =_sdata
	ldr	r3, =_edata
	adds	r2, r0, r1
	cmp	r2, r3
	bcc	CopyDataInit
	ldr	r2, =_sbss
	b	LoopFillZerobss

LoopFillZerobssинициализирует секцию .bss, вызывает функцию SystemInit() из system_stm32f1xx.c (или тот который соответствует контроллеру вашего семейства), __libc_init_array предназначен для вызова конструкторов C++ классов и только затем управление переходит к main().

LoopFillZerobss:
	ldr	r3, = _ebss
	cmp	r2, r3
	bcc	FillZerobss

/* Call the clock system intitialization function.*/
    bl  SystemInit
/* Call static constructors */
    bl __libc_init_array
/* Call the application's entry point.*/
	bl	main

Важно отметить, что сама функция Reset_Handler объявлена как слабая,

	.weak	Reset_Handler

т.е. она может быть переопределена в коде.

Следующее о чём нужно знать — это то как, компилятор должен уложить наш код, чтобы программа работала корректно. В самом начале прошивки лежит начальное значение указателя стека, который задаётся в Reset_Hadler, т.е. MSP, после чего укладывается таблица векторов прерываний от Reset_Handler далее вниз по списку определённого в startup-файле.

Чтобы убедиться в этом, загляните в файл компоновщика, а конкретно в начало определения секции FLASH:

/* Sections */
SECTIONS
{
  /* The startup code into "FLASH" Rom type memory */
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) /* Startup code */
    . = ALIGN(4);
  } >FLASH

KEEP указывает линковщику не оптимизировать данный участок и оставить его нетронутым. А затем, ещё и в startup-файл:

 	.section	.isr_vector,"a",%progbits
	.type	g_pfnVectors, %object
	.size	g_pfnVectors, .-g_pfnVectors

g_pfnVectors:
	.word	_estack
	.word	Reset_Handler
	.word	NMI_Handler
	# ...

Зачем нам все это нужно? Для понимания последовательности действий, которую необходимо произвести перед переходом к участку кода расположенному в другой части флеш-памяти.

Во-первых, когда запускается Reset_Handler предполагается, что вся периферия находится в состоянии по умолчанию, значит: если в загрузчике использовалась какая-либо перифирия, то её нужно сбросить в начальное состояние. Во-вторых, нельзя допустить чтобы во время перехода к основной программе произошло какое-либо прерывание, т.е. все прерывания должны быть отключены. В-третьих, таблица векторов прерываний — это ни что иное как массив указателей на функции-обработчики. Очевидно, в разных прошивках адреса могут и будут отличаться! Заливая прошивку в адрес отличный от 0x08000000 контроллер сам не поймёт откуда эти указатели нужно брать, а точнее будет пытаться их брать начиная с адреса 0x08000004 (начало флеш-памяти плюс 4 байта, .word, под _estack), где находится загрузчик. Поэтому, нужно перенести адрес таблицы векторов прерываний в новое место. В-четвёртых, так как ключевой частью выполнения какой-либо программы является стек, то следом нужно сбросить стек, задав новое положение (_estack). И только после этого, в-пятых, можно переходить на Reset_Handler из прошивки основной программы (так как точка входа она, а не main()).

Когда происходит запись (стирание) во флеш, контроллер памяти не позволит читать из неё (см. PM0075) и отправит программу в HardFault(). Так как код прерывания находится внутри флеша, то выполнить его в процессе стирания не возможно. Если по какой-то причине нужно обеспечить возможность их выполнения, то таблицу векторов, а так же код самих функций нужно вынести либо в оперативную память, либо во внешнюю.

Если используется библиотека HAL, сбросить периферию можно следующей функцией:

HAL_DeInit();

Отключить прерывание можно интринсик-функцией __disable_irq(). Их нужно будет включить обратно в основной прошивке через интринсик-функцию __enable_irq().

Для того чтобы указать новое положение таблицы векторов прерываний в Cortex-M0+/M1/M3/M4/M7 используется регистр VTOR, из System Control Block (заметим что в Cortex-M0 такого регистра нет, как перенести вектор в таком случае рассмотрим чуть позже).

// boorloader size is 10 kb =>
// main application starts at 0x08002800
#define MAIN_APP_START_ADDRESS 0x08002800
// ...
SBC->VTOR = MAIN_APP_START_ADDRESS;

Далее установим MSP.

__set_MSP(*((volatile u32*) MAIN_APP_START_ADDRESS));

Если вы специально ничего не меняете, то оба значения _estack, что в основной программе, что в загрузчике, будут эквивалентны, а значит и нужды забирать данные из начала основной программы не обязательно, можно взять из прошивки загрузчика. Смысл в том, чтобы сбросить стек, поэтому нужно не забыть вызывать эту функцию.

Осталось создать указатель на Reset_Handler основной прошивки, и вызвать её.

int main(void) {
    // ...
    typedef void (*pApplication)(void);
    pApplication app = *((volatile uint32_t*) (MAIN_APP_START_ADDRESS + 4));
    app();
    while(1);
}

Для того чтобы среда подготавливала файл основной прошивки с правильными адресами, нужно отредактировать файл компоновщика, в частности указать начальный адрес и объём доступной памяти. Так же внутри скрипта заведём переменную, в которой будет хранится адрес основной прошивки, для чего это нужно — увидим чуть позже.

_app_addr = ORIGIN(FLASH);

/* Memories definition */
/* FLASH    (rx)    : ORIGIN = 0x8000000,   LENGTH = 64K */
MEMORY
{
  RAM    (xrw)    : ORIGIN = 0x20000000,   LENGTH = 20K
  FLASH   (rx)    : ORIGIN = 0x08002800,   LENGTH = 64K-10K
}

После вызова app() управление передаётся Reset_Handler другой прошивке, в которой… будет вызвана функция SystemInit() (файл system_stm32f1xx.c). И тут нужно посмотреть на её содержимое:

#define VECT_TAB_OFFSET  0x00 /*!< Vector Table base offset field.
                                   This value must be a multiple of 0x200. */

void SystemInit(void) {
// ...
  /* Configure the Vector Table location add offset address ------------------*/
#ifdef VECT_TAB_SRAM
  SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM */
#else
  SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal FLASH */
#endif
}

Выходит, стандартная реализация Reset_Handler затирает то значение VTOR, которое мы устанавливаем в загрузчике (т.е. его там можно и не устанавливать). Выхода два, либо переопределить Reset_Handler, либо выставить значение VTOR заново. Значение адреса лучше взять из заголовочного файла с настройкой самого загрузчика. Так как мы его не создавали в конкретном примере, давайте лучше получим доступ к переменной, объявленной в файле линковщика!

Вариант редактирования библиотеки мы не рассматриваем, в вашей системе сборки для загрузчика и основной программы могут использоваться одни и те же файлы, да и менять содержание библиотеки плохая практика — она начнёт делать не то, что от неё ожидает разработчик (сторонний).

extern uint32_t _app_addr;

int main(void) {
    SCB->VTOR = (uint32_t)&_app_addr;
    __enable_irq();
    // ...
}

Согласно комментарию к макросу VECT_TAB_OFFSET значение должно быть кратно 0x200 (512 байт, на некоторых МК адрес должен быть кратен сектору памяти). Но располагать значение посреди страницы не очень удобно. В нашем примере под загрузчик отводится 10 Кб, т.е. сама прошивка начинается с 10 страницы (0x08002800).

На этом программа загрузчика завершена, осталось лишь добавить логику — откуда-то взять новую прошивку, проверить её целостность и записать её во флеш по указанному адресу.

В STM32 для этих целей можно использовать модуль CRC (англ. cyclic redundancy check).

Перенос таблицы в Cortex-M0

Всё вышеперечисленное сработает на Cortex-M0+/M3/M4/M7, но не сработает на Cortex-M0 (прерывания работать не будут), так как в нём нет регистра VTOR. Что делать в этом случае? Решение есть. Дело в том, что на самом деле все работает с адреса 0x00000000, а не с 0x08000000 или 0x20000000. При запуске контроллер считывает состояние ножек BOOTx и ремапит (создаёт алиас) для адреса 0x000000000. Т.е. при загрузке с флеш, алиасом адресу 0x08000000 служит 0x00000000. Так как адреса в памяти — это просто числа и не важно где они хранятся, то выйти из ситуации можно просто скопировав таблицу из флеш в оперативную память и переключиться на неё.

Подгрузить таблицу в память просто, достаточно создать два указателя, а затем пройтись по ним как по массиву, копируя из одного в другой.

// depends on your MCU
#define N_HANDLERS           67
// ...
uint32_t *tabSRAM = (uint32_t *)SRAM_BASE;      // pointer at the beginning of SRAM
uint32_t *tabFlash = (uint32_t *)MAIN_APP_ADDR; // pointer at your main app vector table

for (uint32_t i = 0; i < N_HANDLERS; i++)
    tabSRAM[i] = tabFlash[i];                   // coping from flash to ra

Единственное, вам нужно посчитать количество прерываний и записать их в макрос N_HANDLERS. Далее, так как мы используем некоторую часть оперативной памяти, нужно сообщить линковщику, чтобы тот не использовал её для своих нужд.

MEMORY
{
  RAM    (xrw)     : ORIGIN = 0x2000010C,   LENGTH = 20K - 0x10C
  FLASH    (rx)    : ORIGIN = 0x8000000,   LENGTH = 64K
}

Для запуска из оперативной памяти нужно подтянуть обе ножки BOOTx к питанию. Но… это проблематично, так как при переходе нам потребуется сбросить всю периферию, а значит GPIO для этого использовать не получится, да и считываться они не будут, так как самого сброса нет, есть только переход к Reset_Handler, а чтение происходит аппаратно до входа в обработчик сброса. К счастью, это лишь один из способов, в STM32 есть регистр отвечающий за ремапинг адресов — CFGR1memcfg, находящийся в блоке SYSCFG.

В Cortex-M3/4/7 регистр называется MEMRMP.

__HAL_RCC_SYSCFG_CLK_ENABLE(); // called from HAL_MspInit()
SYSCFG->CFGR1 |= 0x3;

Таблица векторов в виде структуры

Во встраиваемых системах общепринято использовать структуры для работы с регистрами, посмотрите на любую периферию в CMSIS.

#define GPIOA               ((GPIO_TypeDef *) GPIOA_BASE)
typedef struct
{
  __IO uint32_t MODER;        /*!< GPIO port mode register,                     Address offset: 0x00      */
  __IO uint32_t OTYPER;       /*!< GPIO port output type register,              Address offset: 0x04      */
  __IO uint32_t OSPEEDR;      /*!< GPIO port output speed register,             Address offset: 0x08      */
  __IO uint32_t PUPDR;        /*!< GPIO port pull-up/pull-down register,        Address offset: 0x0C      */
  __IO uint32_t IDR;          /*!< GPIO port input data register,               Address offset: 0x10      */
  __IO uint32_t ODR;          /*!< GPIO port output data register,              Address offset: 0x14      */
  __IO uint32_t BSRR;         /*!< GPIO port bit set/reset register,      Address offset: 0x1A */
  __IO uint32_t LCKR;         /*!< GPIO port configuration lock register,       Address offset: 0x1C      */
  __IO uint32_t AFR[2];       /*!< GPIO alternate function low register,  Address offset: 0x20-0x24 */
  __IO uint32_t BRR;          /*!< GPIO bit reset register,                     Address offset: 0x28      */
} GPIO_TypeDef;

Здесь память приводится к структуре с известными смещениями (uint32_t), которые соответствуют положениям регистров. Точно такой же подход можно применить к таблице векторов прерываний.

#define N_CORE_HANDLERS      15
#define N_PERIF_HANDLERS     56

typedef void(*pHandler)(void);
 
struct VTABLE {
    volatile uint32_t  msp;
    volatile pHandler  Reset_Handler;
    volatile pHandler  Core_Handler[N_CORE_HANDLERS-1];
    volatile pHandler  Perif_Handler[N_PERIF_HANDLERS];
};

__attribute__((always_online)) static inline void jump_to_app(uint32_t app_addr) {
    assert_param(app_addr & 0x07); // 0x200???
    const struct VTABLE *const vtable = (const struct VTABLE *)app_addr;
    
#if defined(__CORE_CM0_H_GENERIC)
    const struct VTABLE *current_vtable = (const struct VTABLE *)app_addr;
    const struct VTABLE *ram_vtable;
    ram_vtable = *(const struct VTABLE*)app_addr;
#else
    SCB->VTOR = app_addr;
#endif

    __set_MSP(vtable->msp);
    vtable->Reset_Handler();
}

int main(void) {
    // some code here
    jump_to_app(MAIN_APP_START_ADDRESS);
    while(1);
}

Единая прошивка

Когда мы говорим о массовом производстве, то невольно встаёт вопрос рационализации процесса. Если до этого нужно было заливать одну прошивку, теперь придётся две. Если с одним устройством это, скажем, пару лишних операций, дополнительные 10 секунд, то вот когда вам нужно прошить 1000 устройств… Начинаются проблемы, ведь это уже не 10 секунд, а 10 тысяч секунд. Жизнь слишком коротка, чтобы тратить её на прошивку устройств, нужно две прошивки как-то объединить.

Компилятор на выходе генерирует *.efl-файл, в котором помимо прошивки хранятся адреса, а так же информация необходимая для отладки. Из этого файла можно сгенерировать либо *.bin-файл, в котором будет храниться только бинарный код, либо *.hex-файл, в котором помимо прочего хранится адрес куда нужно заливать прошивку. Если для получения нужного *.bin-файла придётся их сливать заполняя отсутствующие байты заглушками (англ. padding), то hex можно просто склеить. В Linux достаточно использовать утилиту cat.

Я не уверен что это правильно, т.к. внутри файла есть последовательность обозначающая конец файла, однако склеенный файл заливается успешно через CubeProgrammer.

cat F452_Bootloader.hex F452_MainApp.hex > firmware.hex

API загрузчика

One more thing… Часть кода можно вынести в прошивку загрузчика и не дублировать его в основной программе. Есть правда одно существенное ограничение: все используемые переменные, функции и т.д. должны использовать только известные адреса в памяти. Другими словами, функция не должна пытаться обратиться к внешней по отношению к ней переменной, так как в контексте основной прошивки её никто не создавал! Вы не можете вызвать, например, функцию HAL_Delay() из программы загрузчика, так как она работает с внешней переменной uwTickFreq, а так же вызывает функцию HAL_GetTick(), которая возвращает другую внешнюю переменную uwTick.

__weak void HAL_Delay(uint32_t Delay) {
    uint32_t tickstart = HAL_GetTick();
    uint32_t wait = Delay;
    if (wait < HAL_MAX_DELAY) {
        wait += (uint32_t)(uwTickFreq);
    }
    while((HAL_GetTick() - tickstart) < wait);
}

__weak uint32_t HAL_GetTick(void) {
  return uwTick;
}

Функция может использовать переменные или массивы объявленные с модификатором const, потому что они хранятся в секции .rodata, во флеш-памяти, а значит их адреса известны и вшиты в код функции на этапе компиляции.

Чтобы пользоваться функциями в основной программе из загрузчика нам нужно создать собственную таблицу указателей на функции (по аналогии с таблицей векторов прерываний). Всё что нам нужно обеспечить — известный адрес для этого массива в основной программе. Выхода два: а) разместить массив указателей во флеш, например после таблицы векторов прерываний; б) сделать то же, что мы делали с Cortex-M0, т.е. поместить их в оперативную память. Второй способ проще, рассмотрим его и приведём весьма синтетический пример — помигаем светодиодом.

Во-первых, две константы, две функции для управления светодиодом:

const GPIO_PinState ON = GPIO_PIN_SET;
const GPIO_PinState OFF = GPIO_PIN_RESET;

void led_turn_on(void) {
	HAL_GPIO_WritePin(RED_LED_GPIO_Port, RED_LED_Pin, ON);
}

void led_turn_off(void) {
	HAL_GPIO_WritePin(RED_LED_GPIO_Port, RED_LED_Pin, OFF);
}

Создадим массив указателей. Удобнее всего это сделать через структуру:

Вы можете использовать и обычный массив, но в таком случае вы потеряете возможность контролировать их сигнатуру. Используя структуру, вы можете задавать произвольную сигнатуру, достаточно определить её тип.

typedef void (*pFunction)(void);

typedef struct {
	pFunction led_on;
	pFunction led_off;
} BOOTLOADER_API_t;

Заполним её,

BOOTLOADER_API_t *ram_api = (BOOTLOADER_API_t *)SRAM_BASE;
ram_api->led_on = led_turn_on;
ram_api->led_off = led_turn_off;

и модифицируем файл компоновщика, сместив положение начала оперативной памяти:

MEMORY {
  RAM    (xrw)    : ORIGIN = 0x20000008,   LENGTH = 20K-8
  FLASH    (rx)    : ORIGIN = 0x8000000,   LENGTH = 64K
}

Сместить начало нужно и в загрузчике, и в основной программе иначе наши значения будут затёрты.

В основной программе приведём уже созданный тип (структуру) к началу SRAM и воспользуемся функциями.

BOOTLOADER_API_t api = *(BOOTLOADER_API_t *)SRAM_BASE;
// ...
api.led_on();
HAL_Delay(1000);
api.led_off();
HAL_Delay(1000);

Важно: отлаживать приложение не получится! При прошивке микроконтроллер сбрасывается, а значит сбрасывается и SRAM, в которой до этого хранились нужные адреса функций.

0

Изменено: