基于STM32H5的全场景工业互联设备管理系统
基于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_master | 2字节 | 0~65535 | 上位机看到的寄存器地址,即Linux/Windows上位机直接读写的地址 |
channel | 2字节 | 0 / 1 / 2 | 物理通道号:0=主控本地(GPIO),1=CH1(UART2),2=CH2(UART4) |
dev_addr | 2字节 | 1~247 | 下级传感器的Modbus从机地址 |
reg_addr_salve | 2字节 | 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 时,执行:
- 解析文件信息(文件总长度)
- 调用
clear_point_map()清空旧映射表 - 将接收计数器
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字节对齐 的地址,而 PointMap 中 reg_type[4] 需要对齐访问。如果直接用指针强转,ARM Cortex-M33 在访问未对齐地址时会产生 HardFault 异常。所以代码最终采用的方案是 memcpy 逐字节拷贝,安全可靠。
4. 寄存器类型字符串匹配机制
映射表中的 reg_type 字段是一个字符串(“0x”/“1x”/“4x”/“3x”),为什么这样设计?
因为Modbus标准定义了四类寄存器区,每种有不同的功能码和访问方式:
| 字符串 | 意义 | 读功能码 | 写功能码 | 对应寄存器数组 |
|---|---|---|---|---|
"0x" | 线圈 (Coil) | 0x01 Read Coils | 0x05 Write Single Coil / 0x0F Write Multiple Coils | mb_mapping->tab_bits[] |
"1x" | 离散输入 (Discrete Input) | 0x02 Read Discrete Inputs | 只读 | mb_mapping->tab_input_bits[] |
"4x" | 保持寄存器 (Holding Register) | 0x03 Read Holding Registers | 0x06 Write Single Register / 0x10 Write Multiple Registers | mb_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_type | reg_addr_master | channel | dev_addr | reg_addr_salve | 含义 |
|---|---|---|---|---|---|
"0x" | 0 | 0 | 0 | 0 | 本地GPIOC Pin12输出(上位机写DO[0]控制本地LED) |
"0x" | 100 | 1 | 1 | 0 | 开关模块的线圈0→上位机映射到DO[100] |
"1x" | 0 | 1 | 1 | 0 | 开关模块的按键1→上位机映射到DI[0] |
"1x" | 1 | 1 | 1 | 1 | 开关模块的按键2→上位机映射到DI[1] |
"1x" | 2 | 1 | 1 | 2 | 开关模块的按键3→上位机映射到DI[2] |
"3x" | 0 | 1 | 2 | 0 | 环境传感器光强→上位机映射到AI[0] |
"3x" | 1 | 1 | 2 | 1 | 环境传感器距离→上位机映射到AI[1] |
"3x" | 2 | 2 | 3 | 0 | 温湿度传感器温度→上位机映射到AI[2] |
"3x" | 3 | 2 | 3 | 1 | 温湿度传感器湿度→上位机映射到AI[3] |
"4x" | 0 | 0 | 0 | 0 | CMD_STATUS寄存器(升级命令,特殊处理) |
工作流程示例:
-
读温度:PC发Modbus读AI[2] → 主控
modbus_reply返回mb_mapping->tab_input_registers[2](该值由CH2_Task在loop_once中从从机地址3的寄存器0周期性读取并更新) -
控制开关灯:PC发Modbus写DO[100] = 1 → 主控更新
mb_mapping->tab_bits[100]= 1 → CH1_Task在下一轮loop_once中检测到变化 →modbus_write_bit(ctx, 从机1, 线圈0, 1)→ 开关模块执行亮灯 -
本地控制: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 == 0且reg_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;
当上位机向这个寄存器写入 0x55 或 0xAA 时,process_emergency_cmd() 会立即响应并执行复位操作,不需要等待loop_once周期。这是紧急命令的"快车道"机制。
9. 设计思想总结
点映射表的设计可以归纳为 "三明治"架构:
┌────────────────────────────────────────┐
│ 上层:上位机视图 │
│ 连续的、统一的 4类寄存器空间 (0~499) │
│ 上位机无需知道任何物理拓扑细节 │
├────────────────────────────────────────┤
│ 中间层:点映射表 │
│ 将 "主控地址" 翻译为 "通道+设备+从机地址" │
│ CH0_Task / CH1_Task / CH2_Task 各取所需 │
├────────────────────────────────────────┤
│ 下层:物理设备视图 │
│ 各种Modbus从机、GPIO设备,分散在不同 │
│ 通道、不同总线、不同地址上 │
└────────────────────────────────────────┘
这种设计使得:
- 上位机无需知道物理拓扑——它只读写一组连续的、统一的寄存器空间
- 主控负责路由——根据映射表自动将请求分发到对应通道的对应设备
- 配置灵活——映射表运行时可动态下发,增加/删除传感器无需重新编译固件
- 通道隔离——每个通道有自己的Modbus上下文和互斥锁,互不干扰
- 变化驱动——只同步有变化的值,减少不必要总线通信
三、任务架构详解
3.1 FreeRTOS 任务划分
系统共运行 4个核心RTOS任务:
| 任务名称 | 功能 | 通信方式 |
|---|---|---|
LibmodbusServerTask | Modbus 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() 直接处理 |
0x1 | CH1 (UART2/RS485) 上的传感器 | 转发给 send_firmware_to_device() |
0x2 | CH2 (UART4/RS485) 上的传感器 | 转发给 send_firmware_to_device() |
0x3~0xF | 保留 | 当前未使用 |
核心逻辑:channel == 0 表示目标是自己(主控),channel != 0 表示要转发给下级传感器。
② file_type (bit[11:8]) —— 文件内容类型
| 值 | 宏定义 | 含义 | 处理函数 |
|---|---|---|---|
0x0 | FILE_NUMBER_POINT_MAP | 点映射表 | parse_map_info() |
0x1 | FILE_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原始值 | channel | file_type | dev_addr | 含义 |
|---|---|---|---|---|
0x0000 | 0 | 0 (映射表) | 0 | 点映射表下发 → parse_map_info() |
0x0100 | 0 | 1 (固件) | 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。
六、框架亮点总结
✅ 优势与特色
- 点映射机制 —— 实现了"逻辑寄存器 ↔ 物理设备"的解耦,是工业网关的核心设计模式
- 动态配置 —— 映射表和固件均通过Modbus协议在线下发,无需重新烧录
- 多级升级 —— 不仅自己能升级,还能作为"升级代理"为下级传感器升级
- 通道隔离 —— 每个通道独立任务+独立互斥锁,互不干扰
- 紧急命令优先 ——
process_emergency_cmd()先于普通请求处理,确保复位/升级等高优先级操作及时响应 - 状态保护 —— 升级过程中
SetUpdateStatus(1)暂停通道数据采集,防止干扰
🔧 可改进的方向
- 增强安全性:固件传输可加入CRC校验(目前留了crc32字段但未使用)
- 错误恢复机制:升级中断后的断点续传
- Web配置界面:当前通过上位机软件配置,未来可扩展为Web页面
七、适用场景
这套框架非常适合以下应用场景:
| 场景 | 说明 |
|---|---|
| 工业数据采集网关 | 采集多路传感器数据,汇总上报 |
| 远程设备监控 | 支持远程读写传感器寄存器 |
| 分布式固件管理 | 中心节点统一管理所有子设备升级 |
| 边缘计算节点 | 本地处理数据,减轻云端压力 |
八、总结
本项目是一个工业级、生产可用的嵌入式物联网网关框架。它的核心设计——点映射表 + 多通道Modbus路由 + 多级IAP升级——是工业嵌入式开发的经典模式。代码结构清晰,基于STM32CubeMX生态,既适合学习嵌入式工业通信,也适合作为实际产品的基础框架。
更多推荐
所有评论(0)