首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

应用程序自编程

我们知道给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的启动过程,我们知道跳转进入应用程序需要完成三个操作:

  1. 切换中断向量表,启用应用程序的中断向量;
  2. 从中断向量表中获取应用程序的栈空间起始地址,并更新主栈指针(MSP);
  3. 通过复位中断向量跳转到应用程序的代码段中,开始执行应用程序。

而更新应用程序,则需要依次完成如下的工作:

  1. 擦除以0x08004000开始的应用程序代码空间;
  2. 接收来自上位机的更新程序;
  3. 将更新程序写入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完成关键一跃,从此变化为龙。

        int main(void) {
            usart1_init(115200);
            config_interruts();
            uart_send_bytes(USART1, "BootLoader\r\n", 12);
        
            uint8 cmd;
            while (1) {
                if (!usart1_rsv_byte(&cmd))
                    continue;
                uart_send_byte(USART1, cmd);
                if ('A' == cmd)
                    bootloader_jump();
            }
        }
        void bootloader_jump(void) {
            uint32 msp = *(uint32 *)(APP_FLASH_START_ADDR);
            uint32 reset = *(uint32 *)(APP_FLASH_START_ADDR+4);
            
            if((msp & 0x2FFE0000) == 0x20000000) {  
                SCB->VTOR = 0x08004000;
                
                uart_send_bytes(USART1, "Jumping......\r\n", 15);
        
                board_set_msp(msp);
                ((void (*)())(reset))();
            }
        }

在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的类型,目前有六种,分别为:

所有的record都是以校验和结束的,校验和覆盖了从Length开始到最后一个数据的所有字段,其计算方法就是对这些字段中的数据按字节求和然后用0x100减取其低8位。

4. 更新应用程序

下面我们修改BootLoader的超级循环,检测串口的输入字符,当接收到'B'的时候进入烧写模式,接收到'A'的时候执行跳转功能,修改后的main函数如下面左侧代码所示。 右侧为烧写代码的控制函数IapHex_Update,它也是一个超级循环。

在这个循环中,首先发送一个提示符'Y'标志着已经准备好了,可以接收数据了。 接着通过IapHex_RecvRecord函数接收到一个完整的Hex文件记录,如果没有接收到则重新提示'Y'。当接收到一个完整的记录后,通过函数IapHex_HandleRecord判定不同类型的记录, 并响应的做出处理。如果接收到了文件结束记录就发送提示符'Z'标志着烧写结束,并退出烧写模式。如果出现了错误则发送'N'报错,并退出。

        int main(void) {
            usart1_init(115200);
            config_interruts();
            uart_send_bytes(USART1, "BootLoader\r\n", 12);
        
            uint8 cmd;
            while (1) {
                if (!usart1_rsv_byte(&cmd))
                    continue;
                uart_send_byte(USART1, cmd);
                switch (cmd) {
                case 'A':
                    bootloader_jump();
                    break;
                case 'B':
                    IapHex_Update(&gIapHexParser);
                    break;
                }
            }
        }
        Iap_ErrType IapHex_Update(struct iap_hex_parser *parser) {
            Iap_ErrType err;
            
            while (1) {
                IapHex_SendByte('Y');

                if (Iap_NoErr != IapHex_RecvRecord(parser))
                    continue;
                
                err = IapHex_HandleRecord(parser);

                if (Iap_Finished == err) {
                    IapHex_SendByte('Z');
                    return Iap_NoErr;
                } else if (Iap_NoErr != err) {
                    IapHex_SendByte('N');
                    return err;
                }
            }
        }

上述的IapHex_Update函数有一个struct iap_hex_parser指针类型的参数parser,其定义如下。这是一个Hex文件的解析器, 它的成员变量base_addr用于记录扩展线性地址记录中的基地址,start_addr则是起始线性地址记录中的程序入口函数地址。 结构体struct iap_hex_record按照第3节中文件格式定义了Hex记录中的各个字段。

        struct iap_hex_parser {
            uint32 base_addr;
            uint32 start_addr;
            struct iap_hex_record record;
        };
        struct iap_hex_record {
            uint8 len;
            uint16 offset;
            uint8 type;
            uint8 data[256];
        };

为了方便移植,我们定义了如下的三个类函数宏定义,分别用于查询串口接收缓存的大小,从串口中获取字节,通过串口发送字节。 如果需要通过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码表示一个字节,所以需要先判定接收缓存中至少有两个字节。

        static Iap_ErrType IapHex_ExpectByte(uint8 data) {
            uint32 times = 0;
            uint8 rcv;
        
            while (!IapHex_RecvByte(&rcv)) {
                times++;
                if (times > CFG_HEX_RECV_TIMEOUT)
                    return Iap_TimeOutErr;
            }
        
            if (data != rcv)
                return Iap_CheckErr;
        
            return Iap_NoErr;
        }
        #define EXPECT(data) {              \
            err = IapHex_ExpectByte((data));    \
            if (Iap_NoErr != err)            \
                return err;                 \
        }
        static Iap_ErrType IapHex_ParseByte(uint8 *buf) {
            uint32 times = 0;
            while (IapHex_DataBufCount() < 2) {
                times++;
                if (times > CFG_HEX_RECV_TIMEOUT)
                    return Iap_TimeOutErr;
            }
            uint8 tmpH, tmpL;
            IapHex_RecvByte(&tmpH);
            IapHex_RecvByte(&tmpL);

            buf[0] = (HexChar2Uint8(tmpH) << 4)
                   + HexChar2Uint8(tmpL);
            return Iap_NoErr;
        }
        #define PARSE(data) {                 \
            err = IapHex_ParseByte((data));       \
            if (Iap_NoErr != err)                \
                return err;                     \
        }

下面是处理记录的函数,在该函数中根据记录类型做出响应的处理。接收到文件结束记录'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也给擦除了,我们需要保证这两个固件程序不在一个扇区里。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1