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 小结

这一章的内容,说白了就是三件事:

  1. 分清四种模型:线圈可读写、离散输入只读、输入寄存器只读、保持寄存器可读写。
  2. 规划地址映射:先画表,再写代码。地址连续,方便批量操作。
  3. 实现翻译函数:用查表法把协议地址和实际变量绑定起来。

下一章,咱们要动手写代码了。我会带着你,从零开始实现一个万用表的Modbus从站协议栈。到时候,这些地址映射的知识就会派上大用场。