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

Загрузчик — это удобно и полезно, не правда ли? А если загрузчик собственной реализации, то это еще более удобно, полезно и гибко и не стабильно. Ну и конечно же, очень круто!

Так же, это прекрасная возможность углубиться и изучить особенности используемой вычислительной машины — в нашем случае микроконтроллера STM32 с ядром ARM Cortex-M3.

На самом деле, загрузчик — это проще, чем кажется на первый взгляд. В доказательство, под cut'ом соберём свой собственный USB Mass Storage Bootloader!

imageimage

Работать мы будем с самодельной платой на микроконтроллере (дальше — МК) STM32F103RET. Чтобы не переполнять публикацию лишними картинками, приведу усеченную схему этой железки:

image

При написании bootloader'а я руководствовался следующими принципами:

  1. Свой bootloader очень нужен и хватит откладывать это в TODO-лист, пора уже сесть и сделать;
  2. Bootloader должен иметь удобный для пользователя интерфейс загрузки программы. Никаких драйверов, сторонних программ, плат-переходников и жгутов МГТФ провода до целевого устройства. Что может быть проще автоматически определяемого USB флеш накопителя?
  3. Для работы в режиме bootloader'а микроконтроллеру необходима минимальная аппаратная обвязка (фактически, только USB, кварц и кнопка);
  4. Размер boot'а — не главное. Важен, конечно же, но не будем преследовать цель ужать его в пару килобайт. Без мук совести мы поднимем USB стек, поработаем с файловой системой, навтыкаем printf() через строку и вообще не будем особо ни в чем себе отказывать (hello, Standard Peripheral Libraries!);

Погнали

Немного о FLASH


Так как с собственной FLASH-памятью STM32 мы будем работать постоянно и часто, стоит сразу пояснить некоторые ключевые моменты, связанные с этим фактом.

В используемом МК содержится 512 Kbyte FLASH памяти. Она разбита на страницы по 2048 байт:

image

Для нас это означает, что записать несколько байт в произвольный адрес просто так не выйдет. При записи во FLASH возможно только обнулять нужные ячейки, а вот установка единиц выполняется с помощью операции стирания, минимально возможный объем для которой — одна страница. Для этого служит регистр FLASH_AR, в который достаточно записать любой адрес в пределах нужной нам страницы — и она будет заполнена байтами 0xFF. А еще нужно не забыть разблокировать FLASH перед операциями стирания/записи.

Виртуально разобьем FLASH на несколько областей, у каждой из которых будет свое, особое назначение:

image

  • BOOT_MEM — область памяти, выделенная под bootloader;
  • USER_MEM — тут мы будем хранить (и исполнять отсюда же) пользовательскую прошивку. Очевидно, что теперь она имеет ограничение в 200 Kbyte;
  • MSD_MEM — а тут будет MASS STORAGE диск, куда можно закинуть прошивку средствами компьютера и вашей любимой ОС;
  • OTHER_MEM — ну и оставим еще немного места на всякий случай;

USER_MEM будет соответствовать MSD_MEM по размеру. Это логично, т.к. два противоположных случая будут давать либо нехватку памяти в USER_MEM, либо избыток.

А теперь все то же самое, только для машины (и удобства программиста):
#define FLASH_PAGE_SIZE		2048 						//2 Kbyte per page
#define FLASH_START_ADDR	0x08000000					//Origin
#define FLASH_MAX_SIZE		0x00080000					//Max FLASH size - 512 Kbyte
#define FLASH_END_ADDR		(FLASH_START_ADDR + FLASH_MAX_SIZE)		//FLASH end address
#define FLASH_BOOT_START_ADDR	(FLASH_START_ADDR)				//Bootloader start address
#define FLASH_BOOT_SIZE		0x00010000					//64 Kbyte for bootloader
#define FLASH_USER_START_ADDR	(FLASH_BOOT_START_ADDR + FLASH_BOOT_SIZE)	//User application start address
#define FLASH_USER_SIZE		0x00032000					//200 Kbyte for user application
#define FLASH_MSD_START_ADDR	(FLASH_USER_START_ADDR + FLASH_USER_SIZE)	//USB MSD start address
#define FLASH_MSD_SIZE		0x00032000					//200 Kbyte for USB MASS Storage
#define FLASH_OTHER_START_ADDR	(FLASH_MSD_START_ADDR + FLASH_MSD_SIZE)		//Other free memory start address
#define FLASH_OTHER_SIZE	(FLASH_END_ADDR - FLASH_OTHER_START_ADDR)	//Free memory size

Договорившись о разбиении памяти на области, самое время прикинуть, как это все будет взаимодействовать. Нарисуем блок-схему:

image

Согласно такому алгоритму, bootloader имеет два основных режима, работающих независимо друг от друга, но имеющих общий ресурс — кусок памяти MSD_MEM. Однако, даже его использование происходит в разные моменты времени, что положительно влияет на стабильность работы bootloader'а и упрощает процесс программирования и отладки.

  1. Первый режим отвечает за получение и хранение пользовательского ПО в области MSD_MEM, которая доступна, как внешний накопитель.

  2. Второй режим проверяет MSD_MEM на наличие файла с именем «APP.BIN», проверяет его целостность, подлинность, а так же перемещает в USER_MEM, если там пусто или если прошивка «APP.BIN» более свежая.

Рассмотрим каждый из режимов подробнее:

USB Mass Storage Device


Запускается сразу же после входа в main() в случае, если выполнено соответствующее условие запуска — зажата кнопка. На моей плате это верхний ползунок двухпозиционного переключателя (который, кстати, заведен на ножки МК BOOT0 и BOOT1(PB2) — это позволяет задействовать аппаратный UART загрузчик МК в случае такой необходимости).

int main(void)
int main(void)
{
	Button_Config();
	if(GPIO_ReadInputDataBit(BUTTON_PORT, BUTTON_PIN) == SET) //Bootloader or Mass Storage?
	{
		LED_RGB_Config();
		USB_Config();
		Interrupts_Config();
		USB_Init();
		while(TRUE);
	}
//Bootloader mode
}

Работа в режиме Mass Storage взята из примеров от STMicroelectronics (STM32_USB-FS-Device_Lib_V4.0.0), которые можно скачать с их сайта. Там нам показывают, как нужно (или наоборот, не нужно — отношение к библиотекам от ST у народа не всегда положительное) работать с микроконтроллером и картой памяти, подключенной по интерфейсу SDIO в режиме USB MSD. В примере реализованы два Bulk In/Out Endpoint'а с длиной пакета 64 байта, а так же набор необходимых для работы SCSI команд. Выкидываем оттуда функции, связанные с SD картами или NAND памятью (mass_mal.c/.h) и заменяем их на работу с внутренней FLASH:

u16 MAL_Init(u8 lun)
u16 MAL_Init(u8 lun) 
{
	switch (lun)
	{
		case 0:
			FLASH_Unlock();
			break;

		default:
			return MAL_FAIL;
	}

	return MAL_OK;
}

u16 MAL_Read(u8 lun, u32 memOffset, u32 *readBuff)
u16 MAL_Read(u8 lun, u32 memOffset, u32 *readBuff)
{
	u32 i;

	switch (lun)
	{
		case 0:
			LED_RGB_EnableOne(LED_GREEN);

			for(i = 0; i < MassBlockSize[0]; i += SIZE_OF_U32)
			{
				readBuff[i / SIZE_OF_U32] = *((volatile u32*)(FLASH_MSD_START_ADDR + memOffset + i));
			}

			LED_RGB_DisableOne(LED_GREEN);
			break;

		default:
			return MAL_FAIL;
	}

	return MAL_OK;
}

u16 MAL_Write(u8 lun, u32 memOffset, u32 *writeBuff)
u16 MAL_Write(u8 lun, u32 memOffset, u32 *writeBuff)
{
	u32 i;

	switch (lun)
	{
		case 0:
			LED_RGB_EnableOne(LED_RED);

			while(FLASH_GetStatus() != FLASH_COMPLETE);
			FLASH_ErasePage(FLASH_MSD_START_ADDR + memOffset);

			for(i = 0; i < MassBlockSize[0]; i += SIZE_OF_U32)
			{
				while(FLASH_GetStatus() != FLASH_COMPLETE);
				FLASH_ProgramWord(FLASH_MSD_START_ADDR + memOffset + i, writeBuff[i / SIZE_OF_U32]);
			}

			LED_RGB_DisableOne(LED_RED);
			break;

		default:
			return MAL_FAIL;
	}

	return MAL_OK;
}

Если все сделано правильно, при подключении компьютер определит наше изделие, как USB Mass Storage Device и предложит его отформатировать, т.к. в области MSD_MEM лежит мусор. Стоит отметить, что в данном режиме работы МК является просто посредником между хостом и FLASH памятью, а операционная система самостоятельно решает, какие данные и по каким адресам будут лежать на нашем накопителе.

Отформатируем диск и посмотрим, как это отразилось на области MSD_MEM:

image

Объем совпадает, размер сектора Windows определила верный, нулевой сектор — загрузочный, расположение в памяти соответствует задуманному. Файлы пишутся, читаются, не исчезают после отключения питания — полноценная флешка на 200 Kbyte!

Bootloader


Запускается, если обновление прошивки не требуется. То есть, нормальный режим работы устройства. В нём нам предстоит совершить несколько базовых действий, необходимых для успешного запуска пользовательского ПО. Базовых — потому что при необходимости можно дополнять работу bootloader'а всякими фичами, такими как шифрование, проверка целостности, выводом отладочных сообщений и т.д.

Пусть мы уже создали средствами Windows файловую систему на USB накопителе и загрузили необходимое ПО. Теперь неплохо бы увидеть содержимое носителя «глазами» МК, а значит идем в гости к товарищу ChaN'у за FatFS (модуль простой файловой системы FAT, предназначенный для маленьких встраиваемых систем на микроконтроллерах). Скачиваем, закидываем в проект, прописываем функцию чтения с диска нужных данных:

DRESULT disk_read(BYTE pdrv, BYTE *buff, DWORD sector, UINT count)
DRESULT disk_read (
	BYTE pdrv,	/* Physical drive nmuber to identify the drive */
	BYTE *buff,	/* Data buffer to store read data */
	DWORD sector,	/* Sector address in LBA */
	UINT count	/* Number of sectors to read */
)
{
	u32 i;

	for(i = 0; i < count * SECTOR_SIZE; i++)
	{
		buff[i] = *((volatile u8*)(FLASH_MSD_START_ADDR + sector * SECTOR_SIZE + i));
	}

	return RES_OK;
}

disk_write() не понадобится и оставлена заглушкой, ибо смонтированная файловая система — Read Only. Это так же можно задать в конфигурационном файле ffconf.h, дополнительно отключив все ненужные и неиспользуемые функции.

Дальше все более-менее очевидно: монтируем файловую систему, открываем файл прошивки, начинаем читать. Изначально было реализовано так, что основное место хранения прошивки — MSD_MEM, а микроконтроллер каждый раз при включении перезаписывает свою FLASH память. Нет прошивки — отладочное сообщение об отсутствии и while(TRUE). Есть прошивка — закидываем её в USER_MEM. Однако очевидный минус такого решения — ресурс стирания/записи FLASH памяти имеет лимит и было бы глупо постепенно и осознанно убивать изделие.

Поэтому сравним «APP.BIN» и USER_MEM, тупо, байт за байтом. Возможно, сравнение хеш-сумм двух массивов выглядело бы более изящным решением, но уж точно не самым быстрым. Заглянем снова в main():

int main(void)
int main(void)
{
	Button_Config();
	if(GPIO_ReadInputDataBit(BUTTON_PORT, BUTTON_PIN) == SET) //Bootloader or Mass Storage?
	{
		//USB MSD mode
	}
	
	FATFS_Status = f_mount(&FATFS_Obj, "0", 1);
	if(FATFS_Status == FR_OK)
	{
		FILE_Status = f_open(&appFile, "/APP.BIN", FA_READ);
		if(FILE_Status == FR_OK)
		{
			appSize = f_size(&appFile);

			for(i = 0; i < appSize; i++) //Byte-to-byte compare files in MSD_MEM and USER_MEM
			{
				f_read(&appFile, &appBuffer, 1, &readBytes);

				if(*((volatile u8*)(FLASH_USER_START_ADDR + i)) != appBuffer[0]) 
				{
					//if byte of USER_MEM != byte of MSD_MEM
					break;
				}
			}

			if(i != appSize)//=> was done "break" instruction in for(;;) cycle => new firmware in MSD_FLASH
			{
				CopyAppToUserMemory();
			}

			FILE_Status = f_close(&appFile);
			FATFS_Status = f_mount(NULL, "0", 1);

			PeriphDeInit();
			GoToUserApp();
		}
		else //if FILE_Status != FR_OK
		{
			if(FILE_Status == FR_NO_FILE)
			{
				//No file error
			}
			else //if FILE_Status != FR_NO_FILE
			{
				//Other error
			}
			FATFS_Status = f_mount(NULL, "0", 1);
			while(TRUE);
		}
	}
	else //FATFS_Status != FR_OK
	{
		//FatFS mount error
		while(TRUE);
	}
}

Если в процессе сравнения мы не дошли до конца цикла, значит прошивки различны и самое время обновить USER_MEM с помощью CopyAppToUserMemory(). Ну а потом неплохо бы уничтожить следы работы bootloader'а вызовом PeriphDeInit() и затем GoToUserApp(). Но это чуть позже, а пока — процесс копирования:

void CopyAppToUserMemory(void)
void CopyAppToUserMemory(void)
{
	f_lseek(&appFile, 0); //Go to the fist position of file

	appTailSize = appSize % APP_BLOCK_TRANSFER_SIZE;
	appBodySize = appSize - appTailSize;
	appAddrPointer = 0;

	for(i = 0; i < ((appSize / FLASH_PAGE_SIZE) + 1); i++) //Erase n + 1 pages for new application
	{
		while(FLASH_GetStatus() != FLASH_COMPLETE);
		FLASH_ErasePage(FLASH_USER_START_ADDR + i * FLASH_PAGE_SIZE);
	}

	for(i = 0; i < appBodySize; i += APP_BLOCK_TRANSFER_SIZE)
	{
		/*
		 * For example, size of File1 = 1030 bytes
		 * File1 = 2 * 512 bytes + 6 bytes
		 * "body" = 2 * 512, "tail" = 6
		 * Let's write "body" and "tail" to MCU FLASH byte after byte with 512-byte blocks
		 */
		f_read(&appFile, appBuffer, APP_BLOCK_TRANSFER_SIZE, &readBytes); //Read 512 byte from file
		for(j = 0; j < APP_BLOCK_TRANSFER_SIZE; j += SIZE_OF_U32) //write 512 byte to FLASH
		{
			while(FLASH_GetStatus() != FLASH_COMPLETE);
			FLASH_ProgramWord(FLASH_USER_START_ADDR + i + j, *((volatile u32*)(appBuffer + j)));
		}
		appAddrPointer += APP_BLOCK_TRANSFER_SIZE; //pointer to current position in FLASH for write
	}

	f_read(&appFile, appBuffer, appTailSize, &readBytes); //Read "tail" that < 512 bytes from file

	while((appTailSize % SIZE_OF_U32) != 0)		//if appTailSize MOD 4 != 0 (seems not possible, but still...)
	{
		appTailSize++;				//increase the tail to a multiple of 4
		appBuffer[appTailSize - 1] = 0xFF;	//and put 0xFF in this tail place
	}

	for(i = 0; i < appTailSize; i += SIZE_OF_U32) //write "tail" to FLASH
	{
		while(FLASH_GetStatus() != FLASH_COMPLETE);
		FLASH_ProgramWord(FLASH_USER_START_ADDR + appAddrPointer + i, *((volatile u32*)(appBuffer + i))); 
	}
}

Копировать будем блоками по 512 байт. 512 — потому что я где-то видел, что при размере буфера больше этого значения f_read() может косячить. Я проверял этот момент — у меня все работало и с буфером большего размера. Но на всякий случай оставил 512 — почему бы и нет? Экономим RAM, да и на скорость не влияет, к тому же выполняется один раз — в момент включ��ния устройства и лишь при условии, что прошивку пора обновлять.

Предварительно стираем во FLASH памяти местечко под файл. Размер стираемой области равен количеству страниц в памяти, которые полностью займет «APP.BIN» + еще одна (которую не полностью). А еще, виртуально бьем файл прошивки на «body» и «tail», где «body» — максимально возможный кусок файла, в который входит целое количество блоков по 512 байт, а «tail» — все остальное.

Кажется, что все бинарные файлы прошивок кратны 4-м байтам. Я не был в этом уверен точно (и до сих пор), так что на всякий случай — если прошивка не кратна sizeof(u32) — дополняем её байтами 0xFF. Повторюсь: кажется, что этого не нужно делать — но операция безобидна для кратных sizeof(u32) бинарников, так что оставим.

Hello, User Application!


Уже близко. Деинициализируем всю использованную периферию функцией PeriphDeInit() (а ее вообще почти ничего — GPIO для кнопки выбора режима и при желании UART для вывода отладочных сообщений; никаких прерываний не используется).

Заключительный жизненный этап загрузчика — начало исполнения пользовательской прошивки:

void GoToUserApp(void)
void GoToUserApp(void)
{
	u32 appJumpAddress;
	void (*GoToApp)(void);

	appJumpAddress = *((volatile u32*)(FLASH_USER_START_ADDR + 4));
	GoToApp = (void (*)(void))appJumpAddress;
	SCB->VTOR = FLASH_USER_START_ADDR;
	__set_MSP(*((volatile u32*) FLASH_USER_START_ADDR)); //stack pointer (to RAM) for USER app in this address
	GoToApp();
}

Всего-то 5 строк, но сколько всего происходит!

В ядре ARM Cortex M3, когда возникает какое-либо исключение, для него вызывается соответствующий обработчик. Что б�� определить начальный адрес обработчика исключений, используется механизм векторной таблицы. Таблица векторов представляет собой массив слов данных внутри системной памяти, каждое из которых является начальным адресом одного типа исключений. Таблица перемещаема и перемещение управляется специальным регистром VTOR в SCB (System Control Block)(В мануале звучит круче, но я сломался: The vector table is relocatable, and the relocation is controlled by a relocation register in the NVIC). После RESET'а значение этого регистра равно 0, то есть таблица векторов лежит по адресу 0x0 (для STM32F103 в стартап файле мы уже самостоятельно двигаем её на 0x08000000). И что очень важно для нас, порядок следования там следующий:

image

  • Значение, лежащее по адресу 0x04 — это то место в программе, куда мы попадаем после Reset-исключения
  • Значение, лежащее по адресу 0x00 — это начальное значение Main Stack Pointer для пользовательского приложения

Все это вместе взятое, плюс немного магии с указателем на функцию, и Алиса прыгает вслед за кроликом.

Теперь проверим, работает ли оно вообще. Напишем простую программу мигания светодиодов, с циклами в main() и парочкой прерываний (SysTick и TIM4):

Test programm for MSD bootloader
#include "stm32f10x.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_rcc.h"
#include "stm32f10x_tim.h"
#include "misc.h"

#define SYSCLK_FREQ	72000000
#define TICK_1_KHz	((SYSCLK_FREQ / 1000) - 1)
#define TICK_1_MHz	((SYSCLK_FREQ / 1000000) - 1)

volatile u32 i, j;

int main(void)
{
	GPIO_InitTypeDef GPIO_Options;
	NVIC_InitTypeDef NVIC_Options;

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);

	GPIO_Options.GPIO_Pin = GPIO_Pin_7;
	GPIO_Options.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Options.GPIO_Mode = GPIO_Mode_Out_PP;
	GPIO_Init(GPIOA, &GPIO_Options);

	GPIO_Options.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
	GPIO_Options.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Options.GPIO_Mode = GPIO_Mode_Out_PP;
	GPIO_Init(GPIOB, &GPIO_Options);

	GPIOB->BSRR = GPIO_Pin_0 | GPIO_Pin_1; //LEDs off
	GPIOA->BSRR = GPIO_Pin_7

	TIM4->PSC = 720 - 1;		//clock prescaller
	TIM4->ARR = 60000 - 1;		//auto-reload value
	TIM4->CR1 |= TIM_CounterMode_Up;//upcounter
	TIM4->DIER |= TIM_IT_Update;	//update interrupt enable
	TIM4->CR1 |= TIM_CR1_CEN;	//timer start

	NVIC_Options.NVIC_IRQChannel = TIM4_IRQn;
	NVIC_Options.NVIC_IRQChannelPreemptionPriority = 0;
	NVIC_Options.NVIC_IRQChannelSubPriority = 0;
	NVIC_Options.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&NVIC_Options);

	SysTick_Config(TICK_1_KHz);

	while(1)
	{
		__disable_irq();

		GPIOB->BSRR = GPIO_Pin_0 | GPIO_Pin_1; //Off
		for(i = 0; i < 10; i++)
		{
			for(j = 0; j < 500000; j++); //Pause
			GPIOA->ODR ^= GPIO_Pin_7; //Reverse
		}
		GPIOA->BSRR = GPIO_Pin_7; //Off

		__enable_irq();

		for(i = 0; i < 5000000; i++); //Pause
	}
}

void SysTick_Handler(void)
{
	volatile static u32 LED_Counter = 0;

	if(LED_Counter >= 40)
	{
		GPIOB->ODR ^= GPIO_Pin_1; //Reverse
		LED_Counter = 0;
	}

	LED_Counter++;
}

void TIM4_IRQHandler()
{
	TIM4->SR = ~TIM_SR_UIF;
	GPIOB->ODR ^= GPIO_Pin_0; //Reverse
}

Кстати, надо не забыть исправить в проекте пару вещей, без которых ничего работать не будет:

  1. Убрать из SystemInit() операцию перемещения таблицы векторов на какое либо значение (//SCB->VTOR = FLASH_BASE). Bootloader перемещает ее самостоятельно перед переходом в пользовательскую программу!

  2. В Linker script поменять начало нашей программы с адреса 0x08000000 на адрес начала USER_MEM (FLASH (rx): ORIGIN = 0x08010000, LENGTH = 200K);

И вот так этот код исполняется (ну, может не все видели, как моргают светодиоды...):



А вот так выглядит лог загрузки этой прошивки в МК через бутлоадер:

UART log message
---------------START LOG---------------

BOOT_MEM start addr: 0x08000000
BOOT_MEM size: 64K
USER_MEM start addr: 0x08010000
USER_MEM size: 200K
MSD_MEM start addr: 0x08042000
MSD_MEM size: 200K
OTHER_MEM start addr: 0x08074000
OTHER_MEM size: 48K

Total memory size: 512K

BOOTLOADER Mode…
FAT FS mount status = 0
Application file open status = 0
Difference between MSD_MEM and USER_MEM: 4 byte from 2212 byte
Start copy MSD_MEM to USER_MEM:

File size = 2212 byte
Body size = 2048 byte
Tail size = 164 byte

Sector 0 (0x08010000 — 0x08010800) erased
Sector 1 (0x08010800 — 0x08011000) erased

0 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010000 — 0x08010200
1 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010200 — 0x08010400
2 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010400 — 0x08010600
3 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010600 — 0x08010800
Tail read: read status = 0, 164 byte read, size of tail = 164
New size of tail = 164
164 byte programmed: 0x08010800 — 0x080108A4

File close status = 0
FAT FS unmount status = 0
DeInit peripheral and jump to 0x08010561…

Подведём итоги. Бутлоадер получился! И даже работает. С выводом отладочных сообщений в UART он занимает 31684 байт FLASH памяти, без — 25608 байт. Не так уж и мало, если еще и учесть, сколько памяти нужно отдать под Mass Storage диск. Исходники и рабочий проект (Atollic TrueSTUDIO) можно посмотреть на Bitbucket.

Спасибо за внимание!