应用程序自编程
我们知道给MCU烧录程序一般都是使用STLink或者JLink等烧写工具下载程序。但这多少有些不方便, 因为每次都需要拿着线缆把开发板和烧录器连起来。在产品发布之后,如果还要求用户把外壳打开连上烧录器才能升级固件就显得很不专业了。 因此,就有了所谓的应用程序自编程技术,IAP(In-Application Programming)。
应用程序自编程技术使用预先定义的协议和接口实现固件升级。理论上可以通过各种通信接口来实现升级操作,比如说UART、I2C、USB等形式。 在本文中,我们以UART为例介绍IAP技术,其他形式的接口只需要实现对应的驱动就可以了。
1. IAP基本原理
让固件自己升级自己,需要做两个工作:首先把自己从MCU的Flash中擦除掉,再把新的固件写到Flash中。 这存在一个矛盾,就是固件把自己擦除之后,Flash中将不再有程序可以运行,又该如何完成后续的操作呢? 于是乎人们把MCU中的程序分为了BootLoader和Application两个部分。其中BootLoader是一段不变的程序, 用于擦写Application部分的程序,从而实现应用程序的更新。
对于一个嵌入式的程序而言,大体上可以分为三个部分:代码段、数据段和中断向量表。 BootLoader和Application是两个独立的程序,它们都各自有自己的三个部分。 由于代码段和中断向量表控制了程序的运行逻辑,所以它们是不能够共用的。而数据段的数据一般都是在片上RAM中, 每次复位后其中的数据都会丢失,所以这个地址空间应该是可以共用的。
在片上闪存保存参数一文中我们已经介绍过, 对于Flash的擦写操作必须是以扇区为单位进行的。所以为了保证更新固件的时候,不至于把BootLoader一起擦除了, 我们特意将应用程序的起始地址安排在STM32F407的第二个Flash扇区中了,即以0x08004000开始的一段存储空间。 如右图所示。
Flash空间的起始地址仍然用作是Bootloader的起始地址,这样MCU一上电就会执行BootLoader中的代码。 它将检查一些特定的条件,判定是进入应用程序还是执行更新操作。
参考STM32的启动过程,我们知道跳转进入应用程序需要完成三个操作:
- 切换中断向量表,启用应用程序的中断向量;
- 从中断向量表中获取应用程序的栈空间起始地址,并更新主栈指针(MSP);
- 通过复位中断向量跳转到应用程序的代码段中,开始执行应用程序。
而更新应用程序,则需要依次完成如下的工作:
- 擦除以0x08004000开始的应用程序代码空间;
- 接收来自上位机的更新程序;
- 将更新程序写入Application的代码空间中。
接下来,我们将解释该如何跳转到应用程序,然后介绍Hex文件格式并通过UART更新应用程序。
2. 跳转到应用程序
首先,我们需要准备一个应用程序,如下所示。在main函数的一开始先完成对UART1的初始化操作,配置串口波特率为115200。 再延时一段时间等待系统稳定后通过串口发送一个字符串标志着正在运行应用程序。最后在超级循环里将从串口中接收的数据再原样发送回去。
int main(void) {
usart1_init(115200);
config_interruts();
for (int i = 0; i < 1000; i++)
delay(1000);
uart_send_bytes(USART1, "Application\r\n", 13);
uint8 cmd;
while (1) {
if (!usart1_rsv_byte(&cmd))
continue;
uart_send_byte(USART1, cmd);
}
}
接下来,要想办法将这个应用程序的起始地址更改为0x08004000。在分析STM32的启动过程的时候, 我们提到过链接器通过分布文件(Scatter File)来描述程序的只读代码段和读写数据段的起始地址和大小。
这里我们重写一个分布文件,设定ROM1起始地址为0x08004000,如下代码中红色文字所示。将之保存在工程文件所在的目录中,命名为Test.sct。 打开工程配置对话框,按照右图所示修改工程配置,启用修改后的分布文件。
LR_IROM1 0x08004000 0x007C0000 { ; load region size_region
ER_IROM1 0x08004000 0x007C0000 { ; load address = execution address
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
RW_IRAM1 0x20000000 0x00020000 { ; RW data
.ANY (+RW +ZI)
}
}
直接将这个程序烧录下去并不会运行,因为上电复位后Cortex-M4内核总是从0x0地址获取栈空间起始地址,0x4地址获取复位向量并跳转其所指的地址开始执行程序。 在STM32F407中将0x08000000映射到了0x0地址上。而我们的测试程序则从0x08004000开始,系统不能直接跳转到这个地址上,所以不会运行。 这就需要一个在0x08000000烧录一个BootLoader,让系统跳转到BootLoader上,并以此为跳板跳转到测试程序中。
下面左边时BootLoader的主函数,我们以相同的配置设置串口,并发送一个字符串以表示正在执行BootLoader。在最后的超级循环中原样发送串口接收到的字节, 当接收到'A'的时候,就会执行右边的跳转程序bootloader_jump完成关键一跃,从此变化为龙。
|
|
在bootloader_jump中用到了一个宏定义APP_FLASH_START_ADDR,定义如下所示,记录了应用程序的起始地址。在刚刚提到的分布文件中,我们将RESET段放在了ER_IROM1的起始位置。 应用程序的RESET段是其中断向量表所保存的位置。所以APP_FLASH_START_ADDR所指的内存中记录了应用程序的栈空间起始地址,这里用局部变量msp获取之, 并在第10行中通过函数board_set_msp设定主栈指针寄存器MSP。
#define APP_FLASH_START_ADDR 0x08004000
board_set_msp是一段用汇编写的程序,内容很简单,就是把用R0保存的参数写道MSP寄存器中,然后通过链接寄存器LR返回。
board_set_msp
MSR MSP, R0
BX LR
APP_FLASH_START_ADDR+4所指的内存记录了应用程序的复位向量,用局部变量reset获取之。在第11行中首先对其进行强制类型转换为一个函数指针,然后调用该指针一跃龙门。 在第6行中,我们修改了Cortex-M4的系统控制模块(SCB)的中断向量表偏移地址寄存器(VTOR),启用应用程序的中断向量表。 在第8行中,打印一些提示信息告知系统将要发生跳转了。为了防止系统产生一些不可预知的行为,在第5行中首先检查一下msp是否指向了MCU的片上RAM区域。 如果没有指向,则说明没有成功烧写应用程序,不能跳转。
通过Keil将BootLoader和Application烧录到MCU中之后,复位运行。如果没有出现错误,就可以通过串口收到"BootLoader"标识着当前系统正在BootLoader中运行。 然后发送一个字节'A',就会先收到"AJumping......",其中的A则是返回的接收指令,标识着系统正在跳转。当成功调转到应用程序之后,就会发送"Application", 说明跳转成功,应用程序开始运行了。
能够成功实现跳转,我们的IAP功能就已经完成一半了,接下来的工作就是考虑如何把生成的镜像文件烧录到MCU的FLASH上了。镜像文件的存储方式有很多种, 比较常用的有*.bin和*.hex两种文件,这里以*.hex为例介绍如何烧录程序。
3. Hex文件格式
Hex文件最初是由Intel提出的,通过ASCII码记录二进制文件的格式。之所以选择ASCII记录程序, 是为了方便打印查看。在Hex文件中,用两个ASCII码表示一个字节的16进制,比如说2'b01011010,写成16进制就是0x5A,在Hex文件中就写作5A。
Hex文件以record的形式记录数据,每个record都是由如下的一些字段构成:
Mark | Length | Offset | Type | Data | CheckSum |
---|---|---|---|---|---|
1 Byte | 1 Byte | 2 Byte | 1 Byte | Length Byte | 1 Byte |
':' | Data长度 | 数据偏移地址 | Record类型 | Record数据内容 | 校验和 |
每一个record都是以Mark开始的,它的值为0x3A,对应ASCII的':'。接着是一个Length字段,记录了Data字段中的数据长度。Offset则描述了Data的偏移地址, 再加上一个基地址就可以确定数据存放在Flash中的位置。Type则是record的类型,目前有六种,分别为:
'04': 扩展线性地址记录(Extended Linear Address Record)。该记录只适用于32位的镜像文件, 用于描述线性基地址(Linear Base Address, LBA)的16到31位的内容,而LBA的低16位全为0。该条记录后的所有数据记录通过如下公式得到其绝对地址,即 $$ Addr = (LBA + Offset + Index) \% 4G $$ 其中LBA为扩展线性地址记录定义的基地址,Offset为数据记录中的偏移地址,Index则是数据记录中各个数据字节的索引。该记录可以出现在Hex文件的任何位置, 其作用域则一直持续到下一个扩展线性地址记录。如果没有该记录则LBA为0。
其Data字段是一个由四个ASCII码组成的4位16进制数,记录了高16位的LBA值,简记为ULBA。记录中的第10和11个ASCII码对应着高位数据,12和13个Hex对应着低位数据。
'02': 扩展段地址记录(Extended Segment Address Record)。该记录适用于16位的镜像文件。 记录了段基地址(Segment Base Address, SBA)的4到19位的内容,而SBA中剩下的的0到3位全为0。这种类型的记录可以出现在16位镜像文件的任何位置上, 其后的数据记录中的数据绝对地址通过如下的公式计算: $$ Addr = SBA + [(Offset +Index) \% 64K] $$ 其中SBA为扩展段地址记录定义的基地址,Offset则是数据记录中的偏移地址,Index是数据记录中各字节的索引。其作用域将一直持续到下一个扩展段地址记录为止。 如果Hex文件中没有扩展段地址记录,那么SBA默认为0。与上一种类型的记录类似,其Data字段用4个ASCII码记录了SBA的高16位内容,其中第10和11个码描述了高位数据, 第12和13个码对应着低位数据。
'00': 数据记录(Data Record)。该记录是具体的镜像文件内容,数据应当在文件中的哪个位置,参见'04'和'02'两种基地址记录。 由于Length字段是一个8位数据,所以数据记录最长只有255个字节。
'05': 起始线性地址记录(Start Linear Address Record)。该记录只适用于32位的镜像文件,用于声明可执行文件的入口地址。 这个记录就是为x86的系统设计的,如果目标系统使用的是80386的线性地址空间,这个记录的8个ASCII码声明了32位的EIP寄存器,高位地址在前。 如果使用的是80386的real mode,就要使用'03'起始段地址记录了。当然这些都是x86系统的设置,对于STM32的ARM架构而言不怎么需要。
'03': 起始段地址记录(Start Segment Address Record)。与'05'类似,这个记录就是指定了程序的入口地址。 它以CS/IP的形式描述入口,这是一种很古老的在8086机器上出现的东西了。
'01': 文件结束记录(End of File Record)。正如标题所说的那样,这是一个标志着文件结束的记录,没有数据字段。
所有的record都是以校验和结束的,校验和覆盖了从Length开始到最后一个数据的所有字段,其计算方法就是对这些字段中的数据按字节求和然后用0x100减取其低8位。
4. 更新应用程序
下面我们修改BootLoader的超级循环,检测串口的输入字符,当接收到'B'的时候进入烧写模式,接收到'A'的时候执行跳转功能,修改后的main函数如下面左侧代码所示。 右侧为烧写代码的控制函数IapHex_Update,它也是一个超级循环。
在这个循环中,首先发送一个提示符'Y'标志着已经准备好了,可以接收数据了。 接着通过IapHex_RecvRecord函数接收到一个完整的Hex文件记录,如果没有接收到则重新提示'Y'。当接收到一个完整的记录后,通过函数IapHex_HandleRecord判定不同类型的记录, 并响应的做出处理。如果接收到了文件结束记录就发送提示符'Z'标志着烧写结束,并退出烧写模式。如果出现了错误则发送'N'报错,并退出。
|
|
上述的IapHex_Update函数有一个struct iap_hex_parser指针类型的参数parser,其定义如下。这是一个Hex文件的解析器, 它的成员变量base_addr用于记录扩展线性地址记录中的基地址,start_addr则是起始线性地址记录中的程序入口函数地址。 结构体struct iap_hex_record按照第3节中文件格式定义了Hex记录中的各个字段。
|
|
为了方便移植,我们定义了如下的三个类函数宏定义,分别用于查询串口接收缓存的大小,从串口中获取字节,通过串口发送字节。 如果需要通过I2C、SPI等其它接口形式的IAP烧写方法,可以简单通过修改这几个宏定义实现。
#define IapHex_DataBufCount() usart1_buf_count()
#define IapHex_RecvByte(pByte) usart1_rsv_byte((pByte))
#define IapHex_SendByte(data) uart_send_byte(USART1, (data))
下面是解析Hex文件记录的函数,其工作过程有详细的注释说明,不再赘述了。
Iap_ErrType IapHex_RecvRecord(struct iap_hex_parser *parser) {
Iap_ErrType err;
uint8 sum = 0, sum_rcv, len;
// 接收记录头,解析数据长度
EXPECT(':');
PARSE(&len);
parser->record.len = len;
// 解析数据偏移地址
uint8 *offset = (uint8*)&(parser->record.offset);
PARSE(&(offset[1]));
PARSE(&(offset[0]));
// 判定记录类型
PARSE(&(parser->record.type));
// 解析数据内容
for (int i = 0; i < len; i++)
PARSE(&(parser->record.data[i]));
// 解析校验和
PARSE(&sum_rcv);
// 计算校验和,并检验是否匹配
sum = 0x100 - len - offset[0] - offset[1] - parser->record.type;
for (int i = 0; i < len; i++)
sum -= parser->record.data[i];
if (sum != sum_rcv)
return Iap_CheckErr;
return Iap_NoErr;
}
在IapHex_RecvRecord函数中用到了两个宏定义EXPECT和PARSE,下面是这两个宏定义的实现。 对于EXPECT宏定义,先用一个函数IapHex_ExpectByte接收并检查接收的字节是否与输入的函数匹配,否则就报错。 EXPECT宏定义必须在定义了Iap_ErrType类型的局部变量err的函数中调用若IapHex_ExpectByte没有正常返回就会退出调用函数。 类似的,PARSE宏定义也是相同的套路。只是在Hex文件中使用两个ASCII码表示一个字节,所以需要先判定接收缓存中至少有两个字节。
|
|
下面是处理记录的函数,在该函数中根据记录类型做出响应的处理。接收到文件结束记录'04',就返回Iap_Finished标识着Hex文件接收完毕。 接收到数据记录'00',就调用IapHex_ProgramData烧录程序。接收到基地址和起始地址记录则响应的更新解析器的base_addr和start_addr两个字段。
Iap_ErrType IapHex_HandleRecord(struct iap_hex_parser *parser) {
uint8 *data = parser->record.data;
switch (parser->record.type) {
case IAP_HexType_Eof_Record:
return Iap_Finished;
case IAP_HexType_Data_Record:
return IapHex_ProgramData(parser);
case IAP_HexType_ExtLinearAdd_Record:
parser->base_addr = (data[0] << 24) + (data[1] << 16);
break;
case IAP_HexType_StartLinearAdd_Record:
parser->start_addr = (data[0] << 24) + (data[1] << 16) + (data[2] << 8) + data[3];
break;
}
return Iap_NoErr;
}
下面这个函数才是烧写程序的关键,它先根据Hex的文件描述,计算出数据的具体地址,然后解锁Flash并烧写数据。 在烧写数据之前,需要先判定目标地址所在的Flash扇区是否需要擦写。数组flash_sector_start_addr记录了FLASH中所有扇区的起始地址, 全局变量gEraseSectorIdx记录了下一个需要烧写的扇区编号,当目标地址与查表后的扇区起始地址相同则说明需要再擦写一个扇区。
Iap_ErrType IapHex_ProgramData(struct iap_hex_parser *parser) {
uint32 addr = parser->base_addr + parser->record.offset;
int len = parser->record.len;
uint8 *data = parser->record.data;
flash_unlock();
for (int i = 0; i < len; i++) {
if (addr == flash_sector_start_addr[gEraseSectorIdx]) {
flash_sector_erase(gEraseSectorIdx);
gEraseSectorIdx++;
}
flash_write_byte(addr, data[i]);
addr++;
}
flash_lock();
return Iap_NoErr;
}
针对这个例程我用Qt写了一个上位机程序,用于通过这里实现的BootLoader烧写应用程序。 这里是打包后的程序,下面左图是解压后的文件,双击SerialPortTool.exe运行程序, 就可以看到右边的界面。点击其中红色圈出的按键,在弹出的对话框中选择需要烧写的文件,打开串口,点击烧写按键。就可以看到BootLoader发上来的各个提示消息, 最后接收到'Z'标识着烧录完毕。
然后在发送区写入一个字母'A'并发送下去触发程序跳转,不出意外就可以看到上面右图中看到的提示消息。
5. 总结
在本文中,我们介绍了如何在应用程序中实现固件升级。需要实现两个固件程序,一个是系统上电后立即执行的一段程序BootLoader, 这个程序是一个跳板,用于跳转到目标程序中,并实现对目标程序的更新。具体的功能实现则需要在应用程序中完成,它是系统上的第二个固件程序。 为了防止擦写应用程序时,把BootLoader也给擦除了,我们需要保证这两个固件程序不在一个扇区里。