简介:在工业自动化场景中,Modbus协议常用于设备间的可靠通信。针对TI TMS320F28335 DSP,这份资源提供了一个轻量级Modbus从站程序包,压缩包仅含1个C源文件,大小约60KB,面向需要快速实现串口通信的嵌入式开发者,可帮助理解并落地Modbus从站功能。程序代码覆盖了从站核心机制:CRC校验、寄存器地址映射、UART中断服务、响应帧构建,以及通信超时与CRC失败等异常处理,在RTU/ASCII模式下均能依据主站请求正确读写数据,适合直接移植到工业控制或DSP数据采集项目中。对于熟悉DSP但缺少Modbus协议经验的开发者,这份紧凑的代码尤其有参考价值,既展示串口外设配置与协议栈的状态机思路,也提供了调试主从站联调时的排错路径;已有414人浏览学习,说明其具备一定的实践参考意义。开发者可基于此程序继续扩展网络通信或优化中断响应,快速完成从协议学习到产品开发的过渡。
1. 让 DSP28335 挂进 Modbus 总线,先拆清从站源码的骨架
接手过一台老设备,控制板用 TMS320F28335,上位机是组态软件,现场要改数据采集方案。改上位机不现实,最省事的做法就是让 DSP 在 RS485 总线上老老实实当 Modbus 从站,主站按 03/06 功能码读寄存器、写参数。Modbus_Slver.rar 这个包里最有价值的东西不是那一堆文档,而是 Modbus_Slver.c——一个可以直接放进 CCS 工程、跑在 28335 上、用 SCI 串口实现的 RTU 从站源码。
初学者拿到这个文件最容易懵的地方有三个:F28335 手册里根本找不到 UART 这个外设名,它叫 SCI;RTU 帧的切分不靠起始符,要靠定时器量帧间隙;CRC16 的字节序能把老手绕进去。这篇文章就顺着这三条线把 Modbus_Slver.c 拆开讲,最后落到用 Modbus Poll 联调验证的具体方法,代码拿过来改改就能用。
2. SCI 外设初始化与 Modbus 物理层参数
2.1 F28335 的 SCI 和标准 UART 差在哪
Modbus RTU 是串行帧协议,物理层用 RS232 或 RS485,DSP 侧对应的外设就是 SCI。F28335 有 SCI-A 和 SCI-B 两路,引脚分别是 GPIO16/17 和 GPIO18/19。这里要特别注意:TI 的文档体系里管它叫 Serial Communications Interface,但功能上和 STM32 的 USART 完全等价,没有任何特殊协议在里面。
初始化 SCI 的寄存器顺序是有讲究的。SCICCR 配置字符长度、停止位和校验位;SCICTL1 里的 SW RESET 位(bit5)在配置过程中必须保持 0,所有参数写完后置 1 让外设退出复位。很多人直接参考 STM32 的库函数写法,先把外设使能再配参数,在 28335 上就会碰到首帧数据错乱的问题。正确顺序是清零复位位、配置全部寄存器、最后再置位。
void Sci_Init(void) { EALLOW; // SCI-A 的 TX/RX 引脚复用到 GPIO16/GPIO17 GpioCtrlRegs.GPAMUX1.bit.GPIO16 = 1; // SCITXDA GpioCtrlRegs.GPAMUX1.bit.GPIO17 = 1; // SCIRXDA EDIS; SciaRegs.SCICCR.all = 0x0007; // 8 数据位 + 1 停止位 + 无校验 SciaRegs.SCICTL1.all = 0x0003; // 使能 RX 和 TX,SW RESET 暂为 0 SciaRegs.SCICTL2.all = 0x0003; // 使能接收中断和发送就绪中断 SciaRegs.SCIHBAUD = 0x0000; // BRR 高 8 位 SciaRegs.SCILBAUD = 0x0028; // BRR 低 8 位 = 40,对应 115200 SciaRegs.SCICTL1.all = 0x0023; // SW RESET 置 1,外设开始工作 SciaRegs.SCIFFTX.all = 0xE021; // 使能 FIFO,RX 触发级别设为 1 PieCtrlRegs.PIEIER9.bit.INTx1 = 1; // SCI-A 接收中断挂在 PIE 第 9 组 IER |= M_INT9; }SCICCR 写入 0x0007 表示 8 位数据、1 位停止位、无校验,这是 Modbus RTU 最常用的 8N1 帧格式。SCIHBAUD 和 SCILBAUD 合起来是 16 位波特率寄存器 BRR,计算公式是 BRR = LSPCLK / (波特率 × 8) - 1,默认 LSPCLK 为 37.5MHz 时,115200 对应的 BRR 约等于 40。SCICTL1 最后写入 0x0023,就是把 SW RESET 置 1 的同时保持 RX/TX 使能,这一步漏了串口永远不收发。
2.2 波特率计算及各档位对照
Modbus 从站的波特率必须和主站严格一致,否则主站报超时、从站进错误中断。实际工程里 9600、19200、115200 是最常见的三档,下面这张表按 LSPCLK = 37.5MHz、8N1 帧格式算出 BRR 值,可以直接填寄存器。
| 波特率 | BRR 十进制 | SCIHBAUD | SCILBAUD | 实际波特率 | 误差 |
|---|---|---|---|---|---|
| 9600 | 487 | 0x01 | 0xE7 | 9605 | 0.05% |
| 19200 | 243 | 0x00 | 0xF3 | 19211 | 0.06% |
| 38400 | 121 | 0x00 | 0x79 | 38110 | 0.75% |
| 57600 | 80 | 0x00 | 0x50 | 57870 | 0.47% |
| 115200 | 40 | 0x00 | 0x28 | 115135 | 0.05% |
误差在 1% 以内串口通信没有问题,但要注意 LSPCLK 是可以被改变的。如果工程里修改了 SysCtrlRegs.LOSPCP 的值,波特率必须按实际 LSPCLK 重新算,直接抄表会出乱码。一个快速验证方法:初始化完 SCI 后,在 TX 引脚上发 0x55,用示波器看波形,0x55 的二进制是 01010101,每个 bit 的宽度就是 1/波特率。
2.3 RS485 方向控制的时序陷阱
现场总线绝大多数是 RS485 半双工,DSP 的 SCI 本身只负责串行数据,方向控制要靠外部收发器 MAX3485 的 DE/RE 引脚。这两个引脚通常短接在一起,由普通 GPIO 控制。方向切换的时序非常关键:发送前拉高 DE,发送完成后必须等最后一个字节完全移出移位寄存器再拉低,否则帧尾被截断,主站 CRC 校验一定失败。
我一般不用延时函数去猜发送完成时间,而是轮询 SCICTL1 的 TXRDY 位,再补一个字节时间的余量。常见做法是这样:
void Sci_SendFrame(Uint8 *buf, Uint16 len) { GpioDataRegs.GPBSET.bit.GPIO34 = 1; // DE 拉高,进入发送模式 for (Uint16 i = 0; i < len; i++) { while (SciaRegs.SCICTL1.bit.TXRDY == 0); // 等待发送缓冲就绪 SciaRegs.SCITXBUF = buf[i]; } while (SciaRegs.SCICTL1.bit.TXRDY == 0); // 等最后字节进移位寄存器 DELAY_US(20); // 补齐一字节时间,然后拉低 DE GpioDataRegs.GPBCLEAR.bit.GPIO34 = 1; }GPIO34 只是个例子,实际工程按板卡原理图改引脚号。DELAY_US(20) 在 115200 波特率下覆盖一个字节约 87us,20us 可能不够,稳妥一点写到 100us。这个延时大了不影响通信,顶多让总线空置一会;小了就会偶发丢帧,而且是用逻辑分析仪才查得出来的那种偶发。
3. CRC16 查表法与 RTU 帧重组
3.1 为什么 RTU 要按字节间隙切帧
Modbus RTU 没有帧头帧尾标记,协议靠两种时间间隔区分帧:帧内两个相邻字符间隔不能超过 1.5 个字符时间,超过就认为本帧结束;帧与帧之间必须静默 3.5 个字符时间。所以从站接收逻辑的骨架是:串口中断逐字节收,同时喂一个定时器;定时器溢出时认为一帧完整到达,交给解析函数处理。
115200 波特率下 3.5 个字符时间约 0.3ms,但这个理论值在 DSP 上不能直接用。SCI 的 FIFO 会把多个字节积压后一次性触发中断,中断响应延迟加上代码执行时间,容易把正常帧拆成两段。我一般把帧超时定在 5ms 到 10ms,Modbus 标准允许从站把帧间隙放宽到几个毫秒级,只要主站不发连续背靠背帧就不会有问题。9600 波特率下 3.5 字符接近 4ms,超时设 10ms 更稳。
3.2 CRC16 的逐位算法和查表优化
Modbus RTU 的 CRC16 多项式是 0x8005 的反转形式,实际计算时按低位移出,用 0xA001 参与异或。初值固定为 0xFFFF,逐字节处理完整个帧(地址、功能码、数据区),得到的 16 位结果在帧里按低字节在前发送。这个字节序是最容易踩的坑,后面联调章节会专门验证。
Uint16 cal_crc(Uint8 *buf, Uint16 len) { Uint16 crc = 0xFFFF; for (Uint16 i = 0; i < len; i++) { crc ^= buf[i]; for (Uint8 j = 0; j < 8; j++) { crc = (crc & 1) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } } return crc; }逐位算法适合理解原理,Modbus_Slver.c 里如果是查表版,通常会用 256 项的整表或 16 项的半字节表。半字节表把一次异或和 8 次移位变成两次查表,速度提升明显:
static const Uint16 crc_tab[16] = { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440 }; Uint16 cal_crc_tab(Uint8 *buf, Uint16 len) { Uint16 crc = 0xFFFF; for (Uint16 i = 0; i < len; i++) { crc ^= buf[i]; crc = (crc >> 4) ^ crc_tab[crc & 0x0F]; crc = (crc >> 4) ^ crc_tab[crc & 0x0F]; } return crc; }查表版的逻辑和逐位版完全等价,区别只在计算速度。28335 跑 150MHz,逐位版本处理一帧也就几微秒,查表的意义更多是代码移植到更低主频 MCU 时不用改逻辑。要注意表里的 0xC0C1 那一列顺序不能写错,写反了 CRC 结果全是错的,而且接收端和发送端错得一模一样,单机测试发现不了。
3.3 接收状态机:中断只收字节,主循环拆帧
从站接收不用复杂的有限状态机,一个数组加一个长度计数器就够了。接收中断里只做两件事:把 SCIRXBUF 的数据压进 rx_buf,重置帧超时计数器。主循环里检测超时计数器归零后,把 rx_buf 里攒下的数据当作完整一帧处理。这样中断服务例程非常短,不会出现处理协议时下一字节已经覆盖缓冲区的风险。
#define MODBUS_RX_MAX 64 volatile Uint8 rx_buf[MODBUS_RX_MAX]; volatile Uint16 rx_len = 0; volatile Uint16 frame_timeout = 0; interrupt void scia_rx_isr(void) { Uint16 data = SciaRegs.SCIRXBUF.all; // 读数据,同时清中断标志 if (rx_len < MODBUS_RX_MAX) { rx_buf[rx_len++] = (Uint8)(data & 0xFF); frame_timeout = FRAME_TIMEOUT_MS; // 帧超时设为 5ms } PieCtrlRegs.PIEACK.all = PIEACK_GROUP9; }frame_timeout 的递减放在 CPU Timer0 的中断服务里,每个 1ms tick 减一次,减到 0 就表示超时。FRAME_TIMEOUT_MS 宏在 115200 下设 5、在 9600 下设 10,配合 Timer0 初始化 ConfigCpuTimer(&CpuTimer0, 150, 1000) 就能用。rx_buf 加上 volatile 修饰,是因为它同时在中断和主循环两个上下文里被访问,不加的话编译器优化可能把主循环里的值缓存到寄存器,导致一直读到旧数据。
4. 寄存器映射与功能码解析
4.1 从站地址和寄存器布局的设计
Modbus 从站程序的核心是寄存器映射表。主站访问的不是 DSP 的物理内存,而是从站暴露出去的一组 16 位寄存器。Modbus_Slver.c 里通常会维护一个 Uint16 数组,比如 modbus_regs[64],每个元素对应一个保持寄存器地址。这个数组就是主站和 DSP 应用逻辑之间的接口。
| 寄存器地址 | 用途 | 读写属性 | 说明 |
|---|---|---|---|
| 0x0000-0x000F | 测量值区 | 只读 | ADC 采样结果、计算值,由中断更新 |
| 0x0100-0x010F | 参数区 | 读写 | 控制参数、阈值,上位机可修改 |
| 0x0200 | 命令字 | 只写 | 写 1 启动,写 2 停止 |
| 0x0201 | 状态字 | 只读 | 运行状态、故障标志 |
地址规划的原则是把「只读的数据」和「可写的参数」分开,避免主站误写测量值。从站地址 slave_addr 一般定义成宏或配置文件里的常量,范围 1 到 247,0 是广播地址。广播请求只执行写入操作,不回复任何响应帧,这一点很多人在代码里会漏掉。
4.2 03/06/16 三个功能码的分流处理
Modbus RTU 从站必须处理的功能码按主站需求来定,最基础的一组是 03 读保持寄存器、06 写单个寄存器、16(0x10)写多个寄存器。主循环拿到完整帧后,先做地址匹配和 CRC16 校验,再按功能码分流。下面这段代码是 Modbus_Slver.c 里最核心的解析骨架:
void modbus_poll(void) { if (rx_len == 0) return; if (rx_buf[0] != slave_addr && rx_buf[0] != 0) { rx_len = 0; return; // 地址不匹配,直接丢弃 } Uint16 crc_recv = rx_buf[rx_len-1] << 8 | rx_buf[rx_len-2]; // 低字节在前 if (cal_crc_tab((Uint8 *)rx_buf, rx_len-2) != crc_recv) { rx_len = 0; return; // CRC 错误,不响应 } Uint8 func = rx_buf[1]; switch (func) { case 0x03: handle_read_holding(rx_buf, rx_len); break; case 0x06: handle_write_single(rx_buf, rx_len); break; case 0x10: handle_write_multi(rx_buf, rx_len); break; default: build_exception(0x01); break; // 非法功能码 } rx_len = 0; }CRC 接收端的还原顺序是 rx_buf[rx_len-1] 左移 8 位异或 rx_buf[rx_len-2],因为 RTU 帧里 CRC 低字节在前。03 功能码的请求帧格式是「地址 + 03 + 起始地址高 + 起始地址低 + 寄存器数高 + 寄存器数低 + CRC」,共 8 字节;响应帧是「地址 + 03 + 字节数 + 数据 + CRC」。数据区每个寄存器占 2 字节,高字节在前,这是 Modbus 协议的大端规定。
void handle_read_holding(Uint8 *req, Uint16 len) { Uint16 start = req[2] << 8 | req[3]; // 起始寄存器地址 Uint16 cnt = req[4] << 8 | req[5]; // 寄存器数量 if (start + cnt > MODBUS_REG_NUM) { // 地址越界 build_exception(0x02); return; } Uint8 resp[128]; Uint16 rlen = 0; resp[rlen++] = slave_addr; resp[rlen++] = 0x03; resp[rlen++] = (Uint8)(cnt * 2); // 数据字节数 for (Uint16 i = 0; i < cnt; i++) { resp[rlen++] = (Uint8)(modbus_regs[start + i] >> 8); resp[rlen++] = (Uint8)(modbus_regs[start + i] & 0xFF); } Uint16 crc = cal_crc_tab(resp, rlen); resp[rlen++] = (Uint8)(crc & 0xFF); // CRC 低字节在前 resp[rlen++] = (Uint8)(crc >> 8); Sci_SendFrame(resp, rlen); }06 写单个寄存器的处理要简单一些,请求帧 8 字节,把寄存器地址和值的组合还原后写入 modbus_regs,然后原样回显请求帧作为应答。16 写多个寄存器比 06 多一个字节计数字段,请求格式是「地址 + 10 + 起始地址 + 数量 + 字节数 + 数据 + CRC」,应答时只回显起始地址和数量,不带数据区。这三个功能码覆盖了绝大多数工业上位机的读写需求,Modbus_Slver.c 如果只保留了这些,已经可以作为产品从站的基础。
4.3 异常响应及寄存器地址偏移问题
协议解析时最常见的错误是寄存器地址偏移对不上。Modbus 协议报文里的寄存器地址从 0x0000 开始,但很多组态软件和 PLC 的呈现地址从 40001 开始,比如报文里地址 0x0000 在触摸屏上显示为 40001。这两种表达之间差了 1,代码里不用做任何转换,只需要在上位机配置时把起始地址设对。我见过有人为了对齐显示屏,在 DSP 代码里把寄存器索引整体减 1,结果报文地址 0x0000 时数组越界,这种适配应该放在上位机侧解决。
异常响应按标准格式构造:功能码的最高位置 1,即 0x83、0x86、0x90,后面跟一个异常码。最常见的三个异常码是 01 非法功能、02 非法数据地址、03 非法数据值。比如主站请求读 0x00F0 起始的 10 个寄存器,但本地只有 64 个寄存器,回 02;主站写参数时值超过允许范围,回 03;主站发了功能码 01,回 01。
4.4 主循环轮询和中断的互斥处理
协议解析全部放在主循环里做,不是在中断里回帧。F28335 的中断服务执行耗时操作时,SCI 的 FIFO 可能被后续字节填满,最简单的解决方式是主循环每轮调用一次 modbus_poll(),解析期间关闭 SCI 接收中断,处理完再打开。寄存器数组的更新也有讲究:测量值区由 ADC 中断周期性写入,主循环读取时最好做临界保护。
DINT; // 关总中断,保护寄存器数组读取 for (Uint16 i = 0; i < MODBUS_REG_NUM; i++) { temp_regs[i] = modbus_regs[i]; } EINT; // 恢复中断如果测量值区的数据是 Uint16 单次读写,28335 的 32 位总线上一次访问就能完成,临界保护可以省略。但如果寄存器里放了 32 位累加值、浮点转换结果这类需要拆两次访问的数据,就必须防止主循环读到「高字节是新的、低字节是旧的」这种撕裂数据。工程上更简单的办法是把这类数据先在中断里拼装成一个原子变量,再往寄存器区同步。
5. 用 Modbus Poll 联调 28335 从站:三阶段验证与字节序检查
5.1 阶段一:先用 Modbus Slave 验证链路和工具配置
拿到 Modbus_Slver.c 别急着烧录 DSP,可以先用 Modbus Slave 工具验证整个串口链路。把 USB-485 转接头的 A/B 线分别接到 MAX3485 的 A/B,PC 端打开 Modbus Slave,新建一个从站窗口,设从站地址 1、功能码 03、起始地址 0,连线方式选串口,波特率 115200、8N1。再用 Modbus Poll 开一个主站窗口指向同一个串口,如果能读到数据,说明 USB 转接头的接线和工具配置没问题,问题范围被压缩到 DSP 侧。
5.2 阶段二:DSP 用已知值回环验证 CRC 和字节序
在 DSP 代码里给 modbus_regs[0] 赋一个固定值 0xA55A,主站连续读 10 个寄存器。Modbus Poll 显示区里如果看到 A5 5A,说明寄存器值、CRC16、帧切分全部正确。如果看到 5A A5,说明工具的字序设置和代码的大端输出不一致,在 Modbus Poll 的 Read/Write Definition 里切换字序即可,不用改 DSP 代码。如果看到 CRC 错误计数一直涨,优先查发送方向 CRC 的存储顺序是不是低字节在前。
5.3 阶段三:写操作和广播地址的边界检查
03 读通了再测 06 写单个寄存器。在 Modbus Poll 里切换功能码 06,往 0x0100 写一个值,切回 03 读 0x0100,确认写入生效。如果写请求没有响应,大概率是程序把广播地址 0 也当成普通从站地址处理了。广播请求只执行写入、不回复响应帧,Modbus Slave 工具在主站模式下发出的广播请求,从站回帧会导致总线冲突,这是从站开发里比较隐蔽的逻辑错误。
最后收一个实用技巧:Modbus Poll 连接失败时,用它的监控窗口观察原始报文,看主站发出的请求帧和从站响应的十六进制字节。如果请求帧里 CRC 是 2 字节且前后顺序符合低字节在前,但 DSP 没有任何响应,把波特率降到 9600 试一次,排除传输线质量在高波特率下的信号完整性问题。这个方法在排查不稳定丢帧时比换线、换转接头都直接有效。
本文还有配套的精品资源,点击获取