3、数据模型与地址映射:线圈、离散输入、输入寄存器、保持寄存器
好,咱们接着聊。上一章我们把Modbus的报文格式和功能码理清楚了,这一章要解决一个更实际的问题:数据到底放在哪儿?怎么找到它?
说白了,Modbus协议定义了一套“虚拟的存储空间”。它不管你单片机里用的是哪颗芯片、RAM有多大,它只管四个固定的“抽屉”。你只要把数据放进对应的抽屉,上位机就能通过地址来拿。
这四个抽屉,就是Modbus的四种数据模型。我当年刚接触时,也被这些名字绕晕过。什么线圈、离散输入、输入寄存器、保持寄存器……听着像电工手册,其实没那么复杂。
3.1 四种数据模型,一次讲透
咱们一个一个来。我习惯用“读写权限”和“数据类型”这两个维度去理解它们。
| 模型名称 | 数据类型 | 读写权限 | 通俗理解 |
|---|---|---|---|
| 线圈 (Coil) | 1位 (0或1) | 可读可写 | 开关、继电器、LED |
| 离散输入 (Discrete Input) | 1位 (0或1) | 只读 | 按钮、限位开关、传感器状态 |
| 输入寄存器 (Input Register) | 16位 (0~65535) | 只读 | 电压、电流、温度采样值 |
| 保持寄存器 (Holding Register) | 16位 (0~65535) | 可读可写 | 设定值、校准参数、配置字 |
你看,其实就两种数据类型:位和字。每种又分“只读”和“可读写”。
核心记忆点:
- “线圈”和“保持寄存器”是可写的——上位机可以控制它们。
- “离散输入”和“输入寄存器”是只读的——设备只负责上报。
3.2 地址映射的“潜规则”
嗯,这里要注意。Modbus协议规定了每种模型的地址范围,但并没有规定地址0x0000对应你板子上的哪个变量。这个映射关系,完全由你——嵌入式工程师——来决定。
我见过不少新手,上来就把保持寄存器地址从0x0000开始随便排,结果写到后面发现地址冲突了。我个人习惯是:先画一张地址分配表,再写代码。
标准的Modbus地址分配是这样的:
| 模型 | PLC地址范围 | 协议地址范围 | 功能码 |
|---|---|---|---|
| 线圈 | 00001 ~ 09999 | 0x0000 ~ 0x270E | 01, 05, 15 |
| 离散输入 | 10001 ~ 19999 | 0x0000 ~ 0x270E | 02 |
| 输入寄存器 | 30001 ~ 39999 | 0x0000 ~ 0x270E | 04 |
| 保持寄存器 | 40001 ~ 49999 | 0x0000 ~ 0x270E | 03, 06, 16 |
注意看,PLC地址和协议地址是两码事。PLC地址从1开始,协议地址从0开始。你写代码时,用的都是协议地址。比如保持寄存器40001,对应协议地址就是0x0000。
我曾经踩过的坑:
有一次,我把PLC地址40001直接当协议地址用了,结果上位机读到的数据全是乱的。排查了半天,才发现地址偏移了1。所以,务必确认你的Modbus库用的是哪种地址体系。大多数开源库(比如libmodbus)用的是协议地址。
3.3 万用表的典型寄存器分配
好了,理论讲完,咱们来点实际的。假设你要给一台数字万用表实现Modbus通信,寄存器该怎么分配?
我做过一个项目,给一款工业万用表加Modbus接口。当时的需求是:上位机能实时读取电压、电流、电阻,还能远程切换量程。我的分配方案是这样的:
3.3.1 只读数据(输入寄存器)
这些是万用表测量到的实时值,上位机只能读,不能写。
| 协议地址 | PLC地址 | 内容 | 说明 |
|---|---|---|---|
| 0x0000 | 30001 | 电压测量值 | 单位:mV,有符号整数 |
| 0x0001 | 30002 | 电流测量值 | 单位:mA,有符号整数 |
| 0x0002 | 30003 | 电阻测量值 | 单位:mΩ,无符号整数 |
| 0x0003 | 30004 | 频率测量值 | 单位:0.1Hz |
| 0x0004 | 30005 | 设备状态字 | 位定义见下文 |
3.3.2 可读写数据(保持寄存器)
这些是配置参数,上位机可以修改。比如切换量程、设置报警阈值。
| 协议地址 | PLC地址 | 内容 | 说明 |
|---|---|---|---|
| 0x0000 | 40001 | 量程选择 | 0:自动, 1:200mV, 2:2V, 3:20V... |
| 0x0001 | 40002 | 测量模式 | 0:直流, 1:交流, 2:电阻... |
| 0x0002 | 40003 | 报警上限 | 单位与测量值一致 |
| 0x0003 | 40004 | 报警下限 | 单位与测量值一致 |
| 0x0004 | 40005 | 从站地址 | 1~247,掉电保存 |
3.3.3 位数据(线圈与离散输入)
离散输入用来反映万用表的“开关量”状态,比如是否超量程、是否在测量中。
| 协议地址 | PLC地址 | 内容 | 类型 |
|---|---|---|---|
| 0x0000 | 10001 | 超量程标志 | 离散输入 |
| 0x0001 | 10002 | 电池欠压标志 | 离散输入 |
| 0x0002 | 10003 | 测量完成标志 | 离散输入 |
线圈则用来控制一些“开关动作”,比如开启背光、启动校准。
| 协议地址 | PLC地址 | 内容 | 类型 |
|---|---|---|---|
| 0x0000 | 00001 | 背光控制 | 线圈 |
| 0x0001 | 00002 | 自动校准触发 | 线圈(上升沿有效) |
| 0x0002 | 00003 | 蜂鸣器使能 | 线圈 |
我的经验之谈:
分配地址时,尽量把同类数据放在连续地址段。比如所有测量值放在输入寄存器0x0000~0x0004,所有配置放在保持寄存器0x0000~0x0004。这样上位机可以用“批量读取”功能码(比如功能码03读多个保持寄存器),一次就把所有数据拉回来,效率高很多。
3.4 代码实现:地址映射的“翻译官”
地址分配好了,代码怎么写?说白了,就是写一个“翻译函数”,把Modbus协议地址映射到实际的变量指针。
我常用的方法是定义一个结构体数组,把地址和变量绑定起来。以保持寄存器为例:
// 保持寄存器映射表
typedef struct {
uint16_t addr; // 协议地址
uint16_t *pVar; // 指向实际变量的指针
uint16_t defaultValue; // 默认值
} HoldingRegMap_t;
// 实际变量定义
uint16_t g_rangeSelect = 0; // 量程选择
uint16_t g_measureMode = 0; // 测量模式
uint16_t g_alarmHigh = 1000; // 报警上限
uint16_t g_alarmLow = 0; // 报警下限
uint16_t g_slaveAddr = 1; // 从站地址
// 映射表
const HoldingRegMap_t g_holdingRegMap[] = {
{0x0000, &g_rangeSelect, 0},
{0x0001, &g_measureMode, 0},
{0x0002, &g_alarmHigh, 1000},
{0x0003, &g_alarmLow, 0},
{0x0004, &g_slaveAddr, 1},
{0xFFFF, NULL, 0} // 结束标志
};
然后,在Modbus的回调函数里,通过查表找到对应的变量指针,直接读写即可。这样做的好处是:地址变了,只改表,不改逻辑。
避坑指南:
我曾经在一个项目里,把保持寄存器的地址和输入寄存器的地址搞重叠了。结果读输入寄存器时,返回的却是保持寄存器的值。排查了两天才发现,是地址分配表里两个模型的起始地址都写了0x0000。记住:四种模型是独立的地址空间,0x0000在每种模型里都是“第一个位置”,互不干扰。
3.5 小结
这一章的内容,说白了就是三件事:
- 分清四种模型:线圈可读写、离散输入只读、输入寄存器只读、保持寄存器可读写。
- 规划地址映射:先画表,再写代码。地址连续,方便批量操作。
- 实现翻译函数:用查表法把协议地址和实际变量绑定起来。
下一章,咱们要动手写代码了。我会带着你,从零开始实现一个万用表的Modbus从站协议栈。到时候,这些地址映射的知识就会派上大用场。