简介:面向初学德州仪器TMS320F28335数字信号处理器的嵌入式开发者,这套Modbus RTU学习例程完整覆盖协议主从站角色、帧结构解析、错误检测以及RS-485接口配置等关键环节,并基于真实工程源码演示通信流程,适合从零理解工业Modbus通信协议。压缩包内包含两千个文件,其中头文件和源文件合计一千九百余个,另有说明文档、辅助脚本、扩展代码以及教程,可配合源码阅读、协议仿真和板级调试使用,帮助快速定位通信问题。资源包大小为二十九点四兆字节,目前已有二百八十八人学习。例程中既提供数字信号处理器串口外设初始化代码,也给出主从站收发示例与简单测试脚本,初学者可通过阅读和修改代码掌握浮点型数字信号处理器的寄存器配置以及Modbus RTU报文封装解析技巧。作为入门教材,稍作扩展即可迁移到温度采集、电机控制等实际项目,积累工业自动化与嵌入式系统开发的宝贵经验。
1. DSP28335 跑 Modbus RTU:从 485 接线到寄存器表的完整链路
手里有块 TMS320F28335,买了根 USB 转 485 线,想把这个 DSP 挂到 Modbus RTU 总线上当从机——这套需求在工控和电机驱动项目里非常常见。很多初学 DSP28335 的人卡在同一个地方:官方例程只给了 SCI 收发,没给 Modbus 协议栈,自己啃协议要花不少时间。这份学习例程的价值在于它把「RS485 收发 + CRC16 校验 + Modbus 从机状态机 + 寄存器读写」串成了一条完整链路,寄存器表怎么设计、报文怎么组、方向脚怎么切,全部有代码可以抄。适合刚接触 DSP 和串口总线的初学者,也适合已经调通 SCI 但不知道 Modbus 报文怎么拼的工程师,下载后能直接对着开发板跑。
2. 开发环境与工程骨架:TI 库版本选择与文件组织
2.1 28335 的 SCI 模块与 RS485 方向脚:先搞清楚外设再动手
DSP28335 上的 SCI 模块本质是一个标准 UART,支持 8 位数据位、可配置校验位、1 或 2 位停止位,这在 Modbus RTU 的串口参数里刚好对应 8N1。但 Modbus RTU 在物理层通常跑在 RS485 上,RS485 是半双工总线,一条双绞线上同一时刻只能有一个节点发送。这就意味着你不能像调试普通 UART 那样只接 TX 和 RX,必须额外用一个 GPIO 去控制 485 芯片的 DE/RE 引脚。
常见的做法是用两个 GPIO,一个控制发送使能(接 485 芯片的 DE),另一个控制接收使能(接 /RE)。很多现成的 485 模块把 DE 和 /RE 并在一起,一根线控制方向,高电平发送、低电平接收。在例程里一般选用 GPIO12 和 GPIO13 做方向脚,一个拉高一个拉低,逻辑简单也方便用示波器观察切换时序。
选型时还要注意 485 芯片的类型。便宜的 MAX485 和经典的 SN65HVD3082 用法一致,但终端电阻和偏置电阻的位置不同。如果总线上只有一块 DSP 和一台电脑,终端电阻可加可不加;一旦线上设备超过两台,两端必须各挂一个 120Ω 终端电阻,否则长线传输时信号反射会让 CRC 校验误码。这个细节在实验室里不明显,到了现场经常让人怀疑是程序写错了。
2.2 例程的三层文件结构:外设层、协议层、应用层
这套例程在工程组织上建议分成三层,不要把所有逻辑塞进一个 main.c。外设层负责 SCI 初始化、中断收发和 GPIO 方向切换;协议层负责 Modbus 报文解析、CRC 校验和功能码分发;应用层只维护一份寄存器表,不关心物理层怎么收发的。
| 文件 | 职责 | 关键函数 |
|---|---|---|
| scia_init.c | SCI 初始化、GPIO 方向脚配置 | init_scia_485() |
| modbus_slave.c | 报文接收、帧校验、功能码处理 | modbus_process_frame() |
| modbus_crc.c | CRC16 查表与计算 | crc16_modbus() |
| reg_table.c | 保持寄存器和输入寄存器的读写映射 | read_reg(), write_reg() |
| main.c | 主循环调度、定时器超时计时 | main() |
这里要特别提醒初学但已掉过坑的人:头文件包含顺序不要乱。DSP2833x 的工程里,外设结构体定义在 DSP2833x_GlobalPrototypes.h,而中断向量表在 DSP2833x_PieVect.h,这两个头文件必须放在所有源文件的最前面且顺序固定。顺序放错,编译阶段可能不报错,但链接时会出现符号重复定义,或者运行时中断进不来。
如果之前移植过其他 MCU 的串口程序,会注意到 28335 的 SCI 发送完成标志位有两种:SCICTL2 里的 TXRDY(发送缓冲空)和 TXINT 标志,以及 SCITXBUF 空的情况。Modbus 方向控制的时机必须以「发送完成中断」为准,这个在第 5 章会重点展开,初学者十有八九在这里翻车。
2.3 时钟与波特率配置:LSPCLK 预分频的换算方法
Modbus RTU 目前最常用的波特率是 9600 和 19200,例程默认 9600。28335 的 SCI 波特率不是直接用系统时钟除出来的,而是经过低速外设时钟 LSPCLK 预分频。在默认配置下,系统时钟 SYSCLKOUT 为 150MHz,LSPCLK 默认等于 SYSCLKOUT/4,也就是 37.5MHz。
波特率寄存器的换算公式是:BRR = (LSPCLK / (BAUD × 8)) - 1。注意这个公式只适用于 BRR 大于等于 1 的情况,如果波特率太高导致 BRR 小于 1,SCIHBAUD 和 SCILBAUD 的配置方式完全不同,需要查手册走另一套 16 倍过采样模式。9600 和 19200 都在正常范围内,直接套公式即可。
void init_scia_485(void) { Uint16 brr; EALLOW; GpioCtrlRegs.GPAMUX1.bit.GPIO28 = 1; // GPIO28 配为 SCIRXDA GpioCtrlRegs.GPAMUX1.bit.GPIO29 = 1; // GPIO29 配为 SCITXDA GpioCtrlRegs.GPAMUX1.bit.GPIO12 = 0; // GPIO12 普通 IO,做 485 方向脚 GpioCtrlRegs.GPAMUX1.bit.GPIO13 = 0; // GPIO13 普通 IO,做 485 方向脚 GpioCtrlRegs.GPADIR.bit.GPIO12 = 1; // 方向脚输出 GpioCtrlRegs.GPADIR.bit.GPIO13 = 1; EDIS; SciaRegs.SCICCR.all = 0x07; // 8 位数据位,无校验,1 停止位 SciaRegs.SCICTL1.all = 0x03; // 使能 TX、RX,禁用休眠 SciaRegs.SCICTL2.bit.TXINTENA = 1; // 使能发送中断 SciaRegs.SCICTL2.bit.RXBKINTENA = 1; // 使能接收中断 // LSPCLK = 37.5MHz,目标 9600 bps brr = (37500000L / (9600 * 8)) - 1; SciaRegs.SCIHBAUD = (brr >> 8) & 0xFF; SciaRegs.SCILBAUD = brr & 0xFF; SciaRegs.SCICTL1.all = 0x23; // 退出复位,真正启动 SCI // 注册 PIE 中断,向量等配置省略,依赖 DSP2833x 例程框架 PieCtrlRegs.PIEIER9.bit.INTx9 = 1; // SCIRXINTA PieCtrlRegs.PIEIER9.bit.INTx8 = 1; // SCITXINTA }这段初始化代码里,最容易被忽略的是 SCICTL1 的两次写入。第一次写成 0x03 只是把外设从复位中配置好,此时 SCI 还处于复位状态;最后再写入 0x23,等于把 SCICTL1 的 SWRESET 位置 1,外设才开始真正工作。如果漏掉这一步,寄存器配得再对,串口也不会输出任何数据,而且不报错,排查起来相当费劲。
GPIO28 和 GPIO29 是 28335 SCI-A 的默认引脚,GPIO12 和 GPIO13 的方向控制逻辑根据具体板子的 485 芯片接法调整。有些核心板把方向脚接到了 GPIO32 或 GPIO34,改的时候只需要同步修改 GPAMUX 对应的寄存器位和 GPxSET/GPxCLEAR 寄存器,其余逻辑不需要动。
3. CRC16 校验与从机状态机:初学最容易抄错的两段代码
3.1 CRC16-Modbus 查表法:初值 0xFFFF 与多项式 0xA001
Modbus RTU 的 CRC16 和普通 CRC16 有两个关键区别:多项式是 0x8005 的反序 0xA001,初值是 0xFFFF,而且字节是先低位后高位送入寄存器。很多抄代码的人把标准 CRC16-IBM 的算法直接搬过来,结果上位机一直报 CRC 错误,就是因为多项式反序和字节位序的问题。
查表法适合初学者理解,也适合嵌入到中断里快速计算。表格大小只有 256 个 16 位值,放在 const 数组里不占 RAM,对 28335 这种内部 Flash 充裕、RAM 有限的芯片非常友好。
static const Uint16 crc16_table[256] = { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0281, 0xC240, // 完整 256 项表格可从开源实现生成,省略中间项 }; Uint16 crc16_modbus(const Uint16 *data, Uint16 len) { Uint16 crc = 0xFFFF; Uint16 i; for (i = 0; i < len; i++) { crc = (crc >> 8) ^ crc16_table[(crc ^ data[i]) & 0xFF]; } return crc; }这个函数的输入 data 是字节数组,但因为指针用了 Uint16 类型,实际调用时需要强转。计算完成后返回的 crc 值要注意字节序:Modbus 报文里 CRC 是「低字节在前,高字节在后」,所以发送时要把 crc & 0xFF 放在前面,crc >> 8 放在后面。经常有人在这一点上颠倒,导致报文尾部两个字节互换,整个帧被从机拒绝。
如果不想维护 256 项的静态表,也可以用逐位计算的版本。但查表法在一次扫描 8 个字节报文的情况下耗时极短,对实时性没有影响,我一般还是推荐查表。注意 CRC 计算范围是从站地址到数据域的最后一个字节,不包括 CRC 本身,这个边界和帧长度一样容易弄错。
3.2 从机状态机:从空闲到处理完成要经历哪几个状态
Modbus RTU 从机的核心是一个有限状态机,它把串口收到的字节流逐步转换成完整一帧,然后交给功能码处理函数。如果状态机写得稀烂,常见的表现是:连续发送多帧时偶尔回包,偶尔不回包;或者在响应帧发送过程中收到新请求,导致总线数据交织。
标准的从机状态机至少包含四个状态:空闲、接收、处理、发送。空闲状态下收到第一个字节进入接收态,并在接收态持续累加字节直到帧超时;超时后进入处理态,此时 CRC 校验和功能码解析都是纯计算,最后进入发送态,发送完成后再回到空闲。这个状态转换看起来简单,实际代码里最容易出错的是「接收态超时后的字节计数清空」。
enum mb_rcv_state { MB_IDLE, // 空闲 MB_RECEIVING, // 收帧中 MB_PROCESSING, // 帧完整,处理中 MB_SENDING // 发送响应中 }; volatile Uint16 mb_state = MB_IDLE; volatile Uint16 rcv_buf[256]; volatile Uint16 rcv_cnt = 0; volatile Uint16 rcv_timeout_cnt = 0;主循环里定时器每 1ms 递增一次 rcv_timeout_cnt,接收中断只负责把字节放进缓冲区并把超时计数器清零。这种用主循环超时判断帧结束的方式比在中断里做复杂逻辑要稳得多,特别适合 Modbus RTU 的 3.5 字符帧间隔要求。
状态迁移的时机要在中断外完成,避免接收中断和处理逻辑互相打断。主循环扫描状态,发现超时计数达到阈值且 rcv_cnt 大于零,就把状态切到 MB_PROCESSING,然后清空 rcv_cnt。这个动作必须放在处理之前,否则下一帧数据会跟上一帧残余字节混在一起。
3.3 一帧数据的判定:3.5 字符时间与帧间隔
Modbus RTU 协议规定,一帧结束的标志是串口空闲超过 3.5 个字符时间,相邻两帧之间至少要有 3.5 字符时间的间隔,否则视为非法帧。9600 波特率下 1 个字符包含 1 起始位 + 8 数据位 + 1 停止位,共 10 bit,3.5 字符时间理论上约等于 3.65ms。
实际代码里不需要精确到微秒级,用 4ms 或 5ms 的定时器阈值都可以。但如果把超时设到 10ms 以上,一旦主机连续快速轮询,可能会把多帧合并成一个大帧,CRC 永远对不上。这个问题在 19200 波特率下特别突出,因为 3.5 字符时间缩到约 1.8ms,很多板子的定时器精度不够。
我一般用 1ms 的定时器,阈值设在 5。也就是说 rcv_timeout_cnt 达到 5 就认为帧接收结束。这样既能容忍 9600 波特率下的正常帧间隔,又不会把两帧合并。
另一个容易忽视的点是:从机在发送响应帧的过程中,主机的下一帧请求可能已经到了。因为 485 是半双工,从机发送时接收引脚并不关闭,所以接收中断有可能在发送期间把主机的下一帧收进缓冲区。如果状态机还在 MB_SENDING 状态,这时收到的字节不能简单丢进缓冲区,必须留在处理完当前帧之后再判断。常见做法是发送态下禁止接收中断,或者用一个挂起标志暂存新数据。例程采用的是发送态下屏蔽 RX 中断,简单粗暴但效果可靠。
4. 核心实现:功能码 03/06/16 的寄存器表设计与收发方向控制
4.1 寄存器表设计:把工程变量映射成 Modbus 地址
Modbus 从机的本质是让外部主机读写内部变量,所以核心工作是设计一张寄存器映射表。表格里的每一项对应一个内部变量,外部主机通过功能码和寄存器地址来操作它们。这个映射规则完全由开发者自己定义,但工程实践中通常沿用变频器和 PLC 的习惯:保持寄存器从 0x0000 开始排,输入寄存器从 0x1000 或 0x2000 开始排。
在这套例程中,寄存器表使用一个简单结构体数组,将变量地址、类型和读写权限集中管理。这种方式比散落在一堆 switch-case 里好维护得多,后期增加寄存器只需要往数组里加一行。
typedef struct { Uint16 reg_addr; // Modbus 寄存器地址 Uint16 *reg_ptr; // 指向实际变量的指针 Uint16 reg_count; // 寄存器数量,用于连续读写 } mb_reg_map_t; // 示例变量 Uint16 run_cmd = 0; Uint16 speed_ref = 50; Uint16 fault_code = 0; const mb_reg_map_t reg_table[] = { { 0x0000, &run_cmd, 1 }, { 0x0001, &speed_ref, 1 }, { 0x0002, &fault_code, 1 }, { 0x0003, &fault_code, 0 } // 非法项,用于越界测试 };reg_count 为 1 表示单寄存器操作,大于 1 表示该地址支持连续的多个寄存器读写。通常在初学阶段把所有变量都定义为 Uint16,这样最简单。如果以后遇到 32 位变量,需要拆成两个连续寄存器,并把高 16 位和低 16 位映射到相邻地址,否则主机读出来的数值是乱的。
这里要提醒:Modbus 地址的语义是「协议层地址」而非「物理地址」,当主机使用功能码 06 写单个寄存器时,报文里携带的地址就是 reg_addr。如果寄存器表里有空洞,主机访问到空洞地址时从机应该返回异常码 0x02(非法数据地址),而不是默默忽略,否则主机会认为从机卡死。
4.2 功能码 03:读保持寄存器的响应拼装
功能码 03 是最常用的读保持寄存器命令。主机发来的请求帧格式是:从机地址 + 03 + 起始地址高字节 + 起始地址低字节 + 读取数量高字节 + 读取数量低字节 + CRC 低字节 + CRC 高字节。从机响应帧是把数据字节数、寄存器数据依次拼在后面,再重新算一遍 CRC。
拼装时要注意寄存器的数据在响应帧里是高字节在前、低字节在后,和 CRC 的低字节在前正好相反。很多初学者把数据字节的字节序也按 CRC 的方式处理,结果上位机能解析出寄存器个数,但每个值都变成 0x1234 和 0x3412 颠倒。
Uint16 modbus_handle_03(Uint16 start_addr, Uint16 reg_count, Uint16 *resp_buf) { Uint16 i; Uint16 resp_idx = 0; // 地址越界检查 if (start_addr + reg_count > TABLE_SIZE) { return 0x02; // 异常码,非法数据地址 } resp_buf[resp_idx++] = reg_count * 2; // 数据字节数 for (i = 0; i < reg_count; i++) { resp_buf[resp_idx++] = (*reg_table[start_addr + i].reg_ptr) >> 8; resp_buf[resp_idx++] = (*reg_table[start_addr + i].reg_ptr) & 0xFF; } return resp_idx; // 返回数据区长度 }注意这个函数假设 reg_table 是按地址顺序排列的,所以可以直接用 start_addr 做下标。如果表里有空洞或者乱序,这里必须改为线性查找。初学阶段把表按顺序排好,可以省去很多麻烦。
返回的 resp_idx 是数据部分长度,调用者把从机地址、功能码 03 放在 resp_buf 前面,再计算整个响应帧的 CRC 追加到尾部,然后调用发送函数。计算 CRC 的顺序问题在第 3 章已经讲过,响应帧的 CRC 同样是低字节在前。
4.3 功能码 06 与 16:写单个和写多个寄存器的边界
功能码 06 写单个寄存器比较简单,请求帧只有地址和数值两个有效数据。功能码 16(十进制,对应十六进制 0x10)写多个寄存器,报文头里多了一个字节数,后面跟着实际数据。写多个寄存器时最容易踩的坑是字节数与寄存器数量的换算:如果写 3 个寄存器,数据字节数是 6,但主机报文中把字节数填成 3,从机如果按 3 去解析,会把第二个寄存器的数据拆开,后面全部错位。
Uint16 modbus_handle_16(Uint16 start_addr, Uint16 reg_count, Uint16 *data_buf) { Uint16 i; if (reg_count == 0 || reg_count > MAX_WRITE_REGS) { return 0x03; // 非法数据值 } for (i = 0; i < reg_count; i++) { *reg_table[start_addr + i].reg_ptr = data_buf[i]; } // 响应帧:功能码 16 需要原样返回起始地址和寄存器数量 return 0; }MAX_WRITE_REGS 应该根据寄存器表实际长度设定,一般不要超过 125,否则报文长度过大,在 RS485 低速链路上容易出问题。这里的 data_buf 在调用之前已经从接收缓冲区里解析好了,注意字节序处理。主机发送写多个寄存器时,数据部分也是高字节在前、低字节在后,从机要还原成 Uint16 后再存进寄存器变量。
还有一个约定细节:功能码 16 执行成功后,从机返回的响应帧只包含从机地址、功能码、起始地址和寄存器数量,不包含任何数据字节。有些上位机软件如果按功能码 03 的格式去解析 16 的响应,会多读两个字节导致 CRC 错乱,这是主机侧问题,但从机侧也要严格准守,不要多回任何字节。
4.4 RS485 方向切换:发送完成中断里拉电平的时序
RS485 半双工收发,方向切换是 Modbus 从机最容易出错的地方。理想时序是:发送前拉高方向脚,发送完最后一个字节后拉低方向脚。但「发送完」必须用发送完成中断标志来判断,而不是发送缓冲空标志。
28335 的 SCI 有两个中断:SCITXINT(发送缓冲空中断)和 SCITXINT 对应的 TXRDY 标志。TXRDY 为 1 表示 SCITXBUF 已经空了,但此时最后一位还在移位寄存器里往外“吐”,如果这时立刻把方向脚拉低,最后一个字节的尾巴会被硬生生截断,总线上出现一个残缺的字节,从机回复给你就是 CRC 错或者干脆没有响应。
// SCI 发送中断处理 interrupt void scia_tx_isr(void) { // 发送完响应帧全部字节后,拉低方向脚进入接收状态 if (tx_idx >= tx_len) { GpioDataRegs.GPACLEAR.bit.GPIO12 = 1; // 方向脚拉低,切换为接收 SciaRegs.SCICTL1.bit.TXINTENA = 0; // 关闭发送中断 GpioDataRegs.GPASET.bit.GPIO13 = 1; // 使能接收 mb_state = MB_IDLE; tx_len = 0; return; } // 还有字节未发完,继续发送 SciaRegs.SCITXBUF = tx_buf[tx_idx++]; }正确做法是发送中断里逐字节发送,直到最后一个字节写进 SCITXBUF 后,仍然保留方向脚为发送状态,等下一次发送中断触发,此时才代表移位寄存器也空了,再拉低方向脚。上面的代码通过 tx_idx 是否等于 tx_len 判断是否还有字节要发,如果没有多余字节,说明最后一字节已经进入发送流程完毕,可以切换方向。
写代码时还要注意:SCITXBUF 是 16 位寄存器,但只用了低 8 位,写入时强制类型转换为 Uint16,不要直接赋 8 位整型,虽然编译器会帮你转,但显式写出能避免警告。
GPACLEAR 和 GPASET 是 28335 快速置位/清零寄存器,可以避免读-改-写引起的竞争。如果你用的 485 模块是自动方向切换型,不走 GPIO 控制,那这部分代码可以剔除;但绝大多数国产 485 模块都需要方向脚,保留这段逻辑通用性更强。
5. 避坑清单:常见问题与排查
5.1 串口调试助手能收到乱码,但 Modbus Poll 报超时
现象:用 USB 转 485 直接连 28335,串口调试助手打开后能看到一些乱码字符,但上位机 Modbus Poll 一读就报超时。
原因:大概率是波特率或者帧格式不匹配。串口调试助手能看到乱码说明物理链路是通的,收发双方能够传输数据,但波特率不一致或数据位/校验位/停止位配置不一致时,收到的字节就会错乱,直接显示成乱码。
解决:先把串口调试助手的参数设置成 9600、8、N、1,确认跟代码配置一致。然后在调试助手里手动发送一帧 03 功能码报文,比如从机地址 01、功能码 03、起始地址 00 00、数量 00 01,CRC 用工具算好,看看回包是否正确。如果回包也乱,用示波器或逻辑分析仪抓 SCI TX 引脚波形,数一数一个字节的位数,判断到底是波特率分频错了还是 SCI 初始化没有退出复位。
5.2 发送完不回包,方向脚电平一直拉高
现象:从机收到主机请求后,主循环里看到状态已经切到发送态,但总线上就是没有响应帧,用万用表量方向脚一直是高电平。
原因:TX 中断没有正确触发,或者发送完成后没有拉低方向脚,方向脚卡死在发送状态,响应帧发不出去,也接收不到后续请求。
解决:确认 PIE 中断使能和 CPU 中断使能都写在主程序里,不要只在初始化里配外设。重点检查 SCICTL1 的 TXINTENA 位和 PIEIER9.8 是否置位。另外,发送中断里如果最后一字节发出后立即关 TXINTENA,但 SCICTL1 的 SWRESET 没有操作,可能造成中断标志未清,下一次进入时会异常。建议在方向脚拉低后加一个小的延时,再用示波器确认电平变化。
5.3 功能码 03 能读,06 写完上位机读回来是旧值
现象:主机用 03 读寄存器一切正常,用 06 写单个寄存器后,再读回来还是原来的值,好像写操作没生效。
原因:寄存器表映射错误。写单寄存器时从地址取值后直接修改了 reg_table 对应项的指针内容,但主机读到的是另一个地址。常见错误是没有把地址转换成表下标,比如把 0x0001 当成数组下标去访问 reg_table[1],但表格第一个元素是地址 0x0000,第二个才是 0x0001,按照协议地址和数组下标一一对应,逻辑上应该没错,但实际工程里容易在收发缓冲区解析阶段搞混。
解决:在功能码 06 处理函数里加一个打印,把 start_addr 和写入值通过串口调试助手输出,看主机发过来的值和最终写入到哪个指针。也可以用内存窗口直接在 CCS 里观察 reg_table 对应变量的地址,确保实际操作的就是主机想写的那个变量。如果地址表需要偏移,默认从 0 开始,不要自己加 0x40000 之类的偏移,除非上位机软件约定如此。
5.4 CRC 校验总是失败,但抓包看字节都对
现象:用逻辑分析仪抓总线上数据,帧内容看起来完全正确,CRC 计算也按标准算法实现,但总返回校验失败。
原因:CRC 的字节序问题。Modbus 协议规定 CRC 低字节在前,但很多从网上抄来的代码返回的 crc 变量本身是标准的大端顺序,发送前没有交换高低字节,导致接收端验证时把高低顺序颠倒的 CRC 当成有效值,校验根本无法通过。
解决:在发送函数尾部,把计算出的 crc 值拆成低字节和高字节,按低字节先发送的顺序追加到报文末尾。接收校验时,先把接收到的两个 CRC 字节按高字节在前、低字节在后还原成 Uint16,再重新计算帧内 CRC 对比。这里的一个检验技巧:拿一帧已知正确的报文,比如地址 01、功能码 03、起始地址 00 00、数量 00 01,用在线 CRC 计算工具算出 CRC 应该是 84 0A,如果代码里算出来是 0A 84,说明字节序反了。
5.5 上电后第一次响应正常,第二次开始乱
现象:从机上电后第一次读响应是正确的,连续发几帧就出现回包内容错乱,甚至完全无响应,断电重启又正常。
原因:接收缓冲区没有清零,或者帧超时状态没有完整复位。收完一帧并处理完后,rcv_cnt 没有归零,下一帧数据叠加在老数据后面,帧长度越来越长,CRC 自然越算越错。
解决:在每次帧处理完成的出口处,必须同时清零 rcv_cnt、rcv_timeout_cnt,并将状态机切回 MB_IDLE。特别注意不要只在功能码处理成功时才复位,异常帧和 CRC 错误也必须走同一个复位路径。可以抽一个函数专门做接收复位,所有 return 之前都调用一次,避免漏分支。
6. 用 Modbus Poll 验证例程:从单帧到连续轮询的关键参数
Modbus Poll 是验证这套例程最顺手的工具,免费版足够用。打开软件后先切到 Serial 模式,串口选 USB 转 485 对应的 COM 口,波特率 9600,数据位 8、无校验、1 停止位,从站地址设成 1,功能码选 03 保持寄存器,起始地址填 0,数量填 3,然后点连接并开始轮询。
如果一切正常,你会看到三个寄存器值实时变化。这个时候把轮询时间设置为 500ms 或 1s,观察一段时间确认无超时、无 CRC 错误。如果是向主站方向测试,把功能码切到 16 写多个寄存器,往 0x0001 写入一个新速度值,再用 03 读回确认,整个读写环就通了。
这套例程里比较关键的一个技巧是:Modbus Poll 的地址显示方式默认从 0 显示为 40001,这是 Modbus 的行业惯例,对应协议地址 0x0000。所以你在 Poll 里看到的 40001 对应代码里的 reg_addr 0x0000,40002 对应 0x0001,不要以为地址差了一万个,然后在程序里硬加偏移,那就把算法和协议概念搞混了。从那以后我每次搭 Modbus 从机,都强制自己先按协议地址列表核对一遍寄存器映射表,再给上位机配地址。如果你也准备把这套代码用在自己的板子上,最好先把寄存器数量限制在一个合理的范围,跑通了 03 和 16 再往外扩;希望这套例程的思路能帮你在 28335 上少走点弯路,直接抄作业,把精力留在功能逻辑而不是协议地狱里。
本文还有配套的精品资源,点击获取