跳转到内容
新建笔记

Modbus地址映射:站号、数据空间与缓存边界

Modbus 的线上地址不必等于本地数组下标。地址映射首先要确定站号、数据空间、起始地址和数量,再把有效区间转换为紧凑的存储位置。用地址大小猜设备,或直接执行无边界检查的 buffer[address],都会把协议地址问题变成内存越界问题。

本笔记保留原工程的 S1/S2 场景:S1 使用 0x0000~0x066E,S2 从 0x5064 开始。没有提供原 mb_master 源码,因此下面是独立设计示例,不是对某个固定行号的已验证补丁。

若首尾地址都包含在内,连续区间的寄存器数量为:

N=Alast−Afirst+1N=A_{\mathrm{last}}-A_{\mathrm{first}}+1
存储方式所需空间说明
S1 紧密覆盖 0x0000~0x066E1647个16位字,3294字节2048可作为额外容量选择,但不是区间精确长度
将 S2 的 0x5064 直接作零起点下标至少20581个字,41162字节0x5064 的十进制是20580,必须再加1
S2 只用 0x5064~0x50652个字,4字节映射下标为0和1;实际设备范围须另核

原稿的20516个字不正确;给 S2 分配512个字也只有在确实需要相应范围时才有理由。内存预算还要包括有效标志、更新时间、临时接收区和并发快照,不能只看值数组。

2. 三种存储方式如何选

跳转到“2. 三种存储方式如何选”
方案适用情况代价与约束
每站/数据空间独立数组,并减去基址地址连续,区间不大简单;减法前检查下界和整个请求范围
多个连续块的映射表同一设备有若干离散地址段先查块,再做偏移;跨块请求需要明确拒绝或预先完整拆分
单寄存器地址映射、排序表或哈希表极度稀疏且点数较少节省值存储,但有元数据和查找成本;批量访问必须逐项验证

仅分离数组仍不够。例如两个从站都可以拥有地址 0x0000;同一站的保持寄存器和输入寄存器也可以使用相同地址,而它们属于不同数据空间。不能用 addr < 0x1000 判断是 S1 还是 S2。

手册的 40001 一类显示编号与 PDU 中的零基偏移应明确换算;不要见到4开头就把整个数值直接发送。依据设备手册和抓包确认边界。Modbus 应用协议的数据模型与寻址

3. 带完整请求边界检查的 C 示例

跳转到“3. 带完整请求边界检查的 C 示例”

此例只接收已经成功解码的 0x03/0x04 单次读取结果,每次最多125个寄存器。S1/S2 的本地块配置是示例;未配置的数据空间返回失败。values 必须指向至少 n 个有效的16位值,调用方负责它的寿命。

#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
typedef enum { HOLDING, INPUT } RegSpace;
typedef struct {
uint8_t station;
RegSpace space;
uint16_t base;
size_t count;
uint16_t *values;
} RegBlock;
static uint16_t s1_holding[0x066F];
static uint16_t s2_holding[2];
static const RegBlock blocks[] = {
{1, HOLDING, 0x0000, 0x066F, s1_holding},
{2, HOLDING, 0x5064, 2, s2_holding}
};
static bool store_read_result(uint8_t station, RegSpace space,
uint16_t address,
const uint16_t *values, size_t n) {
if (station < 1 || station > 247 || values == NULL ||
n == 0 || n > 125) return false;
if ((uint32_t)address + (uint32_t)n > UINT32_C(65536))
return false;
for (size_t i = 0; i < sizeof blocks / sizeof blocks[0]; ++i) {
const RegBlock *b = &blocks[i];
if (b->station != station || b->space != space ||
address < b->base) continue;
size_t offset = (size_t)(address - b->base);
if (offset > b->count || n > b->count - offset)
continue;
memmove(b->values + offset, values, n * sizeof *values);
return true;
}
return false;
}
int main(void) {
const uint16_t sample[2] = {100, 200};
bool ok = store_read_result(2, HOLDING, 0x5064, sample, 2);
assert(ok && s2_holding[0] == 100 && s2_holding[1] == 200);
ok = store_read_result(2, HOLDING, 0x5063, sample, 2);
assert(!ok); /* lower bound: no unsigned underflow */
ok = store_read_result(2, HOLDING, 0x5065, sample, 2);
assert(!ok && s2_holding[1] == 200); /* no partial update */
ok = store_read_result(1, HOLDING, 0x5064, sample, 2);
assert(!ok); /* a different station */
ok = store_read_result(2, INPUT, 0x5064, sample, 2);
assert(!ok); /* a different register space */
return 0;
}

下界判断必须先于无符号减法;上界用 n <= count - offset 避免直接做较窄整数加法。整个请求验证完成后才写入,防止最后一个寄存器越界时,前几个已经被更新。

这仍不是多任务意义上的原子更新。若 ISR、显示线程或控制任务同时访问,需用适用的临界区、锁或双缓冲快照保证一致性;不能依赖16位单次写入来保证32位参数或整块数据一致。

原记录提到 mb_reg.h、mb_reg.c 和 mb_master.c,以及当时 mb_master.c:411 附近的存储语句。行号没有绑定源码版本,仅作为检索线索。

  1. 在解析完成的请求上下文中保留站号、功能码/空间、起始地址和预期数量。不要收到任意响应就写缓存。
  2. 先核对 CRC、站号、正常或异常功能码、实际字节计数,再将两个线上字节按高字节在前还原为 uint16_t。
  3. 用请求上下文选映射块,验证全部范围,最后提交结果并记录更新时间。失败时保留旧值,但把它标为陈旧或失败。
  4. 在头文件仅写 extern 声明,在一个 .c 中定义数组,或者像示例一样用 static 封装并暴露访问函数。原稿把数组直接定义在头文件的写法会带来多重定义或多份存储问题。
  5. 同步更新业务读取逻辑。不能只改接收端为偏移存储,业务端仍用 mb_input_buf[0x0100] 之类绝对地址访问。

对于稀疏表,建议启动时检查站号/空间/区间重复及重叠;若允许跨块请求,应先定位并验证所有目标再执行写入,而不是边查边改。

边界测试包括首/尾地址、尾地址加一个字、错误站号、错误数据空间、零数量、超上限数量、空指针,以及地址加数量超过16位地址空间。成功写入后再提交错误请求,应确认原缓存没有部分改变。

本例说明映射与存储契约;它没有连接实际 S1/S2,也没有验证缺失的 mb_master.c。具体地址含义、大小端组合、缓存寿命和异常处理应以设备说明及实际库源码为准。

相关:主站事务与私有API、电机接口历史契约。原始来源:地址映射原文。