OTA BootLoader是什么?
OTA是指无线通信技术,BootLoader是设备开机时最先运行的一段程序,负责初始化硬件,加载操作系统,它本身不具备无线上网的能力,也不会主动去下载更新包。那么OTA BootLoader那就是指的是通过无线通信来对设备进行远程升级的一门技术。
自定义BootLoader:
首先看一下别人的bootloader功能:串口挂起,等待接收程序数据 => (把Bin文件)写入到对应的flash当中(他会识别发送的是Hex文件还是Bin文件,如果是Hex文件,他会自己转换成Bin文件)
自定义的与自带的bootloader的区别:程序本身存放的位置不同。如果是我们自己写的BootLoader会存放在flash的起始位置0x0800 0000中,但是系统内的boot loader是存放在特殊位置0x1FFF F000。这里需要注意,当我们自己写boot loader写入flash时,会造成位置冲突。这是因为我们的程序代码也要放进flash,会冲突。解决方法是将flash分区:AB区存放:因为我们程序上上电后执行的第一个程序必须是BootLoader程序,所以说bootloader必须在flash的起始位置。
但是A区的起始位置是哪里呢?当我们把bootloader编译好了,那么B区的位置就订死了,接下来就是A区的起始位置了。假设boot loader使用了4KB,4KB的16进制是0x1000, 那么A区的起始地址就是0x0800 0000 + 0x1000 = 0x0800 1000(因为flash起始地址是0x0800 0000)。这种方式是紧挨着的,如果你想要让B区有空闲位置的话,那么就可以给B区一个比较大的位置。比如16KB,此时A区的地址就是0x0800 4000.
我们每次复位程序都是从flash的起始位置执行的,那么我们每一次复位都要执行一次boot loader程序。他通过跳转的方式,等boot loader执行完毕后再去跳转到A区。这个跳转相当于复位,就是在boot loader的main函数中执行A区的复位中断。
如何将A程序写入到特定的flash地址?启动时,会给AB都分配一个栈,栈顶元素 = RAM起始地址 + 栈大小。当我们的stack大小相等时,那么A和B的栈顶地址就相等,因为他们不会同一时刻来使用这块内存。写入到flash占多少内存,用页来算,复位中断地址是确定的地址。A程序在编译时一定要自己专门定义自己的起始地址。
使用串口接收方式向BootLoader发送程序
我们使用串口的话,我们需要编写串口接收函数,当我们使用普通的串口接收函数HAL_UART_Receive()的话,他会有超时等待时间,是阻塞式的,CPU一直来查看有没有数据发过来,并且他会有一个接受长度,接受不够这个长度他就会一直超时等待。不适合我们的不定长接受。
另一种函数,串口的空闲接受HAL_UARTEx_ReceiveToIdle()函数,他是空闲中断函数,他实际上也是CPU一直查询看有没有数据过来,但是他跟上面不同的是,他会有一个空闲中断,当接收端检测到总线有一个空闲帧时,就会返回。否则就等待接受数据(直到超时时间结束),他也是一种阻塞式的,他可以接收不定长数据,但是因为他是阻塞式的,所以就不适合
我们一般使用空闲中断接收函数HAL_UARTEx_ReceiveToIdle_IT(),他带有中断,不需要CPU一直来查询是否数据到了,而是当接收数据寄存器的数据全都移动到接受移位寄存器后会提醒CPU过来搬运数据,不占用CPU资源,而且当检测到总线上有空闲帧时,他会产生空闲中断,我们编写这个空闲中断的回调函数就可以了。
这里会产生一个小Bug,就是当我们的串口初始化结束到调用串口接收函数之前,如果有人源源不断的发数据的话,就会造成ORE溢出,所以我们要先清除掉这些问题,调用两个函数:
//清空掉初始化串口使用之前的所有问题。 __HAL_UART_CLEAR_OREFLAG(&huart1); //清楚掉ORE的标志位 __HAL_UART_CLEAR_IDLEFLAG(&huart1); //清楚掉IDLE的标志位,因为后面用的是空闲的中断,所以先清除一下标志位随后再编写我们的回调函数:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if(huart->Instance == USART1) { //保存接收到的实际长度 uart_rec_len = Size; /* 先来进行一个日志打印 */ printf("Buffer:%d", uart_rec_len); // TODO: 将接收到的数据写入flash flash编程后续再说 //使用完数据之后,清空,随后准备下一次的接收 memset(uart_rec_buff, 0, BOOTLOADER_UART_REC_BUFF_LEN); uart_rec_len = 0; HAL_UARTEx_ReceiveToIdle_IT(&huart1, uart_rec_buff, BOOTLOADER_UART_REC_BUFF_LEN); /* 然后再打开空闲接收中断 */ } }这就是大致的一个通过串口向BootLoader发送程序的一般流程。
写数据到FLASH
需要注意:
- Flash默认是加锁的状态,需要优先解锁,随后使用完成之后再重新加锁。
- 先擦除再写入,可以按页擦除或者全部擦除。
- Flash编程只支持16 32 64位写入,默认小端序
解锁:调用官方的函数即可:
// 1. 先解锁flash HAL_FLASH_Unlock();先擦除:
- 每次写入的数据稳定应该在256字节左右(因为串口原因会导致并不是每次都是256)
- 如何判断当前一页是需要擦除,如果是新的一页,那就擦除
- 擦除前是不同的内容
- 擦除后都是FFFF
- 优势:第一次擦除后,后面就不需要再擦除了
当我们第一个256写入后,擦除了一整页,那么后续的7次就不需要再擦除了;
另外一种方法:
我们先用一个缓冲区储存数据,当达到2k时,把这一页全部擦除,再写入2k,但是这种方式不适用,这是因为,我们是一次256发的,但是会因为在传输过程中导致的不精确,导致他最后跟2k会有一两个字节的偏差。那么他就会一直等待。另外就是我们很少会遇到刚好写入的地址是一页的起始地址的。需要扩大缓冲区,或者使用环形缓冲区。
flash擦除相关代码:
// 2. 判断当前写入的地址是否为新的一页 => 如果是,就先擦除一页 // 2.1 遍历需要写入的地址,长度为当前接收到的数据长度,如果全部的内容都是0xffff,则说明已经擦除过了 uint8_t is_erase = 0; //先定义擦除变量为0,默认不需要擦除。 uint32_t Page_Addr = APP_START_ADDR + flash_write_offset; //用来记录当前偏移后的地址 for(uint16_t i = 0;i < uart_rec_len; i ++) { //遍历每一个值,只要有一个不等于0xffff,则说明还没有擦除。需要擦除,赋值为1。 uint8_t data = *(volatile uint8_t *)(APP_START_ADDR + i + flash_write_offset); /* 这里的APP_START_ADDR + i + flash_write_offset 是为了得到当前写入的地址 */ if(data != 0xffff) { is_erase = 1; Page_Addr = (Page_Addr + i) - (Page_Addr + i)%FLASH_PAGE_SIZE; //记录当前页的起始地址 break; } } // 2.2 如果需要擦除,则擦除当前页(写进main.c中) if(is_erase) { FLASH_EraseInitTypeDef erase_init; erase_init.Banks = FLASH_BANK_1; //选择银行1 erase_init.NbPages = 1; //擦除页数 erase_init.PageAddress = Page_Addr; //设置擦除页的起始地址 erase_init.TypeErase = FLASH_TYPEERASE_PAGES; //擦除类型为页 uint32_t * PageError = NULL; //定义一个指针,用来存储擦除的错误地址 HAL_FLASHEx_Erase(&erase_init, &PageError); /* flash擦除比较耗费性能 */ }我们首先要遍历一下要写入的地址是否是新的一页,遍历长度那就是接收到的字节数,如果我们要写入的地址的数据都是0xFFFF那么说明这就是新的一页,我们定义一个变量is_erase 作为标志位来判断是否要擦除。我们遍历时,如果有数据不等于0xFFFF,那就说明这不是新的一页,给is_erase赋值为1(擦除),然后直接break;
我们代码中的APP_START_ADDR是A区的起始地址,我们在.h文件中已经#define定义过了。还有就是flash_write_offset变量,他是作为一个偏移变量,用来记录每次写入多少个数据。随后通过uint32_t Page_Addr = APP_START_ADDR + flash_write_offset;来记录下一次写入的起始地址。
当我们遍历到第一个不是0xFFFF的数据后,就直接break,给is_erase赋值为1.随后我们就要开始擦除了,并且还要记录下当前的位置,但是我们擦除是按页擦除,所以说我们要计算出当前页的起始地址,计算过程如下:
所以说,用 (Page_Addr + i) - (Page_Addr + i)%FLASH_PAGE_SIZE的结果那就是本页的起始地址。
随后我们进行擦除,我们的擦除函数如下:
随后我,我们清空缓冲区,执行下一次接收
注意:
我们的中断要求快进快出,但是擦除Flash要消耗几十ms,不符合中断快进快出的特点,所以我们把擦除的部分放入main中的while循环,在中断中只用置is_erase的标志位就可以了。
再写入:
写入时,我们需要调用HAL_FLASH_Program();来进行一个写入的操作。
函数原型:
HAL_StatusTypeDef HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data);第一个参数是指的是写入数据的宽度,他只能写入半字,1字或者双字。我们的stm32,那么1字就是32位,半字就是16位,双字就是64位。
第二个参数是写入的目标地址。
第三个参数是要写入的数据。
代码:
/* FLASH的写入 使用函数:HAL_FLASH_Program(); */ //我们使用16位字节的写入,所以我们需要将数据拼接成两字节 // 拼接:我们接收到的数据一定是整两个字节的。 for(uint8_t i = 0; i < uart_rec_len ; i = i + 2) { uint16_t data16 = 0; if(i + 1 < uart_rec_len) //健壮型判断 { data16 = uart_rec_buff[i] | (uart_rec_buff[i + 1] << 8); //小端序:低位在前,高位在后 } //随后进行写入 HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, Page_Addr + i, data16); }因为是半字输入。16位就需要把两个字节拼起来。小端序低位在左,高位在右
代码:
while (1) { // 2.2 如果需要擦除,则擦除当前页 if(is_erase) { HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase_init; erase_init.Banks = FLASH_BANK_1; //选择银行1 erase_init.NbPages = 1; //擦除页数 erase_init.PageAddress = Page_Addr; //设置擦除页的起始地址 erase_init.TypeErase = FLASH_TYPEERASE_PAGES; //擦除类型为页 uint32_t PageError = 0; //定义一个指针,用来存储擦除的错误地址 HAL_FLASHEx_Erase(&erase_init, &PageError); /* flash擦除比较耗费性能 */ is_erase = 0; } if(uart_rec_len > 0) { /* FLASH的写入 使用函数:HAL_FLASH_Program(); */ //我们使用16位字节的写入,所以我们需要将数据拼接成两字节 // 拼接:。 for(uint8_t i = 0; i < uart_rec_len ; i = i + 2) { uint16_t flash_adder = Page_Addr + i; //用来记录写入的地址 uint16_t data16 = 0; if(i + 1 < uart_rec_len) //健壮型判断 { data16 = uart_rec_buff[i] | (uart_rec_buff[i + 1] << 8); //小端序:低位在前,高位在后 } else { //(i + 1) == uart_rec_len的情况 data16 = uart_rec_buff[i] | (0xff << 8); } //随后进行写入 HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, flash_adder, data16); } flash_write_offset += uart_rec_len; // 疑问:为什么擦除时,不需要这个偏移量? // 因为我们擦除时只需要记录下哪一擦除,但是写入的时候,我们需要记下下一次要写入的地址。 HAL_FLASH_Lock(); /*--------------------------------------------------------------------------------*/ //使用完数据之后,清空,随后准备下一次的接收 memset(uart_rec_buff, 0, BOOTLOADER_UART_REC_BUFF_LEN); uart_rec_len = 0; HAL_UARTEx_ReceiveToIdle_IT(&huart1, uart_rec_buff, BOOTLOADER_UART_REC_BUFF_LEN); /* 然后再打开空闲接收中断 */ } /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ }BUG:我们在flash中写入数据时,我们此时是按照半字写入,但是当我们写入奇数个数据,比如“”“Hello”,最后一个o是跟0xFF或上后发过去的。下一次再发送Hello时,他的H会跟上一个o一起,但是我们写flash是要求先擦除再写入的。就会发生错误。还有就是我们是不可以将这个0xff空出来的。
解决方法如下:
定义一个last_byte_flag标志位和last_byte位。last_byte_flag用来记录上次是否有多出来的一个,last_byte用来记录上次多出来的数据。随后我们通过判断(uart_rec_len + last_byte_len)%2 是否等于0分为两种情况。然后两种情况内部还可以分为上次有剩余和上次没剩余两种情况。
interface.c代码:
#include "int_bootloader.h" //接受程序的缓冲区 uint8_t uart_rec_buff[BOOTLOADER_UART_REC_BUFF_LEN] = {0}; uint16_t uart_rec_len = 0; //定义接受到的长度变量 uint16_t uart_rec_full = 0; uint32_t flash_write_offset = 0; //用来定义偏移量 uint8_t is_erase = 0; uint32_t Page_Addr = 0; //本次要写入的起始地址(= APP_START_ADDR + flash_write_offset) uint32_t erase_page_addr = 0; //需要擦除的页首地址,单独存放,避免污染 Page_Addr //末尾可能出现的单独的字节。 uint8_t last_byte_flag = 0; //是否有单独遗留的字节没有写入 uint8_t last_byte = 0; //遗留的单独的字节数据 /** * @brief 串口开启接受中断后,触发空闲帧时,使用的回调函数 * @param huart * @param Size 接收的字符数 * @retval * */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if(huart->Instance == USART1) { //保存接收到的实际长度 uart_rec_len = Size; /* 先来进行一个日志打印 */ printf("Buffer:%d\n", uart_rec_len); /*--------------------------------------------------------------------------------*/ // // 2. 判断当前写入的地址是否为新的一页 => 如果是,就先擦除一页 // // 2.1 遍历需要写入的地址,长度为当前接收到的数据长度,如果全部的内容都是0xffff,则说明已经擦除过了 is_erase = 0; //先定义擦除变量为0,默认不需要擦除。 Page_Addr = APP_START_ADDR + flash_write_offset; //用来记录当前偏移后的地址(写入地址,后面不要再改它) printf("Page_Addr:%d\n", Page_Addr); if(uart_rec_len > 0) { HAL_FLASH_Unlock(); for(uint16_t i = 0;i < uart_rec_len; i ++) { //遍历每一个值,只要有一个不等于0xff,说明这个位置已经有数据了(没擦干净),需要擦除。 uint32_t cur = APP_START_ADDR + i + flash_write_offset; /* 当前检查到的绝对地址 */ uint8_t data = *(volatile uint8_t *)cur; if(data != 0xff) { is_erase = 1; /* 注意:这里只写 erase_page_addr,绝不动 Page_Addr, 否则写入地址会被改成页首,导致每次都从 0 开始写 */ erase_page_addr = cur - cur % FLASH_PAGE_SIZE; //该地址所在页的页首 break; } } } } } /** * @brief 串口接收 => A程序 * */ void int_bootloader_init(void) { //清空掉初始化串口使用之前的所有问题。 __HAL_UART_CLEAR_OREFLAG(&huart1); //清楚掉ORE的标志位 __HAL_UART_CLEAR_IDLEFLAG(&huart1); //清楚掉IDLE的标志位,因为后面用的是空闲的中断,所以先清除一下标志位 //HAL_UARTEX_ReceiveToIdle()他是占用CPU的,不如使用带中断的。 //少一个参数,超时时间,因为他是异步执行的 HAL_UARTEx_ReceiveToIdle_IT(&huart1, uart_rec_buff, BOOTLOADER_UART_REC_BUFF_LEN); //他可能会出现一个bug,就是程序没有上电时,就有人给他源源不断的发数据,造成他的RDR寄存器被占满,溢出中断,但是此时还没上电。 //解决方法:在初始化最开始就清空串口的问题。 }main.c代码:
int main(void) { int_bootloader_init(); while (1) { // 2.2 如果需要擦除,则擦除当前页 if(is_erase) { FLASH_EraseInitTypeDef erase_init; erase_init.Banks = FLASH_BANK_1; //选择银行1 erase_init.NbPages = 1; //擦除页数 erase_init.PageAddress = erase_page_addr; //设置擦除页的起始地址(独立变量,不能用 Page_Addr) erase_init.TypeErase = FLASH_TYPEERASE_PAGES; //擦除类型为页 uint32_t PageError = 0; //定义一个指针,用来存储擦除的错误地址 HAL_FLASHEx_Erase(&erase_init, &PageError); /* flash擦除比较耗费性能 */ is_erase = 0; } if(uart_rec_len > 0) { uint16_t data16 = 0; //判断uart_rec_len + last_byte_flag) % 2来判断最后有没有多出来一个 if((uart_rec_len + last_byte_flag) % 2 == 0) // 这次刚好写完 { if(last_byte_flag == 1) { // 上次有遗留,这次没遗留 => 这次是奇数:1 5 Int_flash_write_with_last(); flash_write_offset += uart_rec_len + 1; //记录偏移 } else { // 上次没有遗留,这次也没有遗留 => 这次是偶数:2 4 Int_flash_write_no_last(); flash_write_offset += uart_rec_len; //记录偏移 } last_byte_flag = 0; } else // 这次写完后,还有单独遗留字节 { if(last_byte_flag == 1) { // 上次有遗留,这次也有遗留 => 这次是偶数:4 6 Int_flash_write_with_last(); flash_write_offset += uart_rec_len; //记录偏移 } else { // 这次写完会有遗留字节,上次没有遗留字节 Int_flash_write_no_last(); flash_write_offset += uart_rec_len - 1; } last_byte_flag = 1; last_byte = uart_rec_buff[uart_rec_len - 1]; } HAL_FLASH_Lock(); /*--------------------------------------------------------------------------------*/ //使用完数据之后,清空,随后准备下一次的接收 memset(uart_rec_buff, 0, BOOTLOADER_UART_REC_BUFF_LEN); uart_rec_len = 0; HAL_UARTEx_ReceiveToIdle_IT(&huart1, uart_rec_buff, BOOTLOADER_UART_REC_BUFF_LEN); /* 然后再打开空闲接收中断 */ } /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } }跳转到A程序:
使用函数指针的方式
/** * @brief 串口接收 => A程序 * */ void int_bootloader_init(void) { //清空掉初始化串口使用之前的所有问题。 __HAL_UART_CLEAR_OREFLAG(&huart1); //清楚掉ORE的标志位 __HAL_UART_CLEAR_IDLEFLAG(&huart1); //清楚掉IDLE的标志位,因为后面用的是空闲的中断,所以先清除一下标志位 //HAL_UARTEX_ReceiveToIdle()他是占用CPU的,不如使用带中断的。 //少一个参数,超时时间,因为他是异步执行的 HAL_UARTEx_ReceiveToIdle_IT(&huart1, uart_rec_buff, BOOTLOADER_UART_REC_BUFF_LEN); //他可能会出现一个bug,就是程序没有上电时,就有人给他源源不断的发数据,造成他的RDR寄存器被占满,溢出中断,但是此时还没上电。 //解决方法:在初始化最开始就清空串口的问题。 } /** * @brief 跳转到应用程序 */ void int_bootloader_jump_to_app(void) { typedef void (* pFunc)(void); // 1.0 校验 //栈顶地址的值 uint32_t app_start_ptr = *(volatile uint32_t *)APP_START_ADDR; //复位中断向量的值 uint32_t app_reset_hander = *(volatile uint32_t *)(APP_START_ADDR + 4); // 1.1 校验栈顶地址 if(app_start_ptr < SRAM_BASE_ADDR || app_start_ptr > (SRAM_BASE_ADDR + SRAM_SIZE)) { printf("Stack adder error!\n"); printf("app_start_ptr = 0x%08X\n", app_start_ptr); return; } else { printf("Stack adder ok!\n"); printf("app_start_ptr = 0x%08X\n", app_start_ptr); } // 1.2 校验复位中断地址 if(app_reset_hander < APP_START_ADDR && app_reset_hander > APP_END_ADDR) { printf("Reset adder error!\n"); return; } else { printf("Reset adder ok!\n"); printf("app_reset_hander = 0x%08X\n", app_reset_hander); } // 2.0 注销boot loader程序 NVIC_DisableIRQ(USART1_IRQn); NVIC_DisableIRQ(EXTI9_5_IRQn); // 关闭systick SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // // 2.2 注销HAL库 HAL_DeInit(); // 2.3 设置新的堆栈指针 __set_MSP(app_start_ptr); // 2.4 重定向中断向量表 SCB->VTOR = APP_START_ADDR; // 2.5 跳转到应用程序的复位中断向量地址 pFunc Jump_To_App = (pFunc)app_reset_hander; Jump_To_App(); }注意一点就是我们此时关闭了HAL的一些中断,然后再运行A程序的时候,可能会因为一些中断的关闭而运行不起来。所以我们保险期间需要在A程序的开头添加上:
__disable_irq(); //重定向中断向量表 SCB->VTOR = APP_SATRT_ADDR; __enable_irq();有一个小BUG要注意一下就是我的这些处理代码都是在mian.c中写的,也包括跳转函数,当我们每执行一次后,会直接执行这个跳转函数:我们的串口是用的空闲中断方式,发送时是256字节间隔50ms发送的,所以就会导致刚发送256字节就直接跳转,所以我添加了一个标志位每次写入后对他+1,当他的值 > 50 的时候才会去执行跳转函数.
人机交互
我们的启动boot loader的时候,日志输出给用户,因为可能有人发送hello这些不是bin文件的数据,就会导致擦错。所以我们有一个开始标记:串口发送 "Start" 或 硬件按下按键。结束:串口等待1s/2s没有接受到数据 或 硬件按下按键;最后校验程序。