PIC单片机Modbus RTU主从通信实现与排错指南
2026/9/16 1:30:12 网站建设 项目流程

简介:面向PIC微控制器开发者的Modbus RTU主从协议实现源码包,以CCS编译器C语言工程形式提供,内含基于PIC18F458与PIC16F690的完整代码,覆盖主站与从站两种角色,可广泛用于工业现场仪表数据采集、PLC通信、电力监控等场景。压缩包共13个文件,以C源码、电路原理图(DSN/PDF/JPG)和README说明为主,并附带LCD显示驱动、PCB示意图及多角度实物接线图,整体大小6.88MB。已有330人学习下载。资料中主站部分给出外部中断RB0引脚配置、2400bps波特率设定、RTU帧收发处理等关键代码,从站示例则演示寄存器地址读写与响应组帧,两者配合可快速搭建完整通信链路;README.md详细说明硬件连接、编译环境及常见问题,电路设计文件便于二次开发、仿真验证或直接制板。源码注释清晰、结构模块化、便于裁剪移植,适合具备一定单片机基础、正在调试PIC串口通信或需要移植Modbus协议的嵌入式开发者参考学习。

1. 很多工程师低估了 Modbus RTU 主从实现的时序成本

提到 modbus rtu 协议与 PIC 微控制器,大多数人的第一反应是「串口发帧收帧而已」。但真正把设备挂到 PLC 总线上跑,问题几乎全出在时序上:RS-485 方向切换早了一个位、应答比主机预期晚了几毫秒、CRC 字节序放反,故障现象一律是「偶发不收数」。标题里「主从」不是修饰词,从机数量、轮询间隔和异常码容错,直接决定这套 C 语言代码能不能从样机搬进产线。这篇就以 PIC16F 系列为例子,把一个可落地的 Modbus RTU 主从实现拆开讲:帧结构、CRC 计算、从机接收状态机、主机轮询调度、调试手段,以及下载代码后最容易被坑的几个工程配置点。新手可以照着搭第一版,做过 5 年以上嵌入式的人,重点看第 2 章帧间隔和第 5 章排错清单里的边界条件。

2. Modbus RTU 帧结构、CRC 校验与 3.5T 时序的 C 语言落地

写协议栈之前,先把「线路上到底长什么样」这件事钉死,否则后面每一步都在猜。Modbus RTU 一帧由地址、功能码、数据、CRC16 四部分组成,所有数据按字节流排布。

字段长度取值说明
从站地址1 字节0 为广播,1~247 为从站地址,248~255 保留
功能码1 字节03 读保持寄存器,06 写单个寄存器,16 写多个寄存器
数据区N 字节寄存器起始地址、数量、字节数或寄存器值
CRC162 字节低字节在前发送,多项式 0xA001,初始值 0xFFFF

2.1 字节序陷阱:CRC 低字节在前,数据也是高低位颠倒

PIC 的 XC8 编译器默认大端访问多字节数组成员时容易让人放松警惕。以读保持寄存器 03 功能码的响应帧为例,从站地址 0x01、长度 5 字节、寄存器值 0x1234,线上字节顺序是01 03 02 12 34 高低CRC。寄存器数据 0x1234 拆成0x120x34发送,发送顺序是高位在前。而 CRC 恰恰相反,先送低字节再送高字节。两个顺序放一起,新手最容易把 CRC 的高低字节也按数据区方式发,结果就是用 Modbus Poll 读到 CRC 错误。联调时先确认这一点,能省一下午。

2.2 CRC-16/MODBUS 查表法与逐位法的取舍

CRC 实现有两种常见做法,逐位计算和查表。PIC16F 内存按 bank 划分,一张 256 项的表要占 512 字节,放到 const 段并不费 RAM,但查表时每次访问要处理 bank 切换,算下来未必比逐位快多少。对最长的 48 字节报文,逐位法在 4MHz 主频下大概 1ms 左右,9600 波特率下传一帧需要 50ms,CRC 计算时间占比非常小,直接用逐位法更容易读懂和排错。

uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *buf++; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }

调用时注意最后传参,发送函数里的写法是crc16(modbus_frame, 6),然后按先低后高填进第 7、8 字节。校验应答帧时同样调一次,判断返回值是否为 0。如果 CRC 不对,先查字节序,再查多项式是否用了 CRC-16/IBM 的 0x8005 这种常见误用——Modbus 用的是 0xA001 反向多项式,两者换算关系是颠倒位序,不是改一个常量就完事。

2.3 RTU 帧间隔:1.5T 与 3.5T 的定时器实现,不能靠 delay

协议链路层要求帧与帧之间至少 3.5 个字符时间的静默,帧内两字节间隔不能超过 1.5 个字符时间,否则接收方判定帧断裂。问题在于,这条规则对发送方同样有效:主机连续发送两帧之间,必须主动等待 3.5T。

波特率1字符时间(ms)1.5T(ms)3.5T(ms)
24004.5836.87516.042
96001.1461.7194.010
192000.5730.8592.005
1152000.0960.1430.334

上表按 1 字符 = 11 位(起始位 1 + 数据位 8 + 校验位 1 + 停止位 1)计算。实际工程里很多屏和 PLC 对间隔要求没那么严格,但下位机做从机时,接收端如果只用固定延时判断帧结束,在波特率配置错误时会导致粘包。用定时器做超时判断更可靠,定时器溢出时间设在 3.5T 附近。

// TMR1 每溢出一次代表 3.5 字符时间,在串口接收中断里重载 void tmr1_start_for_frame_gap(void) { TMR1H = (uint8_t)(frame_gap_reload >> 8); TMR1L = (uint8_t)(frame_gap_reload & 0xFF); PIR1bits.TMR1IF = 0; T1CONbits.TMR1ON = 1; }

重载值在初始化时按当前主频和波特率计算,而不是写死一个常数。这样同一份代码在 4MHz 和 8MHz 主频下只要改宏定义即可,避免工程师复制代码后忘记改定时参数。从站收到的每一字节都在 RX 中断里重载 TMR1,定时器溢出就说明一帧结束,可以进协议解析了。

2.4 异常码回复:地址对不上、功能码不支持要回什么

从机在三种情况下需要回异常帧:功能码不认识、寄存器地址越界、数据值非法。异常帧组成是地址、功能码高位加 0x80、异常码、CRC。表里列的是最常见的三个异常码。

异常码含义触发场景
0x01非法功能收到 04 读输入寄存器,但固件没实现
0x02非法数据地址请求起始地址加寄存器数量超出映射范围
0x03非法数据值写线圈时数据不是 0xFF00 或 0x0000

很多代码在地址校验不通过时直接丢弃请求,什么也不回。这对主机来说表现为超时,还能接受。但如果在银行项目里面对电表集中器,它要求每一个请求都有应答,超时会被判定为从机离线。所以在地址越界时回 02 异常帧,比沉默更符合现场运维的排查习惯。

3. 从机侧 C 语言实现:接收状态机与寄存器映射

3.1 寄存器映射:用数组还是结构体

PIC16F 上跑 Modbus 从机,寄存器映射通常两种做法。一种是把保持寄存器做成uint16_t holding_regs[N]数组,用功能码里的地址直接当索引;另一种是定义结构体,把开关量、模拟量、运行参数组织成不同成员。数组的优点是地址换算简单,03 功能码请求地址为 0 就返回holding_regs[0],天然满足协议要求的线性映射。结构体的好处是代码语义清晰,但需要自己做地址到成员的换算表,工程量多一倍。

我一般用数组加偏移量宏的方式,兼顾二者:

#define REG_VERSION 0x00 #define REG_PV 0x01 #define REG_SV 0x02 #define REG_MV 0x03 #define REG_ALARM 0x10 static uint16_t holding_regs[0x20];

需要特别注意保持寄存器数组大小与地址范围一致,防止主机的请求地址落在数组边界外。C 语言指针操作越界时不会报错,但是会把相邻 RAM 里的数据改掉,这类问题在产线上最难查,因为故障表现是随机变量被修改。

3.2 串口接收中断处理:边收边重载 3.5T 定时器

从机的串口接收中断做两件事,存字节、重载帧间隔定时器。不要在中断里做 CRC 校验,CRC 需要遍历整帧,会拖长中断响应时间,放到主循环里做。

// EUSART 接收中断示例,PIC16F18877 系列寄存器写法 void __interrupt() ISR(void) { if (PIR1bits.RCIF) { uint8_t byte = RCREG; if (rx_cnt < RTU_BUF_MAX) { rtu_rx_buf[rx_cnt++] = byte; } else { rx_cnt = 0; // 缓冲溢出,丢弃本帧 } tmr1_start_for_frame_gap(); // 重载 3.5T 定时器 } if (PIR1bits.TMR1IF) { PIR1bits.TMR1IF = 0; T1CONbits.TMR1ON = 0; if (rx_cnt > 0) { rtu_frame_ready = 1; // 置位帧完成标志 } } }

RCIF 标志在读取 RCREG 时由硬件自动清除,这一行代码顺序有讲究,先把 RCREG 取出来再操作其他变量,防止溢出位 OERR 卡死后续接收。缓冲区满时的处理策略是把计数清零、丢弃整帧,而不是继续覆盖写,避免半截帧被当成完整报文解析。

3.3 主循环解析:地址匹配、CRC 校验、功能码分发

中断只负责把字节码放进缓冲区并标记帧完成,真正的协议解析放主循环。解析流程固定:先判断地址是 0(广播)还是本机地址,再算 CRC,然后按功能码分发处理。广播帧不回复,但内容要执行,典型的场景是广播校时。

void modbus_slave_poll(void) { if (!rtu_frame_ready) return; rtu_frame_ready = 0; uint8_t addr = rtu_rx_buf[0]; uint16_t crc_rx = (uint16_t)rtu_rx_buf[rx_cnt - 2] | ((uint16_t)rtu_rx_buf[rx_cnt - 1] << 8); uint16_t crc_cal = modbus_crc16((uint8_t*)rtu_rx_buf, rx_cnt - 2); if (crc_rx != crc_cal) { rx_cnt = 0; return; } if (addr != slave_addr && addr != 0) { rx_cnt = 0; return; } switch (rtu_rx_buf[1]) { case 0x03: modbus_handle_read_regs(addr); break; case 0x06: modbus_handle_write_reg(addr); break; case 0x10: modbus_handle_write_regs(addr); break; default: modbus_send_exception(addr, 0x01); break; } rx_cnt = 0; }

CRC 接收值先拼成 16 位,注意低字节位置,计算时把输入长度减 2 排除 CRC 本身。地址匹配失败时直接返回不清缓冲区,这个细节有争议:清掉可以让下一帧从零开始,不清则方便调试时查原始数据。实际工程里我选择清掉,否则半截帧会跟下一帧拼在一起,导致连续两帧 CRC 失败。

3.4 方向切换:RS-485 的 DE/RE 控制时机

半双工 RS-485 需要手动控制方向脚。发送前拉高 DE,发送完成后必须等最后一个停止位完全送出再拉低。PIC 的 EUSART 在 TXIF 置位时表示发送缓冲区已空,但移位寄存器可能还在输出,此时立刻拉低方向脚会截断停止位。

void modbus_rs485_send(uint8_t *buf, uint8_t len) { RS485_DE = 1; // 拉高方向,进入发送模式 for (uint8_t i = 0; i < len; i++) { TXREG = buf[i]; while (!PIR1bits.TXIF); } while (!TRMT); // 等待移位寄存器彻底送完 RS485_DE = 0; // 再拉低,回到接收模式 }

TRMT 位是发送移位寄存器空标志,在发送函数返回前查询它,确保最后一位已经在线上传输完毕。方向脚切换再加一点余量也是常见做法,比如在 TRMT 之后再延时 50us,给对端收发器一点缓冲时间,尤其是线上挂的设备多、节点电容大的时候。

4. 主机侧轮询调度与重试机制

主机和从机代码逻辑有明显区别:从机是被动响应,主机是主动控制资源的一方,同时要管理总线上所有从机的时序。主机要保证对每个从机的两次请求之间至少隔一个 3.5T 静默期,而且轮询周期要稳定,不能因为某个从机响应慢把其他从机的巡检时间全部吃掉。

4.1 主机状态机:发送、等待应答、超时重试

主机的发送请求代码用一个状态机表示,一次完整请求经历四个状态,IDLE、SEND、WAIT_RESP、RETRY。把状态机放在主循环里轮询,而不是用阻塞式发送加延时等待的方式,好处是单片机在等待应答的间隙还能处理其他任务,比如按键扫描和看门狗喂狗。

typedef enum { MB_POLL_SEND, MB_POLL_WAIT, MB_POLL_RETRY, MB_POLL_NEXT } mb_poll_state_t; void modbus_master_poll(void) { static mb_poll_state_t state = MB_POLL_SEND; static uint8_t retry_cnt = 0; switch (state) { case MB_POLL_SEND: build_request_frame(poll_target_addr, poll_reg_addr, poll_reg_cnt); modbus_rs485_send(tx_buffer, tx_len); tmr_start_for_wait(); // 启动响应超时定时器 state = MB_POLL_WAIT; break; case MB_POLL_WAIT: if (rtu_frame_ready) { // 收到完整响应帧 proccess_slave_response(); retry_cnt = 0; state = MB_POLL_NEXT; } else if (tmr_wait_timeout()) { if (retry_cnt < MAX_RETRY) { retry_cnt++; state = MB_POLL_SEND; } else { mark_slave_offline(poll_target_addr); retry_cnt = 0; state = MB_POLL_NEXT; } } break; case MB_POLL_NEXT: poll_target_addr = next_slave_addr(poll_target_addr); state = MB_POLL_SEND; break; } }

这套状态机把一个常见的功能点顺带解决了:单从站连续失败 3 次后把它标记离线,继续巡检下一个从站,不让故障节点拖垮整条总线。重试次数、超时时间、离线判定这三个参数在工程调试里要单独提出来做成宏,方便现场改,不要写死在协议逻辑里。从机响应超时时间一般设在 50ms 到 200ms 之间,具体取决于从站固件处理速度。如果从机要执行较长的动作,比如控制变频器启动,它可能在第 100ms 才回帧,超时设置太短会把正常响应误判成超时。

4.2 超时重用与定时器分层的设计

主机既要控制 3.5T 帧间隔,又要管响应超时,两个超时时间差一个数量级,共用一个定时器就需要处理重载冲突。常见做法是 TMR1 做帧间隔短超时,TMR2 做主机的响应超时长定时。PIC 的外设资源够用,不必省这一个定时器,把逻辑分清楚比节约外设更重要。

响应超时的定时器最好用溢出不产生中断的方式,只在主循环里查询标志位,避免中断嵌套增加调试复杂度。查询函数下面这样写即可。

uint8_t tmr_wait_timeout(void) { if (PIR1bits.TMR2IF) { PIR1bits.TMR2IF = 0; T2CONbits.TMR2ON = 0; return 1; } return 0; }

注意这里每次进入超时判断后要把定时器停掉,否则定时器继续溢出会导致下一次请求刚发出就被误判超时。这类标志复位遗漏的问题,在 Modbus 主从联调时极难定位,因为现象是「第一次正常、第二次必超时」,排查时先看定时器中断标志是否在每轮被清除。

4.3 主机的「相关文件」工程结构

下载回来的 Modbus RTU 代码,通常至少包含下面几个文件:

文件职责
crc16.c/crc16.hCRC 计算模块,与主从站共用
modbus_slave.c从机功能实现,解析请求、生成响应
modbus_master.c主机轮询调度、超时重试
modbus_regs.c寄存器映射和读写接口
rs485_drv.cEUSART 初始化与收发方向控制
main.c外设初始化与主循环调度

拿到代码后别急着编译,先确认三件事:一是工程使用的是 PIC 的 MCC 生成的初始化代码还是手动寄存器配置,两者对 EUSART 引脚定义方式不同;二是 XC8 编译器的版本,新版编译器对interrupt关键字和函数指针的处理有差异,老代码直接在 PIC16F 新型号上编译容易报naked错误;三是 CONFIG 位里的看门狗使能状态,很多示例代码为了调试方便默认关看门狗,批量烧录时要重新打开并把事件里对喂狗的依赖处理好。

5. 总线抓不到数据?现场排错方法

主从站代码都能编译烧录,但一接起来就出问题,这类情况在 Modbus RTU 项目里占比非常高。这一章把现场最常见的故障现象、原因和验证手段列在一起,拿出来一条条对照检查。

5.1 三个隐蔽坑:方向脚拉早、终端电阻、收发器偏置

方向脚拉早在第 3.4 章说过,这里补充一个容易混淆的表现:发送正常、偶尔丢帧。如果 DE 在 TRMT 之前就拉低,线上波形最后一个字节的停止位会被削成窄脉冲,接收端采样到停止位高电平时间不足,直接报帧错误。从机收到错误字节后,帧间隔超时判定完成,协议层 CRC 却校验失败。这种现象表现成数据偶发丢失,非常容易误判为干扰。

终端电阻的问题在于大多数 485 电路板上没有装跳线,默认不接。现场两三台设备互相通信,距离又短,不接终端电阻反而通信正常。但接到几百米的现场总线,没有 120 欧终端电阻,信号反射会把波形变成振铃。用示波器看 A、B 线之间的波形,上升沿带有明显过冲毛刺,基本就是终端电阻缺失。

A-B 之间的偏置电阻也常被忽略。485 空闲时 A、B 间电压差需要低于 -200mV,保证接收端输出确定的高电平。很多节点收发器只接了 DE/RE 控制,没加偏置,总线上所有设备都处于接收状态时,线路浮空,噪声会把接收值打乱,表现为「收到一堆乱码但每帧 CRC 都错」。

5.2 用逻辑分析仪抓 T3.5 间隔和 CRC 字节序

Modbus RTU 故障定位首推逻辑分析仪,采样率 2M 以上就够。抓一次完整报文,先看两个帧之间的静默时间,用光标量一下是否达到 3.5T。很多从站代码的帧间隔定时器配置错误,实际间隔只有 0.5T,主机已经发下一帧了从站还在处理上一帧,于是缓冲区被新数据覆盖。

再看 CRC 字节序。逻辑分析仪可以把 16 进制数据直接解出来,对比modbus_crc16()函数的输出字节序。一个技巧是,在调试串口或 LCD 上同时打印计算值和接收值,两者高低字节互换是顺序问题,值完全不同则检查多项式。

5.3 故障现象与原因对照表

故障现象常见原因排查手段
完全无响应帧从站地址不匹配;DE 引脚接反串口助手直接发帧,看从站是否回异常帧
偶发超时3.5T 定时器不准确;从站中断被长时间占用逻辑分析仪量帧间隔;检查从站主循环是否有长时间临界区
CRC 错误集中在某个从站该从站晶振偏差大;波特率误差超 2%示波器测实际波特率,换用内部振荡器校准或改用外部晶振
主机收到的数据错位一字节停止位设置不一致确认所有设备统一 8N1
距离远后通信全断缺终端电阻、线缆屏蔽层未接地加 120 欧终端电阻并检查 A-B 线是否接反

现场排查 Modbus 问题时,建议时序进程并行处理:先用串口助手 + USB 转 485 板从外部确认主机发出的帧格式,逻辑分析仪挂总线上看物理波形,最后再加 PLC 侧监听。一步步排除,不看波形直接改代码只会越改越乱。

5.4 PC 端验证工具的组合用法:Modbus Poll 和 Modbus Slave

手头没有 PLC 时,PC 上跑 Modbus Poll 模拟主机,用 USB 转 485 适配器接到从机上,从一套最简单的组合开始验证。Modbus Poll 里设置好波特率和从站地址,读保持寄存器,能连续无报错刷新就说明从机的 03 功能码、CRC 和 485 方向控制都没问题。再从 PC 上跑 Modbus Slave 模拟从站,接 PIC 主机的 485 口,验证主机的轮询和超时重试逻辑。

这两个工具的组合可以覆盖主从联调的大部分场景,还提供了一个额外好处:能快速确认自己的 PIC 硬件电路是否正常。如果 Modbus Poll 发请求后从站不回帧,而从站用 Modbus Slave 能正常响应,问题必然出在 PIC 主机代码或两者波特率配置上,硬件可以排除。

6. 进阶技巧:帧间隔参数自适应计算

Modbus RTU 的时序参数依赖波特率,改波特率忘改定时器是常见失误。把帧间隔参数做成一个初始化函数,把波特率、主频、定时器分频三个参数输入进去,函数自动计算并设置重载值,可以从根上消除这类问题。这个技巧同时适用于主机和从机两侧。

void modbus_timing_init(uint32_t baud, uint32_t sys_freq_khz, uint8_t prescale) { // 1 字符时间 = 11 位时间(起始 1 + 数据 8 + 校验 1 + 停止 1) // 3.5 字符 = 3.5 * 11 / baud 秒 uint32_t t35_us = (uint32_t)((3500000ULL * 11) / baud); // 定时器 tick = 4 / Fosc,单位微秒 uint32_t tick_us = (uint32_t)(4000UL / sys_freq_khz * prescale); uint32_t ticks_needed = t35_us / tick_us; frame_gap_reload = 65536 - (uint16_t)ticks_needed; T1CONbits.T1CKPS0 = prescale & 0x01; T1CONbits.T1CKPS1 = (prescale >> 1) & 0x01; }

计算时注意整数除法精度。t35_us 用 32 位无符号整数,9600 波特率下算出来约 4010us,tick 在 8MHz 主频、1 分频时为 0.5us,需要约 8020 个 tick,这个值落在 16 位定时器的能力范围内。115200 波特率下需要约 668 个 tick,误差约 0.5%。如果分频比选得太大,比如 1:8 分频导致 tick 变大,计算结果会损失精度,帧间隔误差可能超过 5%,在某些严格要求协议时序的设备上会被拒绝。

这个函数的优点是值完全由公式导出,不依赖查表,代码在任意频率的 PIC 上都能用。移植到 PIC18 或 PIC24 时只要把寄存器名换掉,算法保持不变。调试时可以加一个调试打印,启动时输出 reload 值和实际超时时间,对比逻辑分析仪实测值,偏差不超过 1 个 tick 就不用管。参数自适应的意义不在于省那几行查表代码,而是让协议层透明:任何波特率参数更改,系统初始化时自动同步时序,不再存在「代码更新了但定时器参数忘改」这类现场事故。

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

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

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

立即咨询