一、认识Bootloader

在 Linux 系统中,软件组成可以跟 Windows 进行类比:

BootLoader 的主要作用是:
  1. 初始化硬件:比如设置时钟、初始化内存
  2. 启动内核:从 Flash 读出内核、存入内存、给内核设置参数、启动内核
  3. 调试作用:在开发产品时需要经常调试内核,使用 BootLoader 可以方便地更新内核
在单片机中,软件没那么复杂,一般只有一个程序,上电就运行这个程序,并不需BootLoader。
但是涉及软件升级时,必须引入要 BootLoader。假设没有 BootLoader,程序无法升级自己:

所以在单片机中,涉及软件升级时,必须引入 BootLoader:
Flash 上烧写有 BootLoader 和 APP(用户程序),启动过程如下:
  1. 上电时 BootLoader 先运行
  2. BootLoader 判断发现:Flash 上有 APP 并且无需升级,BootLoader 就会启动 APP
  3. BootLoader 判断发现:Flash 上没有 APP 或者需要升级,BootLoader 执行升级操作

二、必备知识

Cortex M3/M4/M33 启动流程
上电后,CPU 默认从 0 地址开始启动:
① 地址 0 就是默认的异常向量表基地址,使用 Flash 启动时 0 地址被映射到 Flash 基地址 0x08000000。
② CPU 读取异常向量表第 1 个 word(4 字节),写入 SP 寄存器
③ CPU 读取异常向量表第 2 个 word(4 字节),跳转执行:这就是 CPU 运行的第 1 个指令

SP寄存器细讲:SP寄存器是R13寄存器,这个寄存器就相当于一个人,一个人可以有2张银行卡,类似得SP也有两个“不同的视图”——MSP、PSP
系统启动阶段: 在系统复位后,处理器默认使用 MSP。此时,MSP 被用于启动代码和main()函数的执行。
任务调度阶段: 当 FreeRTOS 调度器启动时,每个任务都会被分配一个独立的堆栈,并使用 PSP 来管理其堆栈操作。这样可以确保每个任务都有独立的堆栈空间,避免相互干扰。
中断处理阶段: 在发生中断时,处理器会自动切换回 MSP,以处理异常或中断服务程序。这种设计确保了中断处理的可靠性和系统的稳定性。

开机流程:

异常向量表

当发生各类异常、中断时:
① 硬件会从异常向量表中,根据异常号、中断号找到一项,这项里保存的是“处理函数的 地址”
② 硬件跳转执行这个处理函数。

 以 SysTick 中断为例,SysTick 中断发生时,硬件会调用如下函数:

能正确使用中断的前提是:
① 把异常向量表的基地址告诉 CPU:这可以设置 SCB 里的 VTOR 寄存器(寄存器地址为 0xE000ED08
② 在异常向量表里,填充中断处理函数
CPU 内部寄存器
无论是 cortex-M3/M4/M33,CPU 内部都有 R0、R1...、R15 寄存器;它们可以用来 “暂存”数据。
对于 R13、R14、R15,还另有用途:
① R13:别名 SP(Stack Pointer),栈指针
② R14:别名 LR(Link Register),用来保存返回地址
③ R15:别名 PC(Program Counter),程序计数器,表示当前指令地址,写入新值即可跳转

三、简单例子见识Bootloader

我们实验使用单片机型号H5,STM32H563RIV 内置 2MB Flash

我们先简单划分一下flash,见识一下Bootloader的用处先:

默认CPU开始运行地址的映射地址为0x08000000,并且我们默认也是将程序从 0x08000000开始烧写,我们现在修改一下keil的设置,设置成从0x08040000开始烧写:

烧写进去后,因为映射地址和程序的开头地址不合,所以程序肯定也不会得以运行,板子上也不会有现象出现,我们先来编写一个程序,就是简单的控制板子上的LED闪烁 然后将这个程序从flash的0x08040000开始烧写,然后我们再简单写一个Bootloader启动程序APP

 根据二里面的必备知识就大概知道了程序启动的流程,所以我们就仿照里面的步骤来写一个启动APP,让前面的闪烁LED程序得以运行起来,所以我们要做的就是3件事:

①把异常向量表的基地址告诉 CPU:这可以设置 SCB 里的 VTOR 寄存器
② CPU 读取异常向量表第 1 个 word(4 字节),写入 SP 寄存器
③ CPU 读取异常向量表第 2 个 word(4 字节),跳转执行:这就是 CPU 运行的第 1 个指令
那我们就写一个汇编函数实现上面的功能:
LDR 指令在ARM架构中默认就是加载字(32位/4字节)

                AREA    |.text|, CODE, READONLY


; Reset Handler

start_app   PROC
            EXPORT  start_app
            ; 参数 R0 = App的起始地址 (e.g., 0x08040000)

            ; 1. 设置 VTOR 寄存器:将App的地址写入VTOR
            LDR R1, =0xE000ED08    ; R1 = VTOR寄存器的地址 (0xE000ED08)
            STR R0, [R1]           ; 将 R0 (0x08040000) 写入 [R1] (VTOR)
                                    ; 含义:CPU,以后中断就去 0x08040000 找向量表!

            ; 2. 初始化栈指针(SP):从App向量表的第一个条目加载初始栈顶
            LDR R1, [R0]           ; 从 R0 指向的地址(0x08040000)读取4字节 -> R1
                                    ; R1 = *(uint32_t*)0x08040000 (App的初始MSP值)
            MOV SP, R1              ; 将堆栈指针SP设置为R1的值
                                    ; 含义:为App准备好它自己的栈空间。

            ; 3. 跳转到App:从App向量表的第二个条目加载复位地址并跳转
            LDR R1, [R0, #4]       ; 从 R0+4 的地址(0x08040004)读取4字节 -> R1
                                    ; R1 = *(uint32_t*)0x08040004 (App的Reset_Handler地址)
            BX R1                   ; 跳转到 R1 寄存器中的地址执行
                                    ; 含义:永不返回地跳转到App的入口点!

            ENDP
            END

然后我们的APP程序(点灯程序)还要默认不使用默认的向量表,因为我的bootloader程序已经帮我设置好了VTOR:

然后在一个新的空白程序里面使用,然后烧录(这次是从0x08000000开始烧写),然后上面的简单函数就会让前面的闪烁LED程序“跑起来”。到这里demo就演示结束了。

四、中控初代升级方案设计

初代的升级方案是使用串口助手发送固件包给中控,中控进行升级,中控得到固件包后计算并且校验CRC码,一致的话进行每次16字节的烧录!后面新的固件升级方法是每次发送文件块了,每个文件块都有CRC校验,就不用这CRC校验函数了!这里只是最简单的升级方式而已!

4.1、 上位机与下位机

上位机使用 sscom 串口调试助手发送固件,如下图(数据定义后面再设计):
① 先发送文件信息
② 再发送文件
下位机:等待文件信息、读取上位机发来的数据、烧写。

4.2、Flash 使用规划

STM32H563RIV 内置 2MB Flash,划分如下:
① Bootloader 占据 256KB 空间
② APP 占据 1784KB 空间
③ 配置信息占据最后一个扇区 8KB 空间:用来保存 APP 版本、大小、校验码等信息。
这个型号中flash有2MB空间 = 1024X2KB = 1024X2X1024字节
并且flash中分为两个bank部分,每个占据1M,每个bank又分为128个扇区(0~127),所以每个扇区占据8K空间,所以我们划分空间如上图所述,bank2最后一个扇区储存程序bin文件配置信息
我们每次都可以根据配置信息进行升级或者不升级,每次升级完成后都需要将新的配置信息烧写进去里面,以便下次升级使用,这样逻辑就清晰起来了
我们首先进行程序升级都需要获得程序编译生成的bin文件,获取方式:

bin文件就作为我们每次使用上位机进行发送的文件,上位机和下位机之间的通信,我打算首先下位机获取上位机和下位机本身的程序进行对比版本号,如果需要升级的话就发送‘1’请求上位机发送文件的“信息”,信息详细内容后面会将,然后上位机发送信息后,下位机成功接收到后就发送‘2’,此时上位机就可以将新的bin文件发送给下位机,下位机接收到后就根据文件大小和地址对flash进行擦除和烧写

文件“信息”的内容:

typedef struct FirmwareInfo {
    uint32_t version;//程序bin文件版本号
    uint32_t file_len;//程序bin文件文件长度
    uint32_t load_addr;//下载地址
    uint32_t crc32;//校验码
	uint8_t file_name[16];//bin文件名字
}FirmwareInfo, *PFirmwareInfo;

4.3、Bootloader 实现下载功能

我们使用一个脚本可以根据bin文件生成固件信息,格式如下

其中的CRC32是我们接收到固件后,需要进行校验固件大小是否正确需要使用的,其中的文件长度file_len是16的倍数,简化后面我们烧录代码量的判断

4.3.1、具体函数实现:

4.3.1.1、获取本地固件信息

我们的本地的固件信息储存在:

#define CFG_OFFSET 0x081FE000

static int GetLocalFirmwareInfo(PFirmwareInfo ptFirmwareInfo)
{
    PFirmwareInfo ptFlashInfo = (PFirmwareInfo)CFG_OFFSET;
    
    if (ptFlashInfo->file_len == 0xFFFFFFFF)
        return -1;
    
    *ptFirmwareInfo = *ptFlashInfo;
    return 0;
}
4.3.1.2、获取远端固件信息

我们提前设计好了升级方案,我们MCU需要发送“特定”的信息给上位机,上位机才会发送给我们远端固件信息,我这里设计的就是字符“1”,简单方便
前面我也说过了,固件信息是脚本生成的,格式如下:

这个是大端形式的,我们需要转换成小端形式

/* 大端转小端 */
static uint32_t BE32toLE32(uint8_t *buf)
{
    return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) | ((uint32_t)buf[2] << 8) | ((uint32_t)buf[3] << 0);
}

我们发送“1”后,上位机就会发送固件信息给我们,我们就需要先转换成小端形式:

/* 获取上位机服务端的bin文件信息 */
static int GetServerFirmwareInfo(PFirmwareInfo ptFirmwareInfo)
{
    uint8_t data = '1';
    uint8_t buf[sizeof(FirmwareInfo)];

    /* send 0x01 cmd to PC */
    if (0 != g_pUpdateUART->Send(g_pUpdateUART, &data, 1, UPDATE_TIMEOUT))
        return -1;

    /* wait for response */
    while (1)
    {
        if (0 != g_pUpdateUART->RecvByte(g_pUpdateUART, &data, UPDATE_TIMEOUT*10))
            return -1;

        if (data != 0x5a)
        {
            buf[0] = data;
            break;
        }
    }

    /* get firmware info */
    for (int i = 1; i < sizeof(FirmwareInfo); i++)
    {
        if (0 != g_pUpdateUART->RecvByte(g_pUpdateUART, &buf[i], UPDATE_TIMEOUT))
            return -1;
    }

    ptFirmwareInfo->version = BE32toLE32(&buf[0]);
    ptFirmwareInfo->file_len = BE32toLE32(&buf[4]);
    ptFirmwareInfo->load_addr = BE32toLE32(&buf[8]);
    ptFirmwareInfo->crc32 = BE32toLE32(&buf[12]);
    strncpy((char *)ptFirmwareInfo->file_name, (char *)&buf[16], 16);

    return 0;
    
}
4.3.1.3、获取远端固件

然后我们继续发送字符“2”,发送成功后,上位机就会下发固件

/* 获取上位机服务端的bin文件 */
static int GetServerFirmware(uint8_t *buf, uint32_t len)
{
    uint8_t data = '2';

    /* send 2 cmd to PC */
    if (0 != g_pUpdateUART->Send(g_pUpdateUART, &data, 1, UPDATE_TIMEOUT))
        return -1;

    /* get firmware info */
    for (int i = 0; i < len; i++)根据固件信息的长度接收固件
    {
        if (0 != g_pUpdateUART->RecvByte(g_pUpdateUART, &buf[i], UPDATE_TIMEOUT*10))
            return -1;
    }
    return 0;
}
4.3.1.4、根据远端固件计算CRC32

接收到固件后,我们还需要对接收到的固件进行校验,校验内容是否接收完成,大小是否正确,这就使用到了CRC32值,我参考的一个github的代码,直接使用了:

/* https://lxp32.github.io/docs/a-simple-example-crc32-calculation/ */
static int GetCRC32(const char *s,size_t n)
{
    uint32_t crc=0xFFFFFFFF;

    for(size_t i=0;i<n;i++) {
            char ch=s[i];
            for(size_t j=0;j<8;j++) {
                    uint32_t b=(ch^crc)&1;
                    crc>>=1;
                    if(b) crc=crc^0xEDB88320;
                    ch>>=1;
            }
    }

    return ~crc;
}

计算出来后,就将这个CRC32值和我们获取到的远端固件信息里面的CRC32值进行对比是否一致,如果一致就将这个固件进行烧录到我们flash的APP区!

4.4、Bootloader 实现烧录功能

烧录的话我们需要涉及到flash的擦除和烧录功能,这两个的话HAL库都会有函数,我们根据参数使用即可,关于重新启动的话使用软复位。

在启动 APP 之前,应该让系统“尽量”处于初始状态。比如:关闭各类中断、让各类设备处于初始状态。有一个办法可以轻松实现这点:软件复位。

我们烧录需要将新文件烧录进flash里面,然后将新的固件信息烧录进flash最后一个扇区里面。

4.4.1、具体函数实现:

4.4.1.1、烧写固件函数

我们烧写固件前,需要先擦除,擦除我们使用HAL_FLASHEx_Erase函数,因为我们的H5有两个bank,有128*2个扇区,所以我们需要根据我们的固件大小去计算需要擦除的地方,然后烧录我们使用HAL_FLASH_Program函数

根据固件信息的烧录地址计算即可。然后我们采用每次16个字节烧录

/* 烧写文件函数 */
static int WriteFirmware(uint8_t *firmware_buf, uint32_t len, uint32_t flash_addr)
{
    FLASH_EraseInitTypeDef tEraseInit;
    uint32_t SectorError;
		uint32_t sectors = (len + (SECTOR_SIZE - 1)) / SECTOR_SIZE;//计算新文件要擦除的扇区个数
    uint32_t flash_offset = flash_addr - 0x08000000;
		uint32_t bank_sectors;//最多擦除的扇区个数
    uint32_t erased_sectors = 0;
    
    HAL_FLASH_Unlock();

    /* erase bank1 */
    if (flash_offset < 0x100000)
    {
        tEraseInit.TypeErase = FLASH_TYPEERASE_SECTORS;
        tEraseInit.Banks     = FLASH_BANK_1;
        tEraseInit.Sector    = flash_offset / SECTOR_SIZE;
        bank_sectors = (0x100000 - flash_offset) / SECTOR_SIZE;
        if (sectors <= bank_sectors)
            erased_sectors = sectors;
        else
            erased_sectors = bank_sectors;
        tEraseInit.NbSectors = erased_sectors;
        
        if (HAL_OK != HAL_FLASHEx_Erase(&tEraseInit, &SectorError))
        {
            g_pUpdateUART->Send(g_pUpdateUART, (uint8_t *)"HAL_FLASHEx_Erase Failed\r\n", strlen("HAL_FLASHEx_Erase Failed\r\n"), UPDATE_TIMEOUT);
            HAL_FLASH_Lock();
            return -1;
        }

        flash_offset += erased_sectors*SECTOR_SIZE;
    }

    sectors -= erased_sectors;
    flash_offset -= 0x100000;
    
    /* erase bank2 */
    if (sectors)
    {
        tEraseInit.TypeErase = FLASH_TYPEERASE_SECTORS;
        tEraseInit.Banks     = FLASH_BANK_2;
        tEraseInit.Sector    = flash_offset / SECTOR_SIZE;
        bank_sectors = (0x100000 - flash_offset) / SECTOR_SIZE;
        if (sectors <= bank_sectors)
            erased_sectors = sectors;
        else
            erased_sectors = bank_sectors;
        tEraseInit.NbSectors = erased_sectors;
        
        if (HAL_OK != HAL_FLASHEx_Erase(&tEraseInit, &SectorError))
        {
            g_pUpdateUART->Send(g_pUpdateUART, (uint8_t *)"HAL_FLASHEx_Erase Failed\r\n", strlen("HAL_FLASHEx_Erase Failed\r\n"), UPDATE_TIMEOUT);
            HAL_FLASH_Lock();
            return -1;
        }
    }

    /* program */
    len = (len + 15) & ~15;将长度值向上对齐到16字节的倍数

    for (int i = 0; i < len; i+=16)
    {
        if (HAL_OK != HAL_FLASH_Program(FLASH_TYPEPROGRAM_QUADWORD, flash_addr, (uint32_t)firmware_buf))
        {
            g_pUpdateUART->Send(g_pUpdateUART, (uint8_t *)"HAL_FLASH_Program Failed\r\n", strlen("HAL_FLASH_Program Failed\r\n"), UPDATE_TIMEOUT);
            HAL_FLASH_Lock();
            return -1;
        }

        flash_addr += 16;
        firmware_buf += 16;
    }


    HAL_FLASH_Lock();
    return 0;

}
4.4.1.2、烧写固件信息函数

这个就简单了,固定烧写是最后一个扇区,直接擦除然后烧录即可

/* 烧写bin文件配置信息 */
static int WriteFirmwareInfo(PFirmwareInfo ptFirmwareInfo)
{
    FLASH_EraseInitTypeDef tEraseInit;
    uint32_t SectorError;
    uint32_t flash_addr = CFG_OFFSET;
    uint8_t *src_buf = (uint8_t *)ptFirmwareInfo;
    
    HAL_FLASH_Unlock();

    /* erase bank2 */
    tEraseInit.TypeErase = FLASH_TYPEERASE_SECTORS;
    tEraseInit.Banks     = FLASH_BANK_2;
    tEraseInit.Sector    = (flash_addr - 0x08000000 - 0x100000) / SECTOR_SIZE;
    tEraseInit.NbSectors = 1;
    
    if (HAL_OK != HAL_FLASHEx_Erase(&tEraseInit, &SectorError))
    {
        g_pUpdateUART->Send(g_pUpdateUART, (uint8_t *)"HAL_FLASHEx_Erase Failed\r\n", strlen("HAL_FLASHEx_Erase Failed\r\n"), UPDATE_TIMEOUT);
        HAL_FLASH_Lock();
        return -1;
    }

    /* program */
    for (int i = 0; i < sizeof(FirmwareInfo); i+=16)
    {
        if (HAL_OK != HAL_FLASH_Program(FLASH_TYPEPROGRAM_QUADWORD, flash_addr, (uint32_t)src_buf))
        {
            g_pUpdateUART->Send(g_pUpdateUART, (uint8_t *)"HAL_FLASH_Program Failed\r\n", strlen("HAL_FLASH_Program Failed\r\n"), UPDATE_TIMEOUT);
            HAL_FLASH_Lock();
            return -1;
        }

        flash_addr += 16;
        src_buf += 16;
    }

    HAL_FLASH_Lock();
    return 0;
}
4.4.1.3、启动相关函数
/* 判断复位是否是软件复位 */
int isSoftReset(void)
{
    return HAL_RCC_GetResetSource() & RCC_RESET_FLAG_SW;
}

/* 返回升级程序的异常向量表基地址——烧录的地址 */
uint32_t get_app_vector(void)
{
    PFirmwareInfo ptFlashInfo = (PFirmwareInfo)CFG_OFFSET;
    return ptFlashInfo->load_addr;
}

/* 触发软件复位 */
static void SoftReset(void)
{
    __set_FAULTMASK(1);//关闭所有中断
    HAL_NVIC_SystemReset();
}

static void start_app_c(void)
{
    /* 触发软件复位 */
    SoftReset();
}

跳转函数我们还是使用三、里面的,这里使用例子:

/* 判断是否是软复位 */
if (isSoftReset())
{
    extern void start_app(uint32_t vector);     
    /* 更新异常向量表基地址,并跳转 */  
    start_app(get_app_vector());
}

五、IAP升级中控和传感器(最终)

前面我们大概了解了boot loader的功能和职责,就是可以决定是否跳转到APP或是升级程序后再跳转升级烧录后的APP,首先我们需要了解,升级分为升级成功或升级失败,升级成功就不过多介绍了,升级失败处理不好的话就会变砖的,变砖——程序烧录一半(未烧录完全),仍旧跳转到残缺的APP,那么恭喜,这个板子就变砖了!!

升级呢?分为多种升级,第一种就是有两个OTA分区的,当前在一个OTA分区中,然后升级烧录的是另外一个OTA分区,升级成功后就启用另外一个OTA分区为使用分区,升级失败的话仍旧使用原本分区,还有一种就是我们现在介绍的工业互联的升级方式:

我们只有一个分区,boot loader呢功能程序是固定不变的,我们每次升级都需要重启进入boot loader,然后再对APP分区进行升级烧录,所以我们需要实现一个判断功能,就是用于给Boot loader程序判断是否要跳转到APP分区,跳转到APP分区有两种情况:

  • 无需升级
  • 升级成功

我们可以使用一个标志位,储存于配置信息中,然后boot loader就可以从中读取出来用于判断是否需要跳转APP分区运行程序

5.1、升级流程

升级流程分为 3 个步骤:
(1) 上位机让目标板进入 Bootloader
无论是中控还是传感器,它当前运行的程序可能是“Bootloader”或“APP”。要升级
APP,必须让它进入 Bootloader:
  1. 对于中控:上位机直接发命令给中控让它进入 Bootloader
    中控处于 Bootloader 的话则无需重启;中控处于 APP 的话,需要设置配置信息表明要
    进入 Bootloader,然后软件复位;重启后运行的是 Bootloader,它要根据配置信息保持在
    Bootloader。
  2. 对于传感器:上位机直接发命令给中控,中控再转发给传感器让它进入 Bootloader
    传感器处于 Bootloader 的话则无需重启;传感器处于 APP 的话,需要设置配置信息表
    明要进入 Bootloader,然后软件复位;重启后运行的是 Bootloader,它要根据配置信息保
    持在 Bootloader。
注意:升级传感器时,中控要运行 APP:它功能比 Bootloader 强大,可以更方便地操 作传感器。
所以:要升级中控,中控要运行 Bootloader。要升级传感器,中控要运行 APP,传感 器要运行 Bootloader。
(2) 上位机发送固件、目标板接收到固件后烧录
(3) 上位机让目标板进入 APP
当上位机发送完固件后,再给中控或传感器发送“启动 APP”的命令。目标板就要设置配置信息表明要进入 APP,然后软件复位;重启后运行的是 Bootloader,它要根据配置信息启动 APP。
大概升级流程图:

因为上位机是通过发送命令来控制中控或者传感器重启然后是否启动APP的,所以我们首先需要固定一下命令,比如     写入 0x55 : 启动最终进入 Bootloader     写入 0xAA : 启动最终进入 APP

0x55使用场景:

  • 首次升级,或者未经历过升级失败——当前在APP程序,所以发送后,MCU需要修改配置信息,修改为下次boot loader不跳转APP程序,然后重启重新进入boot loader等待升级
  • 经历过升级失败——当前在boot loader程序,因为上次升级前肯定修改过了配置信息,并且升级失败了没有接收到0xAA,没有修改配置信息为启用APP,所以这次无需重启

0xAA使用场景:

  • 这个只有在升级成功后才会发送,然后MCU就会修改配置信息为启用APP,然后就重启进入boot loader,然后启用新的APP

发送命令,我们可以给中控和传感器都新增一个AO,用于接收命令

5.2、升级细节

因为前面说了,配置信息中有着版本升级的版本号以及其他信息,所以我们在这里需要新增一个成员——用于判断最终是否进入APP:

typedef struct FirmwareInfo {
    uint32_t version;//程序bin文件版本号
    uint32_t file_len;//程序bin文件文件长度
    uint32_t load_addr;//下载地址
    uint32_t crc32;//校验码
	uint8_t file_name[16];//bin文件名字
    uint8_t bEnterBootloader;//最终是否进入APP
}FirmwareInfo, *PFirmwareInfo;

5.3、中控作为固件中转站实现传感器固件升级

5.3.1、改写modbus实现WriteFileRecord功能

WriteFileRecord功能就是文件块传输功能,每次传输的大小有限制,如果工程较大,固件大小较大,就需要分小块发送,我们想要改写实现这个功能,必须先了解WriteFileRecord的报文格式:

想想这个场景,你要发出多个文件,每个文件还都很大,那么必然就是分块发送:
① 当前发送的是哪个文件?数据包里有“File Number”
② 当前发送的是第几个数据包?数据包里有“Record Number”
③ 当前发送的数据包多大?数据包里有“Record length”
④ 数据本身

其中的Request data length = 7 + 2N,7指的是后面的7字节(上图红色圈出的),2N指的是后面的Record data的全部数据,因为一个数据是16位的,也就是2字节,所以Request data length = 7 + 2N,其中协议规定整个数据包的大小<=256字节,所以Request data length最大的时候就是:

256-1(设备地址)-2(CRC校验)-1(功能吗)-1(Request data length) = 251 = 0xFB

最小呢,就简单计算了,当N = 1的时候就是最小,所以就是0x09 to 0xFB
那么传输的数据的字节数最大最小就是Request data length - 7了,就是2Bytes to 244Bytes

所以我们可以合理的在这个范围设置我们每次传输的文件块的大小

改写出来我们自己的modbus_write_file_record,我们必须要很熟悉报文格式,然后可以仿照代码的其他API,无非就是为了构建一个完整的报文进行发送。

#define MODBUS_FC_WRITE_FILE_RECORD        0x15


int modbus_write_file_record(modbus_t *ctx,
                               uint16_t file_no,
                               uint16_t record_no,
                               uint8_t *buffer,
                               uint16_t len)
{
    int rc;
    int i;
    int byte_count;
    int req_length;
    int bit_check = 0;
    int pos = 0;
    uint8_t req[MAX_MESSAGE_LENGTH];
    /* 长度是2N */
    //len = (len + 1) & ~0x1;//向上取偶

    /* ADU最大是256, 256-1字节设备地址-2字节CRC=253
     * 功能码等包头是7字节
     * 传输的数据最大253-9=244
     */
    if (len < 2 || len > 244 )
        return -1;

    /* 构建请求数据包基础数据 */
    req[0] = ctx->slave;
    req[1] = MODBUS_FC_WRITE_FILE_RECORD;
    req[2] = 7 + len;        /* Request data length */
    req[3] = 0x06;           /* Sub-Req. x, Reference Type */
    req[4] = file_no >> 8;        /* Sub-Req. x, File Number */
    req[5] = file_no & 0x00ff;
    req[6] = record_no >> 8;        /* Sub-Req. x, Record Number */
    req[7] = record_no & 0x00ff;
    req[8] = (len/2) >> 8;        /* Sub-Req. x, Record length */
    req[9] = (len/2) & 0x00ff;

    req_length = 10;
    /* 数据 */
    for (i = 0; i < len; i++)
    {
        req[req_length++] = buffer[i];
    }

    rc = send_msg(ctx, req, req_length);
    if (rc > 0) {
        /* Used by write_bit and write_register */
        uint8_t rsp[MAX_MESSAGE_LENGTH];

        rc = _modbus_receive_msg(ctx, rsp, MSG_CONFIRMATION);
        if (rc < 0)
            return -2;

        rc = check_confirmation(ctx, req, rsp, rc);
    }

    return rc;
}

然后我们还要改造其中的这两个函数:

根据modbus手册中,发送文件块时,接收方如果成功接收就会回复相同的报文,所以check_confirmation函数检查报文格式就简单多了,只需要比较发送报文和回复的报文是否一致就可以了!

对于_modbus_receive_msg,改造就相对复杂一点点,前面我发的modbus源码解析文章也有说了使用场景的接收报文的函数的解析,详情可见:modbus源码解析

大致流程就是:

所以我们只需要修改里面的计算下一阶段需要接收的字节数函数即可,因为每个请求的报文的稍微有点不一致,

修改如下:

第一阶段都是读取地址+功能码,都是2字节,所以这里不用修改

第二阶段数据长度有所区别:

然后下一阶段

最后就是我们从设备的modbus_reply函数,这个也简单粗暴,因为回复的报文是一摸一样的,所以直接执行copy操作即可。新增分支:

这样我们就完成了WriteFileRecord 功能!

5.3.2、完整文件传输

我们怎么知道对方发送的文件需要分开多少个record进行发送呢?这是我们作为接收方必须要知道的,不然什么时候对方发送完了理应我们应该重新启动使用新固件了,但是我们不知道还在傻傻等,所以我们需要定义一个协议,我们发送方第一个不发送真正的文件,先发送文件的“身份证”,脸面的内容有文件的大小,文件的名称,发送方还要规定自己一次性最多发送多少字节的内容(2-244),这样接收方就可以根据文件大小计算需要接收多少个record了,那么接收方怎么知道文件的“身份证”是哪一个呢?这就需要依靠这个了:我们定义为0,发送的是文件“身份证”,不为0 就是文件本身,这里补充一下,发送的内容是大端格式

int modbus_write_file(modbus_t *ctx,
                              uint16_t file_no,
                              uint8_t *file_name,
                              uint8_t *buffer,
                              uint16_t len)//这里的len永远是偶数,我的脚本会将固件填充成偶数的大小
{
    FileInfo tFileInfo;
    int rc;
    uint16_t record_no = 0;
    uint16_t pos = 0;
    uint16_t send_len = 0;
        
    memset(&tFileInfo, 0, sizeof(tFileInfo));
    
    tFileInfo.file_len = len;
    tFileInfo.file_len = LE32toBE32(&tFileInfo.file_len);
    if (file_name)
    {
        strncpy(tFileInfo.file_name, file_name, sizeof(tFileInfo.file_name));
    }
    /* 发送头部record——总文件的大小、文件名字 */
    rc = modbus_write_file_record(ctx, file_no, record_no, &tFileInfo, sizeof(tFileInfo));
    if (rc < 0)
    {
        return rc;
    }
    record_no++;//发送下一个record

    /* 分块发送文件块,最多一次发送240k */
    while (pos < len)
    {
        send_len = len - pos;
        if (send_len > 240)
            send_len = 240;  /* 选取一次发送240字节是为了便于烧录(烧录时一次烧写16字节) */
        
        rc = modbus_write_file_record(ctx, file_no, record_no, buffer + pos, send_len);
        if (rc < 0)
        {
            return -5;
        }
        record_no++;
        pos += send_len;
    }

    return 1;
}

5.3.3、从报文中解析固件

我们还是需要很了解报文格式,明确知道哪里是数据:

static void modbus_parse_file_record(uint8_t *msg, uint16_t msg_len)
{
    uint16_t record_no;
    FileInfo tFileInfo;
    char buf[100];
    static int recv_len = 0;
    
    if (msg[1] == MODBUS_FC_WRITE_FILE_RECORD)
    {
        record_no = ((uint16_t)msg[6]<<8) | msg[7];//解析record的num
        if (record_no == 0)//头部record
        {
            tFileInfo = *((PFileInfo)&msg[10]);
            tFileInfo.file_len = BE32toLE32(&tFileInfo.file_len);
            sprintf(buf, "Get File Record for Head, file len = %d", tFileInfo.file_len);//打印即将得到的文件的大小
            Draw_String(0, 32, buf, 0xff0000, 0);

            recv_len = 0;
        }
        else
        {
            recv_len += msg[2] - 7;//计算收到的文件的大小
            
            sprintf(buf, "Get File Record %d for Data, record len = %d, recv_len = %d   ", record_no, msg[2] - 7, recv_len);
            Draw_String(0, 64, buf, 0xff0000, 0);  
            
            //烧录操作(每次16字节烧录)


            //烧录的内容够了,就填写文件配置信息,重启
        }
    }
}

5.4、完整升级流程

首先呢?升级分为中控升级和传感器升级,这两者的固件文件都是由上位机通过多次发送文件块发送的,那么上位机发送来一次文件块,中控肯定是直接接收的,那么中控怎么知道这个文件块是上位机发送给自己的,还是上位机发送给传感器的,需要中控进行转发的呢?

我就在每次的文件块的报文中做了一点“手脚”,原本报文格式如下:

我们在File Number中操作一下,将他16个字节的数据拆分一下:

/* file_no是16位的数值
         * 约定:
         * bit[15:12]: channel
         * bit[11:8] : 真正的file_no, 0-map信息(仅对中控有效),1-固件
         * bit[7:0]  : dev_addr
         */

根据这么一个16字节的信息我们就可以根据channel知道这个文件块是发送给谁的,它的modbus设备地址是多少?如果是自己的就自己进一步操作烧录,如果是传感器的,就通过modbus发送文件块功能转发给传感器,传感器收到后就进一步操作烧录

然后还有一个问题就是,上位机发送文件块,上位机发送完了,中控还是不知道你到底有没有发送完啊,所以一直没有重启进入APP,这样就没有完成一次完美的程序升级,我是这样做的,我发送文件块第一次是发送一个结构体,里面有我这里要发送的固件总文件的大小和文件名字,那么你不就可以根据烧录文件的大小和这个总文件大小对比就知道上位机是否发送完了固件了吗?

定义协议如下:

  • 第一次接收 Record Number为0的文件块,里面有着这次升级的固件的总文件大小和文件名字
  • 后面根据Record Number为0的文件块的信息,判断是否烧录完整,然后写入文件配置信息表明下一次最终进入APP程序,然后软件重启重启进入APP程序

传感器升级也是如此,只不过多了一个需要中控转发文件块!

这样就完成了一次程序升级!

那就剩下传感器升级了:

传感器升级,中控必须处于APP程序,因为APP程序有更多功能可以和传感器通信,中控的bootloader程序只有升级功能仅此而已,APP程序有两种“模式”,一个是正常模式,一个是升级模式,升级模式呢?那些CHn任务就会停止不断读取传感器寄存器值100s(足够正常升级了),让出串口总线给中控转发文件块,避免超时的情况,如果100s后没有升级成功,就证明出现了问题,上位机可能崩了,这时候CHn任务就会自动恢复正常模式,可以正常和传感器通信,继续不断读取传感器寄存器值,上位机和传感器之间有着一个中控作为一个中转站,所以每次都必须等传感器烧录完成了。回复中控,然后中控再回复给上位机,上位机才会继续下一个文件块的发送

前面我已经说了H5的重启后跳转APP的方法,告诉VTOR寄存器APP的异常向量表的位置,然后初始化栈,然后进行一系列的硬件初始化,最终会跳转到main函数,因为H5可以支持异常向量表重定向!

LDR 指令在ARM架构中默认就是加载字(32位/4字节)

                AREA    |.text|, CODE, READONLY


; Reset Handler

start_app   PROC
            EXPORT  start_app
            ; 参数 R0 = App的起始地址 (e.g., 0x08040000)

            ; 1. 设置 VTOR 寄存器:将App的地址写入VTOR
            LDR R1, =0xE000ED08    ; R1 = VTOR寄存器的地址 (0xE000ED08)
            STR R0, [R1]           ; 将 R0 (0x08040000) 写入 [R1] (VTOR)
                                    ; 含义:CPU,以后中断就去 0x08040000 找向量表!

            ; 2. 初始化栈指针(SP):从App向量表的第一个条目加载初始栈顶
            LDR R1, [R0]           ; 从 R0 指向的地址(0x08040000)读取4字节 -> R1
                                    ; R1 = *(uint32_t*)0x08040000 (App的初始MSP值)
            MOV SP, R1              ; 将堆栈指针SP设置为R1的值
                                    ; 含义:为App准备好它自己的栈空间。

            ; 3. 跳转到App:从App向量表的第二个条目加载复位地址并跳转
            LDR R1, [R0, #4]       ; 从 R0+4 的地址(0x08040004)读取4字节 -> R1
                                    ; R1 = *(uint32_t*)0x08040004 (App的Reset_Handler地址)
            BX R1                   ; 跳转到 R1 寄存器中的地址执行
                                    ; 含义:永不返回地跳转到App的入口点!

            ENDP
            END

但是我们的传感器F030不支持,它们是廉价的,低成本的,它们没有VTOR这个寄存器,对于Cortex-M0架构,他们的异常向量表永远是0(映射到0x080000),对于传感器的flash规划:

我们是没有办法将0映射到0x020000的,但是我们有办法将它映射到RAM中:

void RelocateVector(void)
{
    // #define APP_LOAD_ADDR 0x08020000
    // #define VECTOR_ADDR_AT_RAM    (0x20000000)
    // #define VECTOR_SIZE_AT_RAM    (200)

    /* http://www.51hei.com/bbs/dpj-40235-1.html */
    memcpy((void *)VECTOR_ADDR_AT_RAM, (void *)APP_LOAD_ADDR, VECTOR_SIZE_AT_RAM);
    __HAL_SYSCFG_REMAPMEMORY_SRAM();
}

那么就证明程序中不能使用RAM的前面200字节了,那么我们就需要再Keil中设置一下:

C8十进制就是200,告诉MCU,RAM前面200字节不可用。

那么传感器的跳转APP的操作就比H5少了一步设置VTOR寄存器,但是多了一步,将0映射到RAM中:


                AREA    |.text|, CODE, READONLY


; Reset Handler

start_app   PROC
                EXPORT  start_app                
                ; READ val of address(0x08020000), set to SP
                LDR R1, [R0]
                MOV SP, R1
                
                ; READ val of address(0x08020004), jump
                LDR R1, [R0, #4]
                BX R1
                    
                ENDP
                END



//跳转APP
RelocateVector();
start_app(get_app_vector());

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐