
简介面向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初始值 0xFFFF2.1 字节序陷阱CRC 低字节在前数据也是高低位颠倒PIC 的 XC8 编译器默认大端访问多字节数组成员时容易让人放松警惕。以读保持寄存器 03 功能码的响应帧为例从站地址 0x01、长度 5 字节、寄存器值 0x1234线上字节顺序是01 03 02 12 34 高低CRC。寄存器数据 0x1234 拆成0x12、0x34发送发送顺序是高位在前。而 CRC 恰恰相反先送低字节再送高字节。两个顺序放一起新手最容易把 CRC 的高低字节也按数据区方式发结果就是用 Modbus Poll 读到 CRC 错误。联调时先确认这一点能省一下午。2.2 CRC-16/MODBUS 查表法与逐位法的取舍CRC 实现有两种常见做法逐位计算和查表。PIC16F 内存按 bank 划分一张 256 项的表要占 512 字节放到 const 段并不费 RAM但查表时每次访问要处理 bank 切换算下来未必比逐位快多少。对最长的 48 字节报文逐位法在 4MHz 主频下大概 1ms 左右9600 波特率下传一帧需要 50msCRC 计算时间占比非常小直接用逐位法更容易读懂和排错。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.04296001.1461.7194.010192000.5730.8592.0051152000.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 波特率下算出来约 4010ustick 在 8MHz 主频、1 分频时为 0.5us需要约 8020 个 tick这个值落在 16 位定时器的能力范围内。115200 波特率下需要约 668 个 tick误差约 0.5%。如果分频比选得太大比如 1:8 分频导致 tick 变大计算结果会损失精度帧间隔误差可能超过 5%在某些严格要求协议时序的设备上会被拒绝。这个函数的优点是值完全由公式导出不依赖查表代码在任意频率的 PIC 上都能用。移植到 PIC18 或 PIC24 时只要把寄存器名换掉算法保持不变。调试时可以加一个调试打印启动时输出 reload 值和实际超时时间对比逻辑分析仪实测值偏差不超过 1 个 tick 就不用管。参数自适应的意义不在于省那几行查表代码而是让协议层透明任何波特率参数更改系统初始化时自动同步时序不再存在「代码更新了但定时器参数忘改」这类现场事故。本文还有配套的精品资源点击获取