资讯动态

51单片机Modbus RTU从机实战:从零实现可被轮询的工业通信

发布时间:2026/10/13 15:21:00 来源:尧图企业网站定制
简介这份资源面向嵌入式初学者与工业自动化方向的开发者提供在51单片机上实现Modbus通信协议的完整实例代码帮助解决串口通信配置、协议解析与主从交互等实际问题。压缩包共21个文件约57KB包含2个c源文件与2个h头文件承载核心逻辑另有uvproj、uvopt等Keil工程文件、hex烧录文件、a51启动代码及lst、obj、m51等编译中间产物结构完整可直接编译调试。目前已有3609人学习下载说明该实例在同类教程中具备一定参考价值。代码覆盖Modbus RTU报文格式、功能码与寄存器地址处理、CRC校验算法、UART波特率与数据位配置以及主从请求应答状态机设计并具备较好的可移植性便于适配不同型号的51系列单片机。读者可据此理解工业级通信协议的底层实现思路并将其迁移到继电器控制、ADC数据采集或与PLC通信等场景中。1. 51单片机跑 Modbus为什么你的第一个从机总是收不到主机响应如果你手头有一块 51 内核的开发板想让它跟触摸屏、组态软件或者另一块板子通信Modbus 几乎是绕不开的协议。它简单、公开、对资源要求低特别适合 8051 这种 RAM 只有几百字节、主频十几兆的平台。但很多人第一次上手就翻车串口助手发出去一帧单片机毫无反应或者主机轮询几次就超时示波器上看波形却“好像没错”。问题往往不在协议本身而在几个被忽略的细节帧间隔怎么判断、寄存器地址是从 0 还是从 1 开始、CRC 高低字节谁先发、串口中断里该不该做耗时操作。这篇笔记就围绕“51 单片机 Modbus 实例”这个方向把 RTU 从机Slave的完整实现路径拆开讲清楚。适合正在做工业采集、仪表通信、课设或小批量控制器的嵌入式开发者也适合已经会点灯但没碰过现场总线的朋友。读完你应该能自己写出一份可被 Modbus Poll、组态屏正常轮询的 51 从机代码并且知道出问题时先查哪里。2. Modbus RTU 从机在 51 上的最小可行架构2.1 为什么 51 更适合做 RTU 从机而不是主机Modbus 有 ASCII 和 RTU 两种串行传输模式。ASCII 用可见字符调试直观但效率低RTU 用二进制同样波特率下吞吐量差不多是 ASCII 的两倍。51 的 UART 只有一个字节缓冲没有 DMA也没有空闲中断这意味着每来一个字节都要进一次中断。如果让它做主机去主动轮询几十个从站还要维护超时重发、任务调度RAM 和时序都会很紧张。反过来做从机就轻松很多从机永远被动应答收到完整帧、校验通过、地址匹配才回一帧。整个流程可以写成“中断收字节 → 主循环判帧 → 处理并回复”不需要复杂的调度器。常见做法是主机用 PLC 或组态软件51 只负责把本地传感器数据映射到保持寄存器等别人来读。这个分工符合现场习惯也把 51 的短板绕开了。选型上晶振建议 11.0592MHz 或 22.1184MHz这两个频率能让常用波特率9600、19200的定时器初值接近整数减少累积误差。串口模式用 UART 方式 18 位数据、可变波特率配合定时器 1 工作在方式 2 做波特率发生器。收发切换如果用 MAX485 这类半双工芯片需要一个 IO 控制 DE/RE发送前拉高、发送完最后一字节后拉低这个切换时机后面会专门讲。2.2 帧结构、寄存器模型与功能码选择一帧标准 RTU 报文由四部分组成从站地址1 字节、功能码1 字节、数据域N 字节、CRC 校验2 字节低字节在前。帧与帧之间靠至少 3.5 个字符时间的静默间隔来界定帧内字符间隔不能超过 1.5 个字符时间否则接收方应丢弃。51 上没有硬件帮你判断这个间隔得自己用定时器做。寄存器模型是理解 Modbus 的关键。协议定义了四种数据区实际项目里最常用的是保持寄存器Holding Register功能码 03 读、06/16 写和输入寄存器Input Register功能码 04 读。注意一个高频踩坑点协议报文里的寄存器地址是从 0 开始编号的而很多组态软件和 PLC 手册里写的是 40001、30001 这种“从 1 开始”的展示地址。40001 对应报文地址 0x000040002 对应 0x0001。如果你在代码里直接把 40001 当偏移去索引数组就会整体错一位。功能码不用全实现。一个够用的从机通常支持这几个功能码名称作用数据方向0x03读保持寄存器主机读从机数据从机回复0x04读输入寄存器主机读只读数据从机回复0x06写单个寄存器主机改一个参数从机回复0x10写多个寄存器主机批量下发从机回复异常回复的规则也要记住功能码最高位置 1后面跟一个异常码。比如 03 出错回 0x83异常码 0x02 表示地址越界0x03 表示数据值非法。很多新手只处理正常帧主机一发越界地址就收不到任何回复现场表现为“通信时好时坏”其实是异常帧没发出去。2.3 串口接收状态机与 3.5 字符定时51 接收 Modbus 帧的经典做法是“中断收字节 定时器判帧尾”。思路是每收到一个字节就重置一个帧间隔定时器如果定时器溢出时还没有新字节进来就认为一帧结束置一个标志给主循环处理。这个定时器的溢出时间要略大于 3.5 个字符时间。以 9600bps、11.0592MHz 为例一个字符是 10 位1 起始 8 数据 1 停止一个字符时间约 1.04ms3.5 个字符约 3.65ms。定时器可以设 4ms 左右。下面是一个接收缓冲和状态标志的骨架#include reg52.h #define RX_BUF_SIZE 64 volatile unsigned char rx_buf[RX_BUF_SIZE]; volatile unsigned char rx_len 0; volatile bit frame_ready 0; // 一帧接收完成标志 volatile bit rx_overflow 0; // 缓冲区溢出标志 // 串口中断每收一个字节进一次 void uart_isr(void) interrupt 4 { if (RI) { RI 0; // 必须软件清零 if (rx_len RX_BUF_SIZE) { rx_buf[rx_len] SBUF; } else { rx_overflow 1; // 帧太长丢弃并标记 } // 每收到字节就重置帧间隔定时器 TH0 FRAME_TIMER_H; TL0 FRAME_TIMER_L; TR0 1; } } // 定时器0中断帧间隔超时认为一帧结束 void timer0_isr(void) interrupt 1 { TR0 0; // 停掉定时器 if (rx_len 0) { frame_ready 1; // 通知主循环 } }这段代码的逻辑是串口中断只负责“搬字节”和“喂定时器”不做任何解析保证中断短。定时器 0 溢出说明总线静默超过设定时间一帧自然结束。主循环看到frame_ready后再去校验 CRC、判断地址和功能码。参数上FRAME_TIMER_H/L要根据晶振和波特率算RX_BUF_SIZE至少能装下最长帧读 125 个寄存器时数据域就有 250 字节如果只读十几个寄存器64 字节够用。注意rx_overflow一旦置位主循环处理完要清掉否则后续帧会一直被误判。3. 从零写一个可被轮询的 51 Modbus 从机3.1 寄存器映射与数据区定义在写解析之前先把“从机有哪些数据”定下来。假设这块 51 板子采集两路 ADC 电压和一路温度同时允许主机设置一个报警阈值和一个设备地址。可以这样映射保持寄存器#define HOLD_REG_NUM 8 unsigned int hold_reg[HOLD_REG_NUM]; // 保持寄存器16位一个 // 约定映射报文地址从0开始 // 0x0000 电压1 (mV) // 0x0001 电压2 (mV) // 0x0002 温度 (0.1℃) // 0x0003 报警阈值 // 0x0004 设备地址(1~247) // 0x0005 波特率代码 // 0x0006 保留 // 0x0007 保留这里用unsigned int数组每个寄存器占两个字节。51 是大端还是小端取决于编译器Keil C51 默认是大端高字节在低地址。Modbus 报文里寄存器值也是高字节在前所以如果直接用unsigned int数组发送时按内存顺序取字节通常正好但换编译器就要小心。稳妥做法是显式拆字节// 把16位值拆成Modbus顺序的两个字节 void put_u16(unsigned char *buf, unsigned int val) { buf[0] (unsigned char)(val 8); // 高字节在前 buf[1] (unsigned char)(val 0xFF); } unsigned int get_u16(unsigned char *buf) { return ((unsigned int)buf[0] 8) | buf[1]; }参数说明put_u16用于组回复帧get_u16用于解析主机写入的值。显式拆字节的好处是不依赖编译器的大小端移植到别的平台也不会突然翻车。寄存器数量HOLD_REG_NUM决定了地址范围主机读 0x0008 就属于越界要回异常码 0x02。3.2 CRC16 校验的查表与逐位实现Modbus RTU 用的是 CRC-16/MODBUS多项式 0xA001反向初值 0xFFFF结果低字节在前。51 上可以用逐位算法代码小如果 Flash 够也可以用 256 项查表换速度。逐位实现如下// Modbus CRC16返回16位值 unsigned int crc16(unsigned char *buf, unsigned char len) { unsigned int crc 0xFFFF; unsigned char i, j; for (i 0; i len; i) { crc ^ buf[i]; for (j 0; j 8; j) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }逻辑说明每个字节先异或进 CRC 低字节然后循环 8 次每次看最低位是 1 就右移后异或多项式是 0 就只右移。算完所有字节后得到的 16 位值发送时低字节在前、高字节在后。校验接收帧时把整帧含 CRC 两字节一起算结果应为 0或者算前面部分跟帧尾两字节比对。我一般用后者因为能直接定位是 CRC 错还是长度错。参数上len是参与计算的字节数不含 CRC 本身。注意buf用unsigned char*如果传rx_buf这种volatile数组某些编译器会警告可以加个临时拷贝或强制转换。CRC 算错是“收不到回复”的头号原因之一建议先用 PC 端工具算一组已知数据对一下比如01 03 00 00 00 01的 CRC 应该是84 0A低字节 0x84 在前。3.3 功能码 03/06/16 的解析与回复组装主循环拿到frame_ready后按“校验 → 地址 → 功能码 → 执行 → 回复”的顺序处理。下面是一个精简的处理函数void modbus_process(void) { unsigned char addr, func; unsigned int recv_crc, calc_crc; unsigned char resp[256]; unsigned char resp_len 0; if (!frame_ready) return; frame_ready 0; if (rx_len 4) { rx_len 0; return; } // 太短丢弃 // 1. 校验CRC最后两字节是CRC低字节在前 recv_crc rx_buf[rx_len - 2] | ((unsigned int)rx_buf[rx_len - 1] 8); calc_crc crc16((unsigned char *)rx_buf, rx_len - 2); if (recv_crc ! calc_crc) { rx_len 0; return; } addr rx_buf[0]; func rx_buf[1]; // 2. 地址判断0是广播本机地址匹配才回复 if (addr ! LOCAL_ADDR addr ! 0) { rx_len 0; return; } // 3. 功能码分发 switch (func) { case 0x03: // 读保持寄存器 case 0x04: { unsigned int start get_u16((unsigned char *)rx_buf[2]); unsigned int num get_u16((unsigned char *)rx_buf[4]); if (num 0 || num 125 || start num HOLD_REG_NUM) { // 异常回复功能码|0x80异常码0x02 resp[0] addr; resp[1] func | 0x80; resp[2] 0x02; resp_len 3; break; } resp[0] addr; resp[1] func; resp[2] (unsigned char)(num * 2); // 字节数 resp_len 3; for (unsigned int k 0; k num; k) { put_u16(resp[resp_len], hold_reg[start k]); resp_len 2; } break; } case 0x06: { // 写单个寄存器 unsigned int reg get_u16((unsigned char *)rx_buf[2]); unsigned int val get_u16((unsigned char *)rx_buf[4]); if (reg HOLD_REG_NUM) { resp[0] addr; resp[1] func | 0x80; resp[2] 0x02; resp_len 3; break; } hold_reg[reg] val; // 正常回复就是原样返回请求 for (unsigned char k 0; k 6; k) resp[k] rx_buf[k]; resp_len 6; break; } case 0x10: { // 写多个寄存器 unsigned int start get_u16((unsigned char *)rx_buf[2]); unsigned int num get_u16((unsigned char *)rx_buf[4]); if (num 0 || num 123 || start num HOLD_REG_NUM) { resp[0] addr; resp[1] func | 0x80; resp[2] 0x02; resp_len 3; break; } for (unsigned int k 0; k num; k) { hold_reg[start k] get_u16((unsigned char *)rx_buf[7 k * 2]); } resp[0] addr; resp[1] func; resp[2] rx_buf[2]; resp[3] rx_buf[3]; // 起始地址 resp[4] rx_buf[4]; resp[5] rx_buf[5]; // 寄存器数量 resp_len 6; break; } default: resp[0] addr; resp[1] func | 0x80; resp[2] 0x01; resp_len 3; // 非法功能码 break; } // 4. 追加CRC并发送 if (resp_len 0) { unsigned int crc crc16(resp, resp_len); resp[resp_len] (unsigned char)(crc 0xFF); resp[resp_len] (unsigned char)(crc 8); uart_send(resp, resp_len); } rx_len 0; }逻辑说明先校验 CRC不通过直接丢再判断地址广播地址 0 不回复然后按功能码分发。03/04 读寄存器时回复格式是“地址 功能码 字节数 数据 CRC”字节数等于寄存器数乘 2。06 写单个寄存器正常回复就是原请求回显。16 写多个寄存器回复“地址 功能码 起始地址 数量 CRC”。异常回复统一是“地址 功能码|0x80 异常码 CRC”。参数上读寄存器数量上限 125 是协议规定写多个上限 123。start num HOLD_REG_NUM这个判断要防溢出start和num都是 16 位加起来可能超过 16 位范围实际项目里可以先判断start HOLD_REG_NUM再判断num。发送函数uart_send要先拉高 485 的 DE发完最后一字节等发送完成标志 TI 置位后再拉低这个切换时机下一章细说。4. 收发切换、定时器与中断优先级的避坑清单4.1 485 方向控制切换太早导致尾字节丢失现象主机能收到回复但 CRC 经常错或者最后一个字节变成 0xFF。原因半双工 485 芯片的 DE 拉低太早最后一个字节还在移位寄存器里没发完总线就被释放了主机收到的是残缺帧。解决发送完所有字节后不能立刻拉低 DE要等 UART 的 TI发送完成标志置位确认移位寄存器空了再切换。void uart_send(unsigned char *buf, unsigned char len) { unsigned char i; RS485_DE 1; // 切到发送 for (i 0; i len; i) { SBUF buf[i]; while (!TI); // 等这一字节发完 TI 0; } // 最后一个字节的TI已经等到此时移位寄存器已空 RS485_DE 0; // 切回接收 }注意这里用的是“发一字节等一字节”的阻塞方式简单可靠代价是发送期间 CPU 被占用。如果帧长、波特率低主循环会被拖住几毫秒对大多数 51 应用可以接受。想优化可以改成中断发送但 DE 切换仍要放在最后一个字节的 TI 中断里。4.2 帧间隔定时器与串口中断的优先级打架现象偶尔把一帧拆成两帧或者两帧粘成一帧。原因帧间隔定时器中断和串口中断优先级设置不当或者定时器重载值算错。51 默认中断优先级里串口是低优先级定时器 0 也是低优先级同级时按查询顺序。如果帧间隔定时器溢出中断正在执行串口来了新字节会等定时器中断返回才处理可能错过字节。解决把串口中断设为高优先级IP 寄存器里置位 PS保证收字节不被帧定时器打断。同时帧间隔定时器的溢出时间要留余量9600bps 下 3.5 字符约 3.65ms定时器设 4~5ms 比较稳。如果主机帧内字节间隔本来就大有些 USB 转串口延迟高可以把判定时间放宽到 5~6ms但别超过主机帧间间隔否则会粘帧。4.3 寄存器地址 0 基与 1 基混用现象组态软件读 40001 读到的是第二个寄存器的值或者写 40001 结果改了 40002。原因软件界面显示的是 1 基地址报文里是 0 基地址代码里又按 1 基去索引数组。解决在代码里统一用 0 基收到报文地址直接当数组下标跟别人对接时明确问一句“你那边 40001 对应报文地址几”。我一般会在寄存器映射表里同时标注“报文地址”和“常见展示地址”避免来回换算出错。4.4 中断里做 CRC 或浮点运算导致丢字节现象通信偶尔丢帧波特率越高越明显。原因串口中断里除了搬字节还做了 CRC 校验、浮点转换甚至串口打印中断执行时间超过一个字符时间下一个字节的 RI 还没读走就被覆盖。解决中断只做最短操作——读 SBUF、存缓冲、重置帧定时器。CRC、解析、回复全部放主循环。如果主循环也忙可以用“接收完成标志 双缓冲”但 51 上单缓冲加及时处理通常够用。4.5 从机地址与广播地址的处理遗漏现象主机用广播地址 0 写参数从机不回复这是对的但参数也没写进去。原因代码里判断addr ! LOCAL_ADDR就直接 return把广播地址也过滤了。解决地址判断写成if (addr ! LOCAL_ADDR addr ! 0) return;广播帧要执行但不回复。另外设备地址本身如果存在保持寄存器里改地址后要立即生效别等下电重启。5. 用 Modbus Poll 验证从机并做压力测试写完代码只是第一步能不能被真实主机稳定轮询才是关键。我习惯先用 PC 端 Modbus 调试工具做功能验证再用脚本做压力测试。工具侧设置串口选对应 COM 口波特率 9600、8 数据位、无校验、1 停止位8N1模式选 RTU。功能码 03从站地址填代码里的LOCAL_ADDR起始地址 0数量 8看能不能读到 8 个寄存器的值。如果读不到先看工具的超时和重试设置再看 485 接线 A/B 有没有接反。功能验证通过后做一轮压力测试。可以用调试工具的自动轮询功能间隔设 50ms连续跑几小时观察有没有超时计数增长。更严格一点用脚本模拟主机快速轮询import serial import time import struct def crc16(data): crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc ser serial.Serial(COM3, 9600, timeout0.1) slave 1 err 0 total 0 for i in range(2000): # 读保持寄存器 0~7 req bytes([slave, 0x03, 0x00, 0x00, 0x00, 0x08]) crc crc16(req) req struct.pack(H, crc) ser.reset_input_buffer() ser.write(req) resp ser.read(21) # 111162 total 1 if len(resp) ! 21: err 1 continue # 校验回复CRC if crc16(resp[:-2]) ! struct.unpack(H, resp[-2:])[0]: err 1 time.sleep(0.02) print(total%d err%d % (total, err)) ser.close()这段脚本连续发 2000 次读请求统计超时和 CRC 错误。参数上timeout0.1是单次读超时sleep(0.02)模拟 20ms 轮询间隔。如果错误率超过千分之一就要回头查帧间隔定时器、485 切换和中断优先级。实测中9600bps 下 51 从机做到零错误是正常的如果错误集中在高波特率多半是中断处理时间不够。最后说一个我自己的习惯每次改完通信相关代码先不接真实设备用串口助手手动发几帧已知报文看回复的十六进制对不对。这一步能挡掉八成低级错误比接上组态屏反复试快得多。寄存器映射表贴在显示器边上地址换算错一次就划掉重写别靠脑子记。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑