☰
51单片机Modbus RTU从机实战:寄存器映射、CRC16与联调
2026/10/16 12:15:38 网站建设 项目流程

简介:最新版Modbus RTU 51单片机从机源码,面向单片机开发者和初学人员,主要解决从机协议移植与适配难题。相比原版,新增芯片型号选择,可适配STC12、STC15、STC8系列,并支持01、02、03、04、05、06、15、16等常用功能码,免去小白移植烦恼,适用性更广。资源包共63个文件,包含C源程序、头文件、Hex固件、Keil工程文件与PDF说明文档,整体约709MB,工程目录按源码、备份、示例分类,便于直接对照修改与二次开发,可直接导入Keil工程编译运行。目前已有984人学习下载,资源实用性经过验证。除完整从机代码外,还附带与威纶通、昆仑通泰触摸屏以及组态王的通信示例工程,覆盖常见上位机组态场景;同时保留原版和改编版以及调试备份,方便读者对比学习、排错验证,适合毕业设计、课程设计及产品原型快速落地。

1. 为什么 51 单片机做 Modbus RTU 从机反而省事

一台挂在 RS485 总线上的温湿度变送器,里面是 STC89C52,主站是组态软件或者 PLC,每隔 100ms 轮询一次读取数据,这是 Modbus RTU 51 单片机从机最常见的形态,也是很多嵌入式工程师入行时第一份和协议相关的工作。标题里说的“最新版”并不是指协议版本,Modbus RTU 本身二十年没有大改,变的是 51 上的实现方式:用串口中断收字节、定时器判定帧边界、状态机分发功能码,替代早期那种在主循环里干等字符的写法。下面内容就从寄存器映射开始,逐步讲清从机怎么设计、参数怎么设定、和 Modbus Poll 联调时先查什么。

2. Modbus RTU 从机的寄存器映射与 CRC16 查表实现

2.1 寄存器映射:Modbus 四大数据区里它只需要两个

Modbus 协议把可访问的数据分成四块:线圈(Coil)、离散输入(Discrete Input)、输入寄存器(Input Register)、保持寄存器(Holding Register)。前两者按位操作,后两者按 16 位字操作。四块数据区的对应功能码和典型用途如下:

数据区读功能码写功能码典型用途
线圈0x010x05 / 0x0F开关量输出
离散输入0x02无开关量输入
输入寄存器0x04无只读测量值
保持寄存器0x030x06 / 0x10可读可写的参数和数据

变送器、采集模块这类从机设备,大多数只做保持寄存器和输入寄存器就够用。我更推荐一个省事的做法:把所有数据放在保持寄存器区,用 0x03 读取测量值,用 0x06 写单个参数,用 0x10 写连续多个参数。这样内部实现就变成一个数组 holding_reg[] 的读写,不需要在 IO 位和寄存器之间来回翻译。地址规划可以这样定:

Modbus 地址实际偏移内容
400010设备状态字
400021温度值,放大 10 倍
400032湿度值,放大 10 倍
40004 ~ 400103 ~ 9报警阈值、预留参数

把温度湿度放大 10 倍是为了避开浮点数。51 没有硬件浮点单元,在中断外做浮点运算会拖慢整个主循环,现场常规做法是把物理量放大 10 倍或 100 倍,以整数方式传输,上位机再自行还原小数点。这也是为什么很多仪表说明书上写着“寄存器值 ÷ 10 为实际值”。如果你后面想把 RTU 报文通过网关转成 Modbus TCP,从机侧完全不用动,寄存器模型保持一致就行。

2.2 CRC16-MODBUS:逐位计算与查表怎么选

Modbus RTU 帧里每个字节之间没有分隔符,靠时间长短区分帧边界,靠 CRC16 保证帧内容正确。CRC16-MODBUS 使用多项式 0xA001(即 0x8005 的反射形式),初值 0xFFFF。最常见的实现是逐位计算,先算出一个可用于验证的版本:

unsigned int crc16_modbus(unsigned char *pData, unsigned char nLen) { unsigned int crc = 0xFFFF; unsigned char i; while (nLen--) { crc ^= *pData++; /* 先与低字节异或 */ for (i = 0; i < 8; i++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; /* 最低位为1时异或多项式 */ } else { crc >>= 1; } } } return crc; }

这段代码的逻辑是:每个字节先和 crc 的低字节异或,然后右移 8 次,每次右移前看最低位是否为 1,为 1 就异或 0xA001。返回值是高字节在前的标准 CRC 值。但 Modbus 协议规定发送时 CRC 低字节在前、高字节在后,所以追加到帧尾时要注意顺序:先放 crc 的低 8 位,再放高 8 位。判断这段函数写得对不对,用一条已知帧验证:01 03 00 00 00 02的 Modbus CRC 是0xC40B,发送顺序为0B C4。

逐位计算的优点是省空间,缺点是慢。一帧 Modbus RTU 报文最长 256 字节,实际请求多数只有 8 到 12 字节,51 在 11.0592MHz 晶振下算一次 CRC 大约几百微秒,对 9600bps 的轮询周期没有压力。但如果把帧超时和功能码分发都放在主循环里,CRC 计算会阻塞主循环,这时查表法就有意义了。查表法把每 8 位异或后的 256 种结果预先算好,用code关键字存在 Flash 里,每字节只需两次查表、三次异或,速度提升五六倍。

查表法的表与初值、多项式一一对应,网上搜“Modbus CRC 256 查表”能找到标准数组。要注意的坑是:查表数组必须声明为code类型,51 的 code 区访问走 DP 指针,如果把表误放到 data 区,128 字节的片内 RAM 立刻爆掉。一个工程里通常只保留一种计算方法,我的建议是中低波特率用逐位计算,19200 以上且主循环任务重时换查表。

2.3 串口中断收字节:不要在主循环里等字符

初学者最容易写出的方案是主循环里while (RI == 0);等待一个字节,这在 Modbus RTU 从机里会出问题:等待期间无法响应串口继续到达的字节,尤其上位机是一次性发完整帧时,主机发送字符的间隔可能小于单字节处理时间,丢一个字节整帧 CRC 就废了。正确做法是串口中断里收字节,主循环只专注协议逻辑。

#define RX_BUF_MAX 64 unsigned char rx_buf[RX_BUF_MAX]; unsigned char rx_len = 0; void UART_ISR(void) interrupt 4 { unsigned char ch; if (RI) { RI = 0; ch = SBUF; if (rx_len < RX_BUF_MAX) { rx_buf[rx_len++] = ch; /* 先收进来,帧完整性交给超时判定 */ } } else { TI = 0; } }

初始化部分用 51 最常见的“定时器 1 做波特率发生器”方式,11.0592MHz 晶振、9600 波特率、8 位数据、无校验:

void uart_init(void) { SCON = 0x50; /* 串口模式1,REN=1 允许接收 */ TMOD &= 0x0F; TMOD |= 0x20; /* 定时器1 模式2,8位自动重装 */ TH1 = 0xFD; /* 9600bps @ 11.0592MHz */ TL1 = 0xFD; TR1 = 1; ES = 1; EA = 1; }

这里有两个参数值得强调:TH1 = 0xFD只适用于 11.0592MHz 晶振,换成 12MHz 晶振时 9600 波特率误差偏大,长帧可能出错;SCON = 0x50中的 REN 位必须为 1,否则从机收不到任何请求。中断服务程序里只做“把 SBUF 搬到数组”,所有 Modbus 解析都不放在中断里,宗旨是中断处理时间远小于一个字符的接收时间。中断里的代码越短,越不容易在高速轮询时丢字符,这也是所谓“最新版”驱动方式和早期轮询写法最本质的区别。

3. 51 从机帧边界判定:定时器超时与主循环状态机

3.1 为什么收完一帧需要超时判定

Modbus RTU 帧里没有结束标志。主站发完请求后直接停在总线上,从机怎么知道这一帧结束了?协议规定:两个字符之间如果超过 3.5 个字符时间的静默,就认为一帧结束。9600bps 下每位约 104μs,一个字符含起始位、8 数据位和停止位共 11 位,约 1.146ms,3.5 个字符约 4ms。所以我用 1ms 为基准的定时器,计数到 5 就是 5ms,比 4ms 略大,留出余量,避免主站发送速度波动造成误判。

有些实现会在串口中断内给每个字节打“时间戳”,再判断当前字节和上一字节的间隔,这种办法对中断嵌套和定时器精度的要求都高,在 51 上并不划算。常见做法是收字节的中断负责清零一个全局计数,主循环发现这个计数超过阈值再进处理流程。为什么是清零而不是累加?因为每收到一个字节,帧就还不完整,计时必须从头开始;只有主站停止发送超过阈值,计数才能累计到阈值。这个过程把协议里的“超时”概念落成了一个简单的计数器比较,逻辑清晰也不需要额外的协议状态位。

除了 3.5 字符时间的帧结束判定,Modbus RTU 还有 1.5 字符时间的字节间隔要求。主站发送帧内字节时,相邻字节间隔不得超过 1.5 个字符时间,否则从机可以认为帧异常并丢弃。工程上最常见的故障就是主站用了软件延时逐字节发送,中间被操作系统调度打断,字节间隔超过 1.5 个字符时间,从机判断帧结束并开始处理,而此时数据还没发完。遇到这种现场问题,先查主站侧发送方式,不要急着改从机。

3.2 定时器 1ms 基准与帧完成标志

定时器 0 配置为模式 1,16 位定时,重装 1ms 初值:

volatile unsigned int uart_idle_cnt = 0; void timer0_isr(void) interrupt 1 { TH0 = (65536 - 1000) / 256; /* 1ms @ 12T,11.0592MHz */ TL0 = (65536 - 1000) % 256; if (uart_idle_cnt < 0xFFFF) { uart_idle_cnt++; } }

在串口中断里加一行清零,主循环的处理如下:

void main(void) { uart_init(); timer0_init(); while (1) { if (uart_idle_cnt >= 5) { /* 约5ms 空闲,判定一帧结束 */ uart_idle_cnt = 0; if (rx_len > 3) { modbus_process_frame(); rx_len = 0; } } } }

为什么阈值选 5 而不是 4?9600bps 下 3.5 个字符的理论时间是 4.0ms,但实际主站发送动作、USB 转串口模块的延迟都会让字符间隔有一定抖动。选 5ms 既能区分“帧内间隔”和“帧间间隔”,又不会让从机响应显得迟钝。如果波特率提高到 19200,3.5 字符时间缩到约 2ms,阈值就应下调到 2 或 3,否则一帧结束后要多等好几个毫秒才响应。

这段代码有两个容易忽略的细节。第一,rx_len = 0必须放在modbus_process_frame()执行完之后,不能放在超时判断刚触发时,因为处理函数内部还要读rx_buf,提前清空等于数据还没用就丢了。第二,modbus_process_frame执行期间如果有新字节进来,中断会往rx_buf继续写,可能覆盖尚未处理完的数据。Modbus 正常轮询节奏是主站发完请求后等响应,响应期间主站不会发新请求,所以这种覆盖通常不会发生。但如果主站设置了极短的轮询周期或用了乱序指令,可以在处理函数入口先保存rx_len,处理完再判断当前rx_len有没有变化,有变化说明收到了新帧的字节,此时选择丢弃新学节或标记重叠错误,不要接着处理旧数据。

3.3 主循环状态机:地址过滤、CRC 校验、功能码分发

处理一帧的核心函数如下:

void modbus_process_frame(void) { unsigned char addr, func; unsigned int crc_calc, crc_recv; if (rx_len < 4) return; addr = rx_buf[0]; func = rx_buf[1]; if (addr != SLAVE_ADDR && addr != 0x00) { return; /* 不是发给本机的帧,直接丢弃,不回复 */ } crc_calc = crc16_modbus(rx_buf, rx_len - 2); crc_recv = (unsigned int)rx_buf[rx_len - 2] | ((unsigned int)rx_buf[rx_len - 1] << 8); if (crc_calc != crc_recv) { return; /* CRC 不一致,按 Modbus 规范不回复 */ } switch (func) { case 0x03: modbus_read_holding(); break; case 0x06: modbus_write_single(); break; case 0x10: modbus_write_multi(); break; default: modbus_send_exception(0x01); break; } }

这段代码就是整个从机的状态机骨架,步骤固定为四步:地址过滤、CRC 校验、功能码分发、错误处理。地址 0 是广播地址,后面章节会详细展开;default分支返回异常码 0x01,表示功能码不支持,返回帧结构是“地址 + 0x80 | 功能码 + 异常码 + CRC”。CRC 校验通过与否决定了要不要回复,这是 Modbus 规范明确要求的,CRC 错的帧直接静默,不要让主站收到一个半截响应。

读保持寄存器的实现:

#define HOLDING_NUM 10 unsigned int holding_reg[HOLDING_NUM]; /* 对应40001~40010 */ void modbus_read_holding(void) { unsigned int start = ((unsigned int)rx_buf[2] << 8) | rx_buf[3]; unsigned int count = ((unsigned int)rx_buf[4] << 8) | rx_buf[5]; unsigned char i; unsigned int val; if (start + count > HOLDING_NUM) { modbus_send_exception(0x02); /* 非法数据地址 */ return; } tx_buf[0] = SLAVE_ADDR; tx_buf[1] = 0x03; tx_buf[2] = count * 2; for (i = 0; i < count; i++) { val = holding_reg[start + i]; tx_buf[3 + i * 2] = val >> 8; /* 高字节先发 */ tx_buf[4 + i * 2] = val & 0xFF; /* 低字节后发 */ } modbus_send_frame(3 + count * 2); }

边界检查start + count > HOLDING_NUM是很多人会漏掉的关键点。主站如果请求从 40008 读 5 个寄存器,没有这个检查,代码就会越界读 holding_reg 之外的内存,返回的数据变成随机值。用 Proteus 仿真时这类问题不一定暴露,现场接真实主站后数据乱跳,排查半天才发现是越界。写完这段代码后用 40001 起始、连续读 10 个寄存器的请求验证一次,再故意读越界地址,确认异常码 0x02 能正确返回,这个从机的读通道就算稳定了。

4. 用 Modbus Poll 联调 51 从机:参数匹配与抓帧验证

4.1 Modbus Poll 里先要配对的四个参数

联调阶段用 Modbus Poll 当主站最省事。打开后进入 Setup 菜单做两件事:Serial Port 设置串口号、波特率、数据位、校验位、停止位;Read/Write Definition 设置从站地址、功能码、起始地址、读取数量。四个关键参数要和从机程序完全一致:

参数推荐值对应从机程序的位置
Slave ID1SLAVE_ADDR宏定义
Function03读保持寄存器
Address0对应 40001,实际偏移 0
Quantity10不超过HOLDING_NUM

波特率设为 9600,8 数据位,无校验,1 停止位。配置正确的话,Modbus Poll 表格里会出现 10 个寄存器数值,状态栏不会报 Timeout。如果超时,先按顺序核对四个点:串口是否选对、从站地址是否匹配、校验位是否一致、波特率是否相同,而不是直接进协议代码里找问题。

校验位是新手最常踩的坑。51 端SCON = 0x50是无校验 8 位数据,但 Modbus Poll 默认可能带 Even 校验,两边不一致时从机收到的每个字节都是乱码,CRC 必然失败,现象就是“一次响应都没有”。还有 USB 转 TTL 模块本身质量参差,有些模块在 9600 波特率下收发正常,但一旦改成 19200 就开始丢字节。联调第一步固定 9600,确认链路稳定后再提高速率。

4.2 用串口助手抓帧与 Python 校验 CRC

Modbus Poll 能显示业务层结果,但不在界面上显示原始字节。当出现“有响应但数据不对”时,要用串口助手抓帧。把 USB 转 TTL 的 RX 接到 51 的 TXD,串口助手设为同样波特率,十六进制显示。主站发出的经典读请求是:

01 03 00 00 00 02 C4 0B

这串字节依次是:从站地址 1、功能码 03、起始地址 0000、数量 0002、CRC 低字节 C4、CRC 高字节 0B。从机正常回复为:

01 03 04 00 0C 00 5A xx xx

其中 01 是地址,03 是功能码,04 表示后面有 4 个数据字节,00 0C 与 00 5A 分别对应两个寄存器的高低位,最后的 xx xx 是从机算出来的 CRC。如果从机没有回复,看时间轴上主站请求结束到下一次请求开始之间有没有足够间隔。如果回复了但字节内容不对,把数据段提取出来,用电脑算一次 CRC,比对抓到的 CRC 字节是否正确。这一步用 Python 很方便:

import crcmod crc16 = crcmod.predefined.Crc("modbus") crc16.update(bytes.fromhex("01 03 00 00 00 02")) print("%04X" % crc16.crcValue) # 输出 C40B

同样一段代码也能验证从站响应帧的 CRC:把抓到的字节去掉最后两个 CRC 字节,从第一个字节开始放进update,打印结果应该和抓包得到的两字节一致。能用电脑验证的环节就不要在单片机里反复加调试打印,否则本来固定的帧结构会越改越乱。

4.3 现场排错的五个现象与对应动作

把调试现场常见的现象整理成一张排查表,按“现象 → 最可能原因 → 第一步动作”的顺序去看:

现象最可能原因第一步动作
Modbus Poll 完全无响应从站地址或串口参数不匹配核对 Slave ID、波特率、校验位
响应慢且偶发超时帧超时阈值偏小把 5ms 阈值调到 10ms 试
有响应但数据全 0xFFFF读取了未初始化的寄存器上电时把 holding_reg 全部清 0
数据有值但会跳变寄存器地址映射差 1检查偏移和 Modbus 地址对应关系
接 PLC 正常、接电脑不正常RS485 电平或接地点问题量 A/B 线电压,检查共地

最后一个现象很隐蔽。电脑通过 USB 转 485 模块连接时,如果模块和 51 从机没有共地,A/B 线之间的共模电压可能超出接收器范围,表现为主站发请求从机能收到,但从机回复回到电脑时已经畸变。把 485 模块的 GND 和从机电源地接在一起,大部分偶发通信问题会消失。硬件上的地线问题,用逻辑分析仪能轻松确认,没有逻辑分析仪就先补地线再继续查协议。

5. 多从机地址、广播帧与 RS485 方向切换细节

5.1 用拨码开关设置从机地址

多从机挂接时,每个从机硬件相同、程序相同,只有 51 的某组 IO 引脚组合不同,用来作为从机地址。常规做法是接 4 位拨码开关,程序启动时读一次,存入全局变量:

#define ADDR_PIN P1 unsigned char slave_addr; void read_addr_from_dipswitch(void) { unsigned char v = ADDR_PIN & 0x0F; /* 低4位为地址 */ if (v == 0) v = 1; /* 全0时兜底到1号机 */ slave_addr = v; }

注意,如果拨码开关不带内部上拉,引脚不能悬空读取,必须外接 10kΩ 上拉电阻,否则干扰造成从机地址跳变,两台从机同时应答,总线就会打架。设置完地址后,把modbus_process_frame里的SLAVE_ADDR全部替换成slave_addr,同一份固件就能适配 1 到 15 号从机。

5.2 广播地址 0 的写操作必须沉默

地址 0 是广播地址。Modbus 规范要求所有从机都接收广播帧,执行写操作,但不返回任何字节。广播写常用于校时、批量设参数这类场景,如果从机回了响应,主站还没处理完所有从机的写动作,总线上就会被多台从机的响应数据塞满。在功能码分发前统一判断:

if (addr == 0x00 && (func == 0x06 || func == 0x10)) { modbus_execute_write(); /* 只执行写,不调用发送函数 */ return; }

读功能码不能广播,这是协议本身的限制,因为多个从机同时响应会把总线打成一片混乱。实现广播写时,要保证modbus_execute_write里没有任何发送操作,包括异常响应也不能发。广播帧后总线会安静下来,主站自己等 RT 间隔再发起下一轮轮询,从机不需要主动延时。

5.3 发送完成后的 DE 释放时机

RS485 模块上的 DE 和 RE 通常是同一个引脚控制,高电平发送、低电平接收。很多代码只关心 TXD 的数据是否发完,随即把 DE 拉低,却忽略了模块内部的收发切换时间。如果切换瞬间太早,最后一个字符的停止位可能被截断,现场表现是偶发丢帧。稳妥的写法是发送完成后留出方向切换余量:

DE = 1; for (i = 0; i < tx_len; i++) { SBUF = tx_buf[i]; while (!TI); TI = 0; } DE = 0; delay_us(200); /* 方向切换余量,视485芯片而定 */

这个延时不属于 Modbus 协议,属于工程经验。200μs 在 9600 波特率下不到 0.2 个字符时间,对整体响应速度没有影响,但能解决一大批“第一次请求成功、第二次超时”的偶发问题。如果现场批量装机后发现个别从机响应不稳定,把这段延时时长做成一个可调参数,放在程序开头统一配置,能省去反复烧录固件的麻烦。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询