基于STM32H5的全场景工业互联设备管理系统

一、项目概述

本项目是一个基于 STM32H563RIVx 微控制器的工业物联网网关,它不仅是一个简单的嵌入式固件,更是一套完整的工业数据采集-转发-升级一体化框架,涵盖以下核心能力:

  • Modbus RTU 网关:通过USB与PC相连,同时通过多路UART/RS485下接各类传感器
  • 远程固件升级(IAP):支持主控自身升级,也支持通过主控向下级传感器转发固件
  • 可视化人机交互:通过SPI LCD实时显示设备状态
  • 实时操作系统:基于FreeRTOS的多任务调度
项目信息详情
主控芯片STM32H563RIVx (Cortex-M33)
开发框架STM32CubeMX + HAL库 + FreeRTOS
通信协议Modbus RTU (自定义扩展)
升级方式IAP (In-Application Programming)
上位机通信USB虚拟串口 (CDC)

二、系统总体架构

┌─────────────────────────────────────────────────────────┐
│                      PC 上位机                            │
│                  (Modbus RTU Client)                     │
└───────────────────────┬─────────────────────────────────┘
                        │ USB (虚拟串口)
┌───────────────────────▼─────────────────────────────────┐
│              LibmodbusServerTask                          │
│           (Modbus RTU Server, 核心网关任务)                 │
├──────────────────────────────────────────────────────────┤
│              ┌──────────────┐                             │
│              │   点映射表    │  ← 上位机通过"文件记录"下发     │
│              │ (Point Map)  │                              │
│              └──────┬───────┘                             │
│     ┌───────────────┼───────────────┐                     │
│     ▼               ▼               ▼                     │
│  CH0_Task       CH1_Task        CH2_Task                  │
│  (本地GPIO)  (UART2/RS485通道) (UART4/RS485通道)           │
│     │               │               │                     │
│     ▼               ▼               ▼                     │
│ 本地传感器      ┌──────────┐   ┌──────────┐               │
│  (GPIO)         │ 开关设备  │   │温湿度传感器│              │
│                 │ 环境传感器│   │ (Modbus)  │              │
│                 │ (Modbus) │   └──────────┘               │
│                 └──────────┘                              │
└──────────────────────────────────────────────────────────┘

核心设计理念:点映射表 (Point Map)

整个框架的灵魂是一张 点映射表(Point Map)。它将"上位机看到的Modbus寄存器地址"与"物理设备的寄存器地址"解耦,是理解整个系统架构的关键。


点映射表深度解析

下面我们从数据结构定义、内存布局、动态下发机制、双向同步流程、以及一个完整案例来层层剖析。

1. 数据结构定义

typedef struct PointMap {
    char reg_type[4];          // 寄存器类型: "0x"(线圈), "1x"(离散输入), "4x"(保持寄存器), "3x"(输入寄存器)
    uint16_t reg_addr_master;  // 主控(上位机可见)的寄存器地址
    uint16_t channel;          // 通道号: 0-主控本身, 1-CH1, 2-CH2
    uint16_t dev_addr;         // 下级传感器设备地址(Modbus从机地址)
    uint16_t reg_addr_salve;   // 下级传感器的寄存器地址
} PointMap;

每个字段的作用

字段大小取值含义
reg_type[4]4字节"0x" / "1x" / "4x" / "3x"Modbus寄存器区类型,对应线圈/离散输入/保持寄存器/输入寄存器
reg_addr_master2字节0~65535上位机看到的寄存器地址,即Linux/Windows上位机直接读写的地址
channel2字节0 / 1 / 2物理通道号:0=主控本地(GPIO),1=CH1(UART2),2=CH2(UART4)
dev_addr2字节1~247下级传感器的Modbus从机地址
reg_addr_salve2字节0~65535传感器本地的寄存器地址,即最终发往从机设备的地址

整个结构体占 14字节,一张表最多500个点,共占用约7KB内存。

2. 存储模型:三段数据结构

control.c 中定义了三个全局变量来维护点映射系统:

static PointMap  g_tPointMaps[MAX_POINT_COUNT];   /* ① 映射表本体:500条记录 */
static uint16_t  g_iPointVals[MAX_POINT_COUNT];    /* ② 每个点的"缓存值":用于检测变化 */
static int       g_iPointMapCnt;                    /* ③ 表中有效记录条数 */

为什么需要 g_iPointVals

g_iPointVals[i] 记录了第 i 个点上一次操作成功的数值。在 loop_once() 中,每次循环会对比"当前寄存器值"与"缓存值":

  • 如果不相等 → 说明上位机修改了该寄存器 → 触发写操作向下写入设备
  • 如果相等 → 跳过写(避免不必要的总线通信)

这是实现"变化即同步"的关键,避免了对总线的轮询风暴。

3. 映射表动态下发机制

映射表由上位机通过 Modbus Write File Record 功能码下发,分为文件头和数据包两步:

步骤1:上位机发送文件头(record_no == 0)
Modbus PDU 格式:
┌──────┬──────────┬──────────┬──────────┬──────────────────┐
│ FC   │ RefType  │ FileNo   │ RecordNo │    Data (文件头)    │
│ 0x15 │  0x06    │ 0x0000   │  0x0000  │  ┌──────────┐   │
└──────┴──────────┴──────────┴──────────┘  │FileInfo  │   │
                                           │ 包含      │   │
                                           │file_len等 │   │
                                           └──────────┘   │

parse_map_info() 收到 record_no == 0 时,执行:

  1. 解析文件信息(文件总长度)
  2. 调用 clear_point_map() 清空旧映射表
  3. 将接收计数器 recv_len 归零
步骤2:上位机发送数据包(record_no >= 1)
Modbus PDU 格式:
┌──────┬──────────┬──────────┬──────────┬──────┬──────────────────┐
│ FC   │ RefType  │ FileNo   │ RecordNo │ Len  │  PointMap数据块   │
│ 0x15 │  0x06    │ 0x0000   │  0x0001  │  n   │ 多个PointMap结构体 │
└──────┴──────────┴──────────┴──────────┴──────┴──────────────────┘
                      ▲                          ▲
                  file_no=0表示映射表     直接用memcpy拷贝到g_tPointMaps

关键实现细节——parse_map_info() 中为何使用 memcpy 而不是结构体指针强转?

// 实际代码(正确做法):
memcpy(g_pucPointMapCurPos, &msg[10], cur_len);
g_pucPointMapCurPos += cur_len;
g_iPointMapCnt = (uint32_t)(g_pucPointMapCurPos - (uint8_t *)g_tPointMaps) / sizeof(PointMap);

// 被#if 0注释掉的错误做法:
ptPointMap = (PPointMap)&msg[10];  // ❌ 危险!msg[10]可能未对齐
add_point_map(ptPointMap);          // 结构体成员访问会触发异常

因为 msg 是Modbus接收缓冲区(字节数组),其下标10很可能不是 2字节对齐 的地址,而 PointMapreg_type[4] 需要对齐访问。如果直接用指针强转,ARM Cortex-M33 在访问未对齐地址时会产生 HardFault 异常。所以代码最终采用的方案是 memcpy 逐字节拷贝,安全可靠。

4. 寄存器类型字符串匹配机制

映射表中的 reg_type 字段是一个字符串(“0x”/“1x”/“4x”/“3x”),为什么这样设计?

因为Modbus标准定义了四类寄存器区,每种有不同的功能码和访问方式:

字符串意义读功能码写功能码对应寄存器数组
"0x"线圈 (Coil)0x01 Read Coils0x05 Write Single Coil / 0x0F Write Multiple Coilsmb_mapping->tab_bits[]
"1x"离散输入 (Discrete Input)0x02 Read Discrete Inputs只读mb_mapping->tab_input_bits[]
"4x"保持寄存器 (Holding Register)0x03 Read Holding Registers0x06 Write Single Register / 0x10 Write Multiple Registersmb_mapping->tab_registers[]
"3x"输入寄存器 (Input Register)0x04 Read Input Registers只读mb_mapping->tab_input_registers[]

框架通过 strcmp() 在运行时分发到不同的操作函数:

static void update_modbus_mapping_reg(PPointMap ptPointMap, modbus_mapping_t *mb_mapping, int val)
{
    if (!strcmp(ptPointMap->reg_type, "0x"))
        mb_mapping->tab_bits[ptPointMap->reg_addr_master] = val;
    if (!strcmp(ptPointMap->reg_type, "1x"))
        mb_mapping->tab_input_bits[ptPointMap->reg_addr_master] = val;
    if (!strcmp(ptPointMap->reg_type, "4x"))
        mb_mapping->tab_registers[ptPointMap->reg_addr_master] = val;
    if (!strcmp(ptPointMap->reg_type, "3x"))
        mb_mapping->tab_input_registers[ptPointMap->reg_addr_master] = val;
}

5. 双向同步的核心流程:loop_once()

每个通道任务(CH0/CH1/CH2)周期性调用 loop_once(ctx, channel, mb_mapping),完成一次完整的双向同步

loop_once(ctx, channel, mb_mapping)
│
├──── 阶段一:上位机→设备 (写同步)
│    │
│    for each point in g_tPointMaps:
│      if point.channel == my_channel:
│        │
│        ├─ 跳过 CMD_STATUS 寄存器(特殊处理)
│        │
│        ├─ channel != 0 (外接传感器)
│        │    val = mb_mapping[point.reg_addr_master]  ← 读上位机下发的值
│        │    if val != g_iPointVals[i]:                ← 值有变化?
│        │        modbus_write_point(ctx, point, val)   → 通过Modbus写入传感器
│        │        if 成功:
│        │            g_iPointVals[i] = val             ← 更新缓存
│        │
│        └─ channel == 0 (本地GPIO)
│             val = mb_mapping[point.reg_addr_master]
│             if val != g_iPointVals[i]:
│                 local_write_point(point, val)          → 直接操作GPIO
│                 if 成功:
│                     g_iPointVals[i] = val
│
├──── 阶段二:设备→上位机 (读同步)
│
│    for each point in g_tPointMaps:
│      if point.channel == my_channel:
│        │
│        ├─ 跳过 CMD_STATUS 寄存器
│        │
│        ├─ channel != 0 (外接传感器)
│        │    modbus_read_point(ctx, point, &val)        → 从传感器读取
│        │    if 成功:
│        │        mb_mapping[point.reg_addr_master] = val ← 更新寄存器
│        │                                                下次PC轮询时即可读到
│        │
│        └─ channel == 0 (本地GPIO)
│             local_read_point(point, &val)               → 直接读GPIO
│             if 成功:
│                 mb_mapping[point.reg_addr_master] = val
│
└──── 完成,任务休眠100ms后进入下一周期

6. 读/写物理设备的具体实现

Modbus写操作 modbus_write_point()
static int modbus_write_point(modbus_t *ctx, PPointMap ptPointMap, int val)
{
    // 1. 获取通道互斥锁(防止多任务争用UART)
    xSemaphoreTake(ptChannelInfo->xMutex, portMAX_DELAY);

    // 2. 设置Modbus从机地址
    modbus_set_slave(ctx, ptPointMap->dev_addr);

    // 3. 根据reg_type选择写方式
    if (!strcmp(ptPointMap->reg_type, "0x"))
        rc = modbus_write_bit(ctx, ptPointMap->reg_addr_salve, val);
    else if (!strcmp(ptPointMap->reg_type, "4x"))
        rc = modbus_write_register(ctx, ptPointMap->reg_addr_salve, val);

    // 4. 释放互斥锁
    xSemaphoreGive(ptChannelInfo->xMutex);
}
Modbus读操作 modbus_read_point()

与写操作对称,根据 reg_type 选择 modbus_read_bits() / modbus_read_registers() / modbus_read_input_bits() / modbus_read_input_registers()

本地GPIO操作

channel == 0 时,不经过Modbus协议,直接调用HAL库操作GPIO:

static int local_read_point(PPointMap ptPointMap, int *pVal)
{
    if (ptPointMap->reg_addr_salve == 0 && !strcmp(ptPointMap->reg_type, "0x"))
    {
        if (GPIO_PIN_RESET == HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_12))
            *pVal = 1;
        else
            *pVal = 0;
        return 0;
    }
    return -1;
}

这里的关键reg_addr_salve 对本地GPIO来说不再代表Modbus寄存器地址,而是解释为"GPIO端口引脚编号"。代码中 reg_addr_salve == 0 对应 GPIOC_PIN_12。这展示了映射表的灵活性——同一张表可以承载不同物理总线的访问。

7. 完整映射示例

假设我们有这样一个物理拓扑:

PC上位机
    │
    └── USB ── 主控 (STM32H5)
                  │
                  ├── CH0: GPIOC Pin12 (本地LED/按键)
                  │
                  ├── CH1: UART2 ── RS485总线
                  │                ├── 从机地址1: 开关模块 (4路按键+4路LED)
                  │                └── 从机地址2: 环境传感器 (光照+距离)
                  │
                  └── CH2: UART4 ── RS485总线
                                   └── 从机地址3: 温湿度传感器

上位机下发的映射表可能是这样的:

reg_typereg_addr_masterchanneldev_addrreg_addr_salve含义
"0x"0000本地GPIOC Pin12输出(上位机写DO[0]控制本地LED)
"0x"100110开关模块的线圈0→上位机映射到DO[100]
"1x"0110开关模块的按键1→上位机映射到DI[0]
"1x"1111开关模块的按键2→上位机映射到DI[1]
"1x"2112开关模块的按键3→上位机映射到DI[2]
"3x"0120环境传感器光强→上位机映射到AI[0]
"3x"1121环境传感器距离→上位机映射到AI[1]
"3x"2230温湿度传感器温度→上位机映射到AI[2]
"3x"3231温湿度传感器湿度→上位机映射到AI[3]
"4x"0000CMD_STATUS寄存器(升级命令,特殊处理)

工作流程示例

  1. 读温度:PC发Modbus读AI[2] → 主控 modbus_reply 返回 mb_mapping->tab_input_registers[2](该值由CH2_Task在loop_once中从从机地址3的寄存器0周期性读取并更新)

  2. 控制开关灯:PC发Modbus写DO[100] = 1 → 主控更新 mb_mapping->tab_bits[100] = 1 → CH1_Task在下一轮loop_once中检测到变化 → modbus_write_bit(ctx, 从机1, 线圈0, 1) → 开关模块执行亮灯

  3. 本地控制:PC发Modbus写DO[0] = 0 → 主控更新 mb_mapping->tab_bits[0] = 0 → CH0_Task检测到变化 → HAL_GPIO_WritePin(GPIOC, GPIO_PIN_12, GPIO_PIN_SET)(LED关)

8. 特殊寄存器:CMD_STATUS

映射表中的 MODBUS_UPDATE_REG_ADDR(即reg_addr_salve == 0reg_type == "4x")是一个特殊命令寄存器,在 loop_once() 中被跳过不做常规同步,而是由 process_emergency_cmd() 单独处理:

// loop_once 中跳过:
if (g_tPointMaps[i].reg_addr_salve == MODBUS_UPDATE_REG_ADDR && !strcmp(g_tPointMaps[i].reg_type, "4x"))
    continue;

当上位机向这个寄存器写入 0x550xAA 时,process_emergency_cmd()立即响应并执行复位操作,不需要等待loop_once周期。这是紧急命令的"快车道"机制。

9. 设计思想总结

点映射表的设计可以归纳为 "三明治"架构

┌────────────────────────────────────────┐
│          上层:上位机视图                │
│  连续的、统一的 4类寄存器空间 (0~499)     │
│  上位机无需知道任何物理拓扑细节            │
├────────────────────────────────────────┤
│          中间层:点映射表                 │
│  将 "主控地址" 翻译为 "通道+设备+从机地址"  │
│  CH0_Task / CH1_Task / CH2_Task 各取所需 │
├────────────────────────────────────────┤
│          下层:物理设备视图               │
│  各种Modbus从机、GPIO设备,分散在不同       │
│  通道、不同总线、不同地址上                │
└────────────────────────────────────────┘

这种设计使得:

  1. 上位机无需知道物理拓扑——它只读写一组连续的、统一的寄存器空间
  2. 主控负责路由——根据映射表自动将请求分发到对应通道的对应设备
  3. 配置灵活——映射表运行时可动态下发,增加/删除传感器无需重新编译固件
  4. 通道隔离——每个通道有自己的Modbus上下文和互斥锁,互不干扰
  5. 变化驱动——只同步有变化的值,减少不必要总线通信

三、任务架构详解

3.1 FreeRTOS 任务划分

系统共运行 4个核心RTOS任务

任务名称功能通信方式
LibmodbusServerTaskModbus Server,与PC通信,处理请求USB虚拟串口
CH0_Task读写本地GPIO传感器直接HAL库调用
CH1_Task读写CH1通道上所有Modbus从机UART2 (RS485)
CH2_Task读写CH2通道上所有Modbus从机UART4 (RS485)

3.2 主任务:LibmodbusServerTask

这是系统的核心网关任务,工作流程如下:

1. 初始化Modbus RTU (USB, 115200, N/8/1)
2. 创建Modbus映射寄存器区 (mb_mapping)
   - 500个线圈 (0x)
   - 500个离散输入 (1x)
   - 500个保持寄存器 (4x)
   - 500个输入寄存器 (3x)
3. 为CH1/CH2创建互斥锁 (Mutex)
4. 如果是APP模式 → 创建CH0/CH1/CH2任务
5. 进入主循环:
   ┌─────────────────────────────────────┐
   │  modbus_receive() ← 等待PC请求       │
   ├─────────────────────────────────────┤
   │  process_emergency_cmd()            │
   │   → 处理紧急命令(如"复位/升级")       │
   ├─────────────────────────────────────┤
   │  process_file_record()              │
   │   → 处理文件记录(映射表/固件数据)     │
   ├─────────────────────────────────────┤
   │  modbus_reply() → 回复PC             │
   └─────────────────────────────────────┘

3.3 通道任务:CH0_Task & CHn_Task

每个通道任务周期性执行 loop_once(),完成两个阶段:

阶段一:写操作 —— 检查映射表中本通道的点,如果主控寄存器值发生变化,则写入物理设备:

for each point in map:
    if point.channel == my_channel:
        new_val = mb_mapping[point.reg_addr_master]
        if new_val != old_val:
            write_to_device(point, new_val)  // Modbus写 or GPIO写
            update_mapping(new_val)

阶段二:读操作 —— 读取物理设备的值,更新到主控寄存器:

for each point in map:
    if point.channel == my_channel:
        val = read_from_device(point)  // Modbus读 or GPIO读
        mb_mapping[point.reg_addr_master] = val  // 更新,PC下次可读到

四、双区启动与IAP升级框架

4.1 Flash布局

0x0800 0000 ┌────────────────────┐
            │   Bootloader区域    │  256KB
0x0804 0000 ├────────────────────┤
            │   Application区域   │   ~1.7MB
0x081F E000 ├────────────────────┤
            │   固件信息配置区      │  8KB (存放FirmwareInfo)
0x0820 0000 └────────────────────┘

4.2 启动流程

上电/复位
    │
    ▼
Bootloader 启动
    │
    ├── isBootloader()? ──┤
    │                     │
    │ 链接地址 < 0x08040000│
    │                     │
    ▼                     ▼
  是Bootloader          是APP
    │                     │
    ▼                     ▼
 isNeedToUpdate()       start_app()
    │                   跳转到APP地址
    ├── 无固件信息 → 需要升级
    ├── bEnterBootloader=1 → 需要升级
    └── bEnterBootloader=0 → 启动APP

4.3 三级升级机制

整个系统的升级能力分三个层次:

层次描述实现方式
Level 1上位机→主控 升级主控自身固件burn_firmware() 直接写入Flash
Level 2上位机→主控→传感器 转发固件send_firmware_to_device() 通过Modbus文件记录转发
Level 3主控远程复位控制MODBUS_PRIVATE_CMD_ENTER_BOOT/APP 私有命令

升级触发方式:上位机向 MODBUS_UPDATE_REG_ADDR(寄存器0) 写入特定值:

  • 0x55 → 进入Bootloader模式(准备升级)
  • 0xAA → 启动Application

4.4 固件传输协议

采用 Modbus Write File Record 功能码(0x15)进行固件和映射表传输。


file_no 在协议中的作用深度解析

file_no 是Modbus Write File Record请求中一个16位的文件编号字段,位于Modbus PDU的第4~5字节。在这个项目中,它远超出一个简单文件编号的范畴——它是路由信息的多路复用编码,是整个分布式升级系统的寻址核心。

1. Modbus Write File Record 标准PDU格式

Modbus PDU (Write File Record):
┌──────┬──────────┬──────────┬──────────┬──────┬──────────────────────┐
│ FC   │ RefType  │ FileNo   │ RecordNo │  Len  │       Data           │
│ 0x15 │  0x06    │ 2字节     │  2字节    │ 2字节 │    N字节             │
└──────┴──────────┴──────────┴──────────┴──────┴──────────────────────┘
  ▲         ▲         ▲          ▲         ▲
  │         │         │          │         └─ 数据长度(字节数)
  │         │         │          └─────────── 记录号(分包序号)
  │         │         └────────────────────── 文件编号 ← 核心!
  │         └──────────────────────────────── 引用类型(固定0x06)
  └────────────────────────────────────────── 功能码(0x15)

2. file_no 的位域编码设计

项目将16位的 file_no 按位拆分为三个独立信息

file_no (16位) = 例如 0x2103

bit 15  14  13  12 | 11  10  9  8  | 7  6  5  4  3  2  1  0
├───────────────────┼───────────────┼──────────────────────────┤
│     channel       │   file_type   │        dev_addr          │
│    (4位 通道号)    │  (4位 文件类型) │      (8位 设备地址)        │
├───────────────────┼───────────────┼──────────────────────────┤
│     0x2           │     0x1       │         0x03             │
│    → CH2 通道     │   → 固件数据   │    → 从机地址 3           │
└───────────────────┴───────────────┴──────────────────────────┘

解码代码

file_no = ((uint16_t)msg[4]<<8) | msg[5];  /* 从PDU中提取原始值, 如0x2103 */

channel  = (file_no >> 12) & 0xf;   /* bit[15:12] → 通道号 */
file_no  = (file_no >> 8) & 0xf;    /* bit[11:8]  → 文件类型 (注意: 原变量被复用) */
dev_addr = (file_no) & 0xff;        /* bit[7:0]   → 设备地址 */

注意:代码中 file_no 变量被复用了——先提取原始值,解析出 channel,然后右移8位覆盖自身变成"文件类型"。这在C语言中是常见的节省变量的写法,但阅读时容易混淆。

3. 每个字段的语义

① channel (bit[15:12]) —— 路由目标通道
含义处理者
0x0主控自身LibmodbusServerTask 中的 process_file_record() 直接处理
0x1CH1 (UART2/RS485) 上的传感器转发给 send_firmware_to_device()
0x2CH2 (UART4/RS485) 上的传感器转发给 send_firmware_to_device()
0x3~0xF保留当前未使用

核心逻辑channel == 0 表示目标是自己(主控),channel != 0 表示要转发给下级传感器。

② file_type (bit[11:8]) —— 文件内容类型
宏定义含义处理函数
0x0FILE_NUMBER_POINT_MAP点映射表parse_map_info()
0x1FILE_NUMBER_FIRMWARE固件数据burn_firmware()send_firmware_to_device()
③ dev_addr (bit[7:0]) —— 目标传感器地址

channel != 0 时,这个字段指定Modbus从机地址(1~247),send_firmware_to_device() 会调用 modbus_set_slave(ctx, dev_addr) 来选定目标传感器。

4. file_no 的分流路由逻辑

process_file_record() 中,file_no 的解码结果决定了三种完全不同的处理路径:

static int process_file_record(uint8_t *msg, uint16_t msg_len)
{
    // 1. 从PDU提取并解码 file_no
    file_no = ((uint16_t)msg[4]<<8) | msg[5];
    channel  = (file_no >> 12) & 0xf;   // 提取通道
    dev_addr = (file_no) & 0xff;        // 提取设备地址
    file_no  = (file_no >> 8) & 0xf;    // 提取文件类型 (覆盖原变量)

    // 2. 根据 channel + file_type 分流
    if (channel == 0 && file_no == FILE_NUMBER_POINT_MAP)    // 0x00xx
        parse_map_info(msg, msg_len);
        // → 主控本地:解析点映射表,写入 g_tPointMaps

    if (channel == 0 && file_no == FILE_NUMBER_FIRMWARE)     // 0x01xx
        burn_firmware(msg, msg_len);
        // → 主控本地:烧写固件到Flash

    if (channel != 0 && file_no == FILE_NUMBER_FIRMWARE)     // 0x11xx ~ 0xF1xx
        send_firmware_to_device(msg, msg_len);
        // → 转发给传感器:通过Modbus Write File Record向下转发
}

5. 完整传输流程示例

以"上位机给CH2通道上地址为3的温湿度传感器升级固件"为例:

Step 1: 上位机 → 主控 (通过USB)
┌────────┬──────────┬──────────┬──────────┬──────┬──────────────────────┐
│  0x15  │  0x06    │  0x21 03 │  0x00 00 │  Len │    FileInfo (文件头)    │
└────────┴──────────┴──────────┴──────────┴──────┴──────────────────────┘
                      ▲
                  0x2103 = 通道2 + 文件类型1(固件) + 地址3
                  
主控解析 record_no == 0 → 文件头
  ├── channel=2 → 不是本地,标记 SetUpdateStatus(1) 暂停数据采集
  ├── 调用 send_firmware_to_device()
  │     ├── modbus_set_slave(ctx, 3)      // 选中温湿度传感器
  │     ├── modbus_write_file_record(ctx, 1, 0, &msg[10], len)  // 原样转发
  │     │    注意: 这里传给传感器的 file_no = 1 (只有文件类型,不包含路由)
  │     └── 传感器收到后准备接收固件
  └── 映射表/file_no无关

Step 2~N: 上位机 → 主控 → 传感器 (固件数据分包发送)
┌────────┬──────────┬──────────┬──────────┬──────┬──────────────────────┐
│  0x15  │  0x06    │  0x21 03 │  0x00 01 │  Len │    固件数据块 #1       │
├────────┼──────────┼──────────┼──────────┼──────┼──────────────────────┤
│  0x15  │  0x06    │  0x21 03 │  0x00 02 │  Len │    固件数据块 #2       │
├────────┼──────────┼──────────┼──────────┼──────┼──────────────────────┤
│  ...   │  ...     │  ...     │  ...     │  ... │   ...                │
└────────┴──────────┴──────────┴──────────┴──────┴──────────────────────┘

主控每次收到后,原封不动通过 UART4/RS485 转发给从机地址3

6. 主控自身升级时 file_no = 0x00xx 的特殊情况

channel == 0 时,dev_addr 字段没有意义(因为目标就是主控自己),此时有两种子类型:

file_no原始值channelfile_typedev_addr含义
0x000000 (映射表)0点映射表下发parse_map_info()
0x010001 (固件)0主控自身固件升级burn_firmware()

注意:burn_firmware() 中虽然也解析了 dev_addr,但仅在 process_file_record()channel == 0 时被调用,所以 dev_addr 实际上被忽略。

7. 为什么file_no要这样设计?(设计意图总结)

需求问题file_no的解决方案
多目标一个Modbus Server后面挂了多个设备,怎么指定给谁?dev_addr 字段指定从机地址
多通道主控有多个UART通道,数据该走哪条路?channel 字段路由到对应通道任务
多内容上位机可能下发映射表,也可能下发固件,怎么区分?file_type 字段区分数据类型
零额外开销不想引入私有协议头,保持Modbus标准兼容复用了标准Write File Record中的 file_no 字段,通过位域编码

一句话总结file_no 通过位域编码将路由信息(通道号+设备地址)和内容信息(文件类型)打包进一个16位标准字段,使得一条Modbus Write File Record请求既能描述"发给谁",又能描述"发的是什么",同时保持了与标准Modbus协议的完全兼容,无需自定义协议头。


五、硬件抽象层设计

5.1 UART设备模型

采用面向对象风格的设备抽象:

typedef struct UART_Device {
    char *name;
    int (*Init)(...);
    int (*Send)(...);
    int (*RecvByte)(...);
    int (*Flush)(...);
    void *priv_data;  // 私有数据
} UART_Device;

通过 GetUARTDevice("usb") / GetUARTDevice("uart4") 获取设备实例,统一接口操作不同物理UART。

六、框架亮点总结

✅ 优势与特色

  1. 点映射机制 —— 实现了"逻辑寄存器 ↔ 物理设备"的解耦,是工业网关的核心设计模式
  2. 动态配置 —— 映射表和固件均通过Modbus协议在线下发,无需重新烧录
  3. 多级升级 —— 不仅自己能升级,还能作为"升级代理"为下级传感器升级
  4. 通道隔离 —— 每个通道独立任务+独立互斥锁,互不干扰
  5. 紧急命令优先 —— process_emergency_cmd() 先于普通请求处理,确保复位/升级等高优先级操作及时响应
  6. 状态保护 —— 升级过程中SetUpdateStatus(1)暂停通道数据采集,防止干扰

🔧 可改进的方向

  • 增强安全性:固件传输可加入CRC校验(目前留了crc32字段但未使用)
  • 错误恢复机制:升级中断后的断点续传
  • Web配置界面:当前通过上位机软件配置,未来可扩展为Web页面

七、适用场景

这套框架非常适合以下应用场景:

场景说明
工业数据采集网关采集多路传感器数据,汇总上报
远程设备监控支持远程读写传感器寄存器
分布式固件管理中心节点统一管理所有子设备升级
边缘计算节点本地处理数据,减轻云端压力

八、总结

本项目是一个工业级、生产可用的嵌入式物联网网关框架。它的核心设计——点映射表 + 多通道Modbus路由 + 多级IAP升级——是工业嵌入式开发的经典模式。代码结构清晰,基于STM32CubeMX生态,既适合学习嵌入式工业通信,也适合作为实际产品的基础框架。

更多推荐