
做工业设备的上位机通信开发十个项目里有八个绕不开Modbus。最近在帮客户处理一台设备的主控升级板子核心用的是AVP28335这颗芯片和TI的TMS320F28335引脚兼容、指令兼容替换起来基本无痛。项目里需要让它作为Modbus从站通过RS-485和触摸屏、组态软件通信于是我就把整套Modbus Slave RTU-485的源码和调试过程整理了一遍。这篇文章就是这次实践的完整记录核心内容包括AVP28335上SCI串口的初始化、485收发切换时序、Modbus RTU协议栈的状态机设计、CRC16校验、功能码03/06/16的实现以及我实际调试中踩过的一堆坑。如果你的项目正好也需要给28335或者AVP28335这类兼容DSP加上Modbus从站功能这篇文章可以直接拿来当参考。为什么专门把源码和注释单独拿出来讲因为很多工程师手里有现成的28335例程但例程大多是GPIO点灯、ADC采样、PWM输出这类真正能直接用在Modbus从站上、带完整中文注释的SCI通信代码并不算多。而Modbus从站看起来简单实际跑起来却有不少细节485方向脚什么时候拉高、什么时候拉低收帧超时怎么判断CRC到底怎么发寄存器地址怎么映射……这些点没处理好设备挂在总线上就是时好时坏。下面我从方案设计开始一步步拆解。1. 项目背景与整体方案设计1.1 为什么用AVP28335做Modbus从站先说说选型。AVP28335在硬件上兼容TMS320F28335主频150MHz自带浮点运算单元原本用28335的板子基本可以无缝切换。Modbus从站本身对主频要求不高一片8位单片机都能跑为什么用DSP来做因为这个项目里主控不只是处理Modbus还要跑电机控制算法、采样、故障保护Modbus只是它的一个对外通信接口。选AVP28335纯粹是因为主控整体方案定了SCI串口是现成的外设不用额外加一颗MCU转通信硬件成本省一块是一块。这种“大马拉小车”的做法在工业设备里其实很常见。一套控制器本来就要用DSP处理核心算法顺便把通信功能做进去省掉一颗通信协处理器既降低了BOM成本也减少了板上器件数量。Modbus从站功能对DSP来说占用资源极少跑起来绰绰余。另外国产兼容芯片在供货和价格上确实有优势。有些项目对成本敏感或者对进口芯片的交期有顾虑用兼容型号就能把风险降下来。我这次用的AVP28335在指令集和外设寄存器上跟原厂芯片基本一致开发环境还是CCS原来的工程文件大部分能直接用。1.2 RTU和ASCII两种模式怎么选Modbus协议有两种串行传输模式ASCII和RTU。ASCII模式用可打印字符表示数据每个字节拆成两个十六进制字符发送中间还有起始符和结束符人眼看着直观调试方便但同样的数据量需要两倍的字节来传效率低。RTU模式是纯二进制传输每8位数据直接发出去帧紧凑、效率高工业现场绝大多数设备用的都是RTU。我这个项目选RTU原因有三。第一现场总线上数据量不算小触摸屏要轮询几十个寄存器用ASCII模式帧太长轮询周期会被拉得很长画面刷新跟不上。第二RTU模式在DSP上实现起来并不复杂CRC校验用查表法算一次也就几十微秒性能完全不是问题。第三RTU是行业默认选项和PLC、组态软件、触摸屏对接时双方配成RTU 8N1几乎不会出兼容性问题。1.3 整体软件架构中断接收加主循环处理Modbus从站的软件结构我建议分成三层来设计不要把所有逻辑都塞进中断里。物理层SCI串口外设加RS-485收发芯片负责把TTL电平转成485差分信号。链路层负责收帧、判断帧结束、解析地址、计算CRC、组装响应帧。应用层处理具体的功能码读写寄存器生成异常响应。这三层对应到代码里就是串口接收中断、定时器超时判断、主循环协议处理。接收中断只在字节到达时把数据放进缓冲区并刷新超时计数不做过多的协议判断主循环检测到一帧完整数据后再进入协议解析和响应流程。这样做的最大好处是中断里的事情足够少不容易出现“正在处理协议时又来一个新字节”的竞争问题。为什么不用纯轮询接收我曾经在别的项目里试过主循环轮询接收结果波特率稍高或者主循环里算法耗时稍长就丢字节。SCI硬件有FIFO能缓冲16个字节但算法代码一跑几百微秒波特率115200下每个字节才87微秒轮询很容易错过。中断接收是唯一可靠的选择。2. 硬件设计与底层初始化2.1 485接口电路设计要点RS-485通信需要一颗收发芯片我用的是MAX3485SP3485也可以两者引脚完全兼容。这类芯片是3.3V供电的正好和28335的IO电平匹配不需要额外的电平转换。电路上关键就是几个引脚RO接收输出接DSP的SCIRXDA引脚。DI发送输入接DSP的SCITXDA引脚。RE接收使能低有效和DE发送使能高有效并在一起用一个GPIO控制。A和B接485总线A接AB接B不能接反。这里有个常见的坑RE和DE如果分开接两个GPIO软件上就要操心“接收状态”和“发送状态”的切换稍不注意就会出现同时收发或者都不收发的状态。把它们并在一起用一根GPIO控制逻辑就简单了GPIO拉高就是发送模式拉低就是接收模式。我项目里用的是GPIO34当然这只是我当时板子的分配你完全可以按自己板子来。另外有两个细节需要注意。总线两端要各接一个120欧终端电阻如果设备是总线中间节点终端电阻可不接但如果是末端节点就必须接否则信号反射会导致误码。A和B线上还可以加一个偏置电阻网络保证总线空闲时电平是确定的避免接收器输出乱跳。不过偏置电阻会影响带载能力节点多的时候要仔细算最简单稳妥的做法是只在主站和末端从站加偏置。2.2 SCI模块初始化与波特率计算28335的SCI模块就是一个增强型UART配置步骤固定使能时钟配置GPIO复用为SCI功能设置帧格式设置波特率使能FIFO和中断最后复位并启动收发。下面这段是我工程里实际用的SCI-A初始化函数注释写得很详细。void SCI_Init(void) { EALLOW; // 1. 使能SCI-A的时钟对应外设时钟控制寄存器PCLKCR0 SysCtrlRegs.PCLKCR0.bit.SCIAENCLK 1; // 2. 把GPIO28配置为SCITXDAGPIO29配置为SCIRXDA // 具体是哪个GPIO要看板子原理图这里以开发板默认为例 GpioCtrlRegs.GPAMUX1.bit.GPIO28 1; // GPIO28 - SCITXDA GpioCtrlRegs.GPAMUX1.bit.GPIO29 1; // GPIO29 - SCIRXDA EDIS; // 3. 配置帧格式8位数据、无校验、1位停止位即8N1 SciaRegs.SCICCR.all 0x07; // bit6 SCICHAR 111 - 8位数据 // bit5 ADDRIDLE 0 - 空闲线模式不用地址位 // bit4 LOOPBKENA 0 - 禁止回环 // bit3 PARITY 0 - 奇偶校验位为0未使能校验 // bit2 PARITYENA 0 - 禁止奇偶校验 // bit1 STOPBITS 0 - 1位停止位 // bit0 SCICHAR 1 - 与bit6配合构成111 // 4. 使能发送和接收 SciaRegs.SCICTL1.all 0x03; // bit5 TXENA 1 - 使能发送 // bit4 RXENA 1 - 使能接收 // bit0 SWRESET 0稍后统一置1启动 // 5. 设置波特率9600 bps // 公式BRR LSPCLK / (波特率 * 8) - 1 // 假设LSPCLK 37.5MHz150MHz系统时钟4分频 // 9600BRR 37500000 / (9600 * 8) - 1 487.28取487 // 实际波特率 37500000 / (8 * (4871)) 9605.2误差0.05%完全可用 SciaRegs.SCIHBAUD 0x0000; // 高16位为0 SciaRegs.SCILBAUD 487; // 低16位为487 // 6. FIFO配置使能FIFO接收中断阈值设为1即收到1个字节就触发中断 SciaRegs.SCIFFTX.all 0xC028; // bit15 SCIRST 1 - 释放SCI复位开始工作 // bit14 SCIFFENA 1 - 使能FIFO增强功能 // bit13 TXFIFORESET 1 - 复位发送FIFO // bit12 TXFIFOINTENA 1 - 使能发送FIFO中断 // bit7 SCIFFTX 1 - 不清除发送FIFO // bit0-4 TXFFIL 0x8 - 发送FIFO阈值这里不常用 SciaRegs.SCIFFRX.all 0x2021; // bit15 RXFIFORESET 1 - 复位接收FIFO // bit13 RXFIFOINTENA 1 - 使能接收FIFO中断 // bit8-0 RXFFIL 0x1 - 接收FIFO阈值1字节收到一字节立刻进中断 // 7. 使能接收FIFO中断。28335的中断要经过PIE模块这里打开PIE第9组第1个中断 PieCtrlRegs.PIEIER9.bit.INTx1 1; IER | M_INT9; EINT; ERTM; // 使能实时中断 // 8. 软件复位释放SCI开始工作 SciaRegs.SCICTL1.all 0x23; // 同样TXENA/RXENA有效SWRESET1 SciaRegs.SCIFFTX.bit.TXFIFORESET 1; SciaRegs.SCIFFRX.bit.RXFIFORESET 1; }波特率这里我多说一句。很多人在28335上配串口直接抄例程里的波特率寄存器值但换一颗芯片或者改了系统时钟分频就把通信配置搞崩了。最可靠的办法就是按公式自己算一遍BRR LSPCLK / (波特率 * 8) - 1。比如LSPCLK是37.5MHz9600波特率算出来487.28取整487实际波特率和理论值的误差只有0.05%远小于UART允许的2%误差稳得很。如果算出来BRR特别接近整数边界就要考虑换一个分频或者调整系统时钟把误差压到1%以内。2.3 485方向切换的时序控制485是半双工总线同一时刻只能发或者只能收所以收发方向切换是Modbus从站软件里最关键的时序点。我的做法是用一个GPIO控制DE/RE发送前拉高发送完成后拉低回到接收状态。GPIO初始化很简单就不展开代码了但切换时序必须讲清楚。发送前拉高DE这个是常规操作但发送完成后什么时候拉低这里有讲究。如果SCI的发送数据寄存器写入了最后一个字节就立刻把DE拉低那么最后一位数据其实还在移位寄存器里往外送马上拉低会把帧尾截断远端设备就会报CRC错误或者直接丢弃整帧。正确做法是等发送移位寄存器彻底空掉再拉低DE。具体实现有两种。第一种是查询方式写完最后一个字节后循环等待SCICTL2.bit.TXEMPTY变成1确认移位寄存器和发送FIFO都空再加一点小延时再拉低DE。第二种是固定延时方式根据当前波特率算出一个字符时间写完后延时1.5到2个字符时间再拉低。比如9600波特率下一个字符按10位算大约1.04ms延时2ms就足够保险115200波特率下一个字符大约87us延时150us以上就行。我实际项目里用的是查询TXEMPTY加固定小延时的方式void RS485_SendBytes(uint8_t *buf, uint16_t len) { uint16_t i; GpioDataRegs.GPBSET.bit.GPIO34 1; // DE拉高切换到发送模式 for (i 0; i len; i) { while (SciaRegs.SCIFFTX.bit.TXFFST 16); // 发送FIFO满则等待 SciaRegs.SCITXBUF buf[i]; // 写入发送数据寄存器 } // 等待最后一个字节彻底送完 while (SciaRegs.SCICTL2.bit.TXEMPTY 0); // 再补一个小子节时间的延时确保485芯片完全把差分信号发完 DELAY_US(50); // 具体延时按波特率调整9600建议200us以上 GpioDataRegs.GPBCLEAR.bit.GPIO34 1; // DE拉低回到接收模式 }注意上面的DELAY_US是个粗略延时如果你用的是不同频率的DSP要重新校准延时时间。实际上这个50us的延时在9600波特率下单字符时间是1ms左右显得不够我调试时发现如果查询TXEMPTY返回后立刻拉低DE个别从站会丢尾巴后来把延时调到200us问题消失。我在代码注释里也标注了这个值要根据波特率调整别照抄。3. Modbus RTU协议核心实现3.1 报文格式与状态机设计Modbus RTU的报文格式非常固定一个完整请求帧由地址码、功能码、数据和CRC16组成。从站收到一帧后先看地址是不是自己的再看CRC对不对然后解析功能码和数据区最后组织响应帧发回去。以读保持寄存器功能码03为例字段长度说明地址码1字节从站地址我项目里设置的是0x01功能码1字节0x03表示读保持寄存器起始地址2字节高字节在前如0x0000表示从0号寄存器开始读寄存器数量2字节高字节在前如0x000A表示读10个寄存器CRC162字节低字节在前覆盖前面所有字节从站的协议处理我建议用状态机实现状态不要多三个足够空闲状态没有数据总线静默。接收状态正在接收一帧数据每来一个字节就刷新超时计数器。处理状态一帧接收完毕CRC和地址都校验通过进入功能码处理然后把响应发出去。状态机的好处是逻辑清晰主循环里不会出现“边收边处理”的混乱情况。中断只负责把字节塞进缓冲区什么时候算一帧结束靠的是定时器超时判断。3.2 超时判断与3.5字符时间Modbus RTU协议规定帧与帧之间要保留至少3.5个字符时间的静默间隔。也就是说从站判断一帧是否接收完毕靠的是“超过3.5个字符时间没有收到下一个字节”而不是靠帧尾标志。因为RTU没有帧结束符只能靠时间间隔来分帧。3.5个字符时间怎么算以9600波特率8N1为例一个字符包含1个起始位、8个数据位、1个停止位共10位单字符时间约1.04ms3.5个字符时间约3.65ms。在38400波特率下大约0.91ms在115200波特率下大约0.30ms。我的实现方式是在串口接收中断里每收到一个字节就把超时计数清零同时把缓冲区索引加一在一个周期较小的定时器中断里对超时计数累加如果累加值超过了3.5字符时间对应的阈值就认为一帧结束。以9600波特率为例定时器周期设为0.5ms那么超时阈值设为8也就是4ms比3.65ms稍微宽松一点又远小于帧间隔不会把下一帧误并进来。// 定时器中断周期0.5ms interrupt void Timer0_ISR(void) { if (rs485_rx_len 0) // 接收缓冲区里有数据说明正在收帧 { if (rs485_rx_timeout 50) // 上限50ms防止异常卡死 { rs485_rx_timeout; } if (rs485_rx_timeout 8) // 4ms没有新字节认为帧结束 { rs485_frame_ready 1; // 置帧完成标志让主循环处理 } } }这里有一个容易踩的坑定时器周期不能比3.5字符时间还长。比如你用一个10ms的定时器来做超时判断9600波特率下3.5字符时间是3.65ms你还没来得及累计下一帧的数据可能已经来了导致帧粘连。所以波特率越高定时器周期必须设得越小或者在代码里根据波特率动态调整阈值。3.3 CRC16校验的实现与细节CRC16是Modbus RTU的差错校验核心覆盖从地址码到数据区结束的所有字节。Modbus用的CRC16算法多项式是0x8005初始值是0xFFFF但在实际计算中高位反转为0xA001每次处理一个字节时对当前CRC的低位进行判断。CRC实现有两种方式按位计算和查表法。按位计算代码短、不用建表适合寄存器紧张的场景查表法速度快适合主循环里频繁调用的场景。28335主频150MHz按位计算一个几百字节的帧也就是几十微秒完全够用。不过为了将来移植方便我这里给出查表法的完整实现附建表代码和查表代码。// 生成CRC16高位表多项式用0xA001 void crc16_init_table(uint16_t *table) { uint16_t i, j, crc; for (i 0; i 256; i) { crc i; for (j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } table[i] crc; } } // 查表计算CRC16 uint16_t crc16_modbus(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; while (len--) { i (crc ^ *buf) 0xFF; crc (crc 8) ^ crc_table[i]; } return crc; }细节提醒Modbus RTU发送CRC时低字节在前高字节在后。所以如果算出的crc变量里低8位是0x12、高8位是0x34发送顺序是先发0x12再发0x34。很多新手在这里栽跟头校验明明算对了但发送顺序反了从站一直报CRC错误。调试时可以用串口助手抓包对比一下标准报文。3.4 寄存器映射与地址规划Modbus从站说到底就是对寄存器区进行读写。28335内部没有专门的“Modbus寄存器”我们就是在内存里划一块数组来模拟保持寄存器。工程里我定义了一个结构体数组#define REG_HOLDING_NUM 100 // 保持寄存器数量 uint16_t reg_holding[REG_HOLDING_NUM];寄存器地址和Modbus协议地址的映射规则要提前定好。比如Modbus协议里请求“起始地址0x0000数量10”那就对应reg_holding[0]到reg_holding[9]。如果设备里有多个参数可以在代码里用宏或者枚举把每个参数的寄存器地址定义出来#define REG_DEV_ADDR 0 // 设备地址参数 #define REG_BAUD_RATE 1 // 波特率参数 #define REG_RUN_STATUS 2 // 运行状态 #define REG_TEMPERATURE 10 // 温度值这里有个原则读写寄存器前要检查地址是否越界。比如读请求说“起始地址0x0010数量100”那就超出reg_holding数组范围了必须返回异常响应0x02非法数据地址而不是直接操作越界内存。这个问题在工业现场非常常见上位机配置错误或者没有按设备文档操作一上来就报非法地址如果没有做边界检查轻则数据乱掉重则程序跑飞喂狗复位。3.5 功能码03/06/16的实现流程项目里主要实现了三个功能码覆盖了绝大多数应用场景读保持寄存器03、写单个寄存器06、写多个寄存器16。功能码03的处理流程是解析起始地址和寄存器数量检查地址范围然后组织响应帧响应帧的数据区格式是“字节数”加“寄存器数据”每个寄存器占2字节高字节在前。具体代码在下一节展示。功能码06写单个寄存器比较简单请求数据区就是“寄存器地址”加“要写入的值”各2字节。从站处理完写操作后把请求帧原样返回给主站表示写入成功。注意Modbus从站在收到写请求后必须先执行写操作再返回响应。不要先回响应再写因为主站收到响应就认为写入完成如果你还没来得及写主站那边可能已经读取新值了。功能码16写多个寄存器请求帧格式是起始地址2字节、寄存器数量2字节、字节数1字节然后跟着寄存器数据每个寄存器2字节最后是CRC。实现时要注意字节数和实际数据的对应关系如果寄存器数量是N字节数必须是2N。收到请求后先校验字节数是否符合然后再写寄存器最后响应帧是“起始地址加寄存器数量”原样返回。异常响应也不能省。如果主站发来一个从站不支持的功能码从站要返回异常码0x01。具体做法是把响应帧的功能码最高位置1即原功能码加上0x80然后数据区带一个异常码字节。比如收到一个功能码0x04从站不支持响应帧就是“地址 0x84 0x01 CRC”。这个细节很多第一次写的工程师会漏掉主站那边就会一直等待或者超时报错。4. 源码框架与关键代码注释4.1 主循环与协议状态机主循环的框架很简洁就是检查帧完成标志然后进入协议处理void main(void) { // 初始化系统时钟、GPIO、SCI、定时器等 System_Init(); SCI_Init(); Timer0_Init(); for (;;) { if (rs485_frame_ready) { rs485_frame_ready 0; Modbus_ProcessFrame(); // 收到完整一帧处理协议 } // 其他任务比如电机控制、故障检测等 User_Task(); } }这个结构的好处是Modbus处理不会阻塞主循环太久。Modbus_ProcessFrame里最多就是校验CRC、读写寄存器、通过RS485_SendBytes发响应耗时很短对主循环里其他实时任务影响很小。4.2 串口接收中断与缓冲区SCI接收中断里做的事情非常简单从SCIRXBUF读出来一个字节放进接收缓冲区刷新超时计数防止缓冲区溢出。// SCI-A接收FIFO中断服务函数 interrupt void SCIA_RX_ISR(void) { uint16_t data; while (SciaRegs.SCIFFRX.bit.RXFFST 0) // FIFO里还有数据就继续读 { data SciaRegs.SCIRXBUF 0xFF; // 读取一个字节 if (rs485_rx_len RS485_RX_BUF_SIZE) { rs485_rx_buf[rs485_rx_len] data; // 放入缓冲区 rs485_rx_timeout 0; // 刷新超时计数关键 } else { // 缓冲区满说明帧异常或超时阈值设得太大清空后重新接收 rs485_rx_len 0; } } // 清除中断标志PIEACK确认位 PieCtrlRegs.PIEACK.all PIEACK_GROUP9; }这段代码最关键的一行是rs485_rx_timeout 0。每收到一个字节超时计数就必须清零。如果漏了这句定时器那边觉得“已经超时”了一帧数据还没收完就被丢给协议处理结果就是永远只能收到半截帧。实际使用中发现SCI接收FIFO的阈值设成1字节是最稳妥的。虽然在高速率下中断频率高一些但28335中断响应足够快不会丢数据。如果设成4字节或者8字节才触发中断那么短帧比如只有6字节的控制命令就会一直憋在FIFO里等不到触发点直到超时处理逻辑介入增加了不少复杂性。4.3 帧校验与地址过滤Modbus_ProcessFrame的第一步是对收到的缓冲区内容做完整校验。校验分四个层次按顺序来void Modbus_ProcessFrame(void) { uint16_t crc_recv, crc_calc; uint16_t rx_len rs485_rx_len; // 1. 帧长度最小为4字节地址功能码CRC2字节 if (rx_len 4) { rs485_rx_len 0; return; } // 2. 地址过滤只处理发给自己的帧 if (rs485_rx_buf[0] ! RS485_SLAVE_ADDR rs485_rx_buf[0] ! 0xFF) // 0xFF是广播地址广播帧不回响应 { rs485_rx_len 0; return; } // 3. CRC校验收到的最后两字节是发送端算好的CRC crc_recv rs485_rx_buf[rx_len - 2] | (rs485_rx_buf[rx_len - 1] 8); crc_calc crc16_modbus(rs485_rx_buf, rx_len - 2); // 注意只算到CRC之前 if (crc_recv ! crc_calc) { rs485_rx_len 0; return; // CRC错误直接丢弃不回异常响应 } // 4. 走到这里说明帧有效按功能码分发 Modbus_HandleFunction(rs485_rx_buf[1]); rs485_rx_len 0; }注意广播地址0xFFModbus规定广播帧对所有从站有效但从站不能对广播帧发响应否则总线上多个从站同时响应会冲突。所以代码里地址判断时允许0xFF进来但后续不进入响应发送流程。CRC校验这里有个逻辑细节收到的CRC是低字节在前的两字节而函数crc16_modbus返回的crc是一个16位变量把crc低8位放到buf[rx_len-2]高8位放到buf[rx_len-1]正好和发送端顺序一致。计算接收CRC时调用crc16_modbus时传的len是rx_len - 2也就是说计算范围不包含CRC自身这是最容易写错的地方写少了会多算两个字节进去写多了又会漏算。4.4 功能码处理与响应发送功能码处理函数通过switch分支完成每个case内部按协议规则组织响应帧最后统一调用RS485_SendBytes发送。以功能码03为例void Modbus_HandleFunction(uint8_t func) { uint16_t start_addr, reg_num, crc; uint16_t i; switch (func) { case 0x03: // 读保持寄存器 // 请求地址 03 起始地址(2) 寄存器数量(2) CRC(2) if (rs485_rx_len ! 8) { Modbus_SendException(0x03, 0x03); // 长度异常返回非法数据值 return; } start_addr (rs485_rx_buf[2] 8) | rs485_rx_buf[3]; reg_num (rs485_rx_buf[4] 8) | rs485_rx_buf[5]; // 地址越界检查 if (start_addr reg_num REG_HOLDING_NUM || reg_num 0) { Modbus_SendException(0x03, 0x02); // 非法数据地址 return; } // 组装响应帧地址 03 字节数 寄存器数据 rs485_tx_buf[0] RS485_SLAVE_ADDR; rs485_tx_buf[1] 0x03; rs485_tx_buf[2] reg_num * 2; // 数据字节数 寄存器数量 * 2 for (i 0; i reg_num; i) { rs485_tx_buf[3 i*2] (reg_holding[start_addr i] 8) 0xFF; rs485_tx_buf[4 i*2] reg_holding[start_addr i] 0xFF; } crc crc16_modbus(rs485_tx_buf, 3 reg_num*2); rs485_tx_buf[3 reg_num*2] crc 0xFF; rs485_tx_buf[4 reg_num*2] (crc 8) 0xFF; RS485_SendBytes(rs485_tx_buf, 5 reg_num*2); break; case 0x06: // 写单个寄存器 // 请求地址 06 寄存器地址(2) 寄存器值(2) CRC(2)共8字节 if (rs485_rx_len ! 8) { Modbus_SendException(0x06, 0x03); return; } start_addr (rs485_rx_buf[2] 8) | rs485_rx_buf[3]; if (start_addr REG_HOLDING_NUM) { Modbus_SendException(0x06, 0x02); return; } reg_holding[start_addr] (rs485_rx_buf[4] 8) | rs485_rx_buf[5]; // 响应原样返回请求帧 RS485_SendBytes(rs485_rx_buf, 8); break; case 0x10: // 写多个寄存器 // 请求地址 10 起始地址(2) 寄存器数量(2) 字节数(1) 数据... CRC(2) start_addr (rs485_rx_buf[2] 8) | rs485_rx_buf[3]; reg_num (rs485_rx_buf[4] 8) | rs485_rx_buf[5]; if (rs485_rx_len ! 9 reg_num*2 || (start_addr reg_num REG_HOLDING_NUM)) { Modbus_SendException(0x10, 0x03); return; } for (i 0; i reg_num; i) { reg_holding[start_addr i] (rs485_rx_buf[7 i*2] 8) | rs485_rx_buf[8 i*2]; } // 响应地址 10 起始地址 数量 CRC共8字节 rs485_tx_buf[0] RS485_SLAVE_ADDR; rs485_tx_buf[1] 0x10; rs485_tx_buf[2] (start_addr 8) 0xFF; rs485_tx_buf[3] start_addr 0xFF; rs485_tx_buf[4] (reg_num 8) 0xFF; rs485_tx_buf[5] reg_num 0xFF; crc crc16_modbus(rs485_tx_buf, 6); rs485_tx_buf[6] crc 0xFF; rs485_tx_buf[7] (crc 8) 0xFF; RS485_SendBytes(rs485_tx_buf, 8); break; default: // 不支持的功能码返回异常0x01 Modbus_SendException(func, 0x01); break; } }异常响应发送函数也很简单把功能码最高位置1再附一个异常码然后计算CRC发出去void Modbus_SendException(uint8_t func, uint8_t exc_code) { uint16_t crc; rs485_tx_buf[0] RS485_SLAVE_ADDR; rs485_tx_buf[1] func | 0x80; // 功能码最高位置1 rs485_tx_buf[2] exc_code; crc crc16_modbus(rs485_tx_buf, 3); rs485_tx_buf[3] crc 0xFF; rs485_tx_buf[4] (crc 8) 0xFF; RS485_SendBytes(rs485_tx_buf, 5); }这里有一个经验想特别说一下功能码16的响应帧长度是8字节不是把收到的数据原样回传。如果在这里偷懒直接回传整个请求帧主站那边虽然也能通过但有些严格的Modbus测试工具会报“响应长度错误”。协议文档怎么写代码就怎么写不要自作聪明。5. 调试过程与常见问题排查5.1 调试工具和流程调试这套Modbus从站我的工具链很简单一个USB转RS-485的调试器、一个串口助手软件、一个Modbus调试工具Modbus Poll再加一个示波器。调试步骤按顺序来千万别跳步。第一步先用串口助手手动发报文。把USB转485接到AVP28335的A、B线上串口助手选择波特率9600、8N1手动输入RTU格式的十六进制报文比如读寄存器01 03 00 00 00 0A C5 CD看从站能不能返回正确响应。这一步能快速验证物理链路和底层收发的正确性。第二步用Modbus Poll这类工具做自动化轮询。Modbus Poll可以按固定周期发送读请求自动解析响应还能统计错误帧数量。如果串口助手手动发没问题但Modbus Poll轮询一会儿就报错那多半是帧间隔、485切换时序或者干扰问题。第三步如果还是有问题上示波器。重点看两个信号A、B之间的差分波形以及DE控制脚的电平变化。DE脚从低拉高再落回低应该和发送的数据帧在时间上对齐。如果数据还没发完DE就落下来了波形上能看到帧尾部被截断的毛刺。5.2 常见问题速查表现象可能原因解决办法从站完全无响应从站地址不匹配检查RS485_SLAVE_ADDR是否和主站配置一致从站完全无响应485的A、B线接反对调A、B试试或者用示波器看波形极性从站完全无响应DE没有拉高数据发送不出去用示波器看GPIO控制脚的波形确认发送期间为高电平从站完全无响应终端电阻缺失导致信号反射严重总线两端接120欧终端电阻响应偶尔错误485切换时DE拉低太快帧尾被截断等待TXEMPTY后再延时200us再拉低DE响应帧CRC错误发送CRC字节高低顺序反了确认先发低字节再发高字节收到请求但总报超时超时阈值比3.5字符时间大太多按波特率重新计算阈值9600下约4ms上位机显示部分寄存器读取失败读请求越界返回异常0x02检查寄存器数量上限确保数组不越界5.3 现场干扰与长期稳定性的处理Modbus从站实验室调通只是第一步工业现场才是真正的考验。我在客户现场遇到过最典型的问题是实验室用一台触摸屏通信一切正常到了现场接上长距离电缆偶尔会出现通信中断重启一下又好。排查下来问题基本集中在三个方面。第一是地电位差。485总线是差分信号两个节点之间如果地电位差过大共模电压超出收发芯片的承受范围就会导致通信异常。解决方法是使用带隔离的485模块或者在总线上做好单点接地。有些现场两个设备分别接在不同的开关电源上地电位差最大能到几十伏不隔离肯定扛不住。第二是电缆屏蔽层接地。485电缆如果有屏蔽层应该单端接地不能两端都接地否则会形成地环路反而引入更多干扰。第三是软件上的容错。即使硬件做好隔离软件也得考虑总线被干扰后的恢复能力。我的做法是接收超时计数上面已经有个50ms上限防止某帧数据只收到一半就永远卡住协议处理完不管结果如何接收缓冲区都会清空重新开始再加上看门狗定时器万一程序跑飞也能自动复位。这套机制跑下来现场连续运行一个月没有再出现过需要人工复位的通信故障。5.4 调试时容易被忽略的细节最后补充几个我在调试中踩过、但代码和电路图上都看不出来的细节。第一个是关于Modbus从站的响应时序。从站收到请求帧后不能拖太久才发响应。Modbus协议规定从站应该在主站超时时间内响应一般主站设置几百毫秒到几秒不等但如果从站响应慢到几百毫秒以上有些主站会认为从站没收到而不断重发导致总线上拥塞。28335主频高处理一帧数据不到1ms不会成为瓶颈但如果你的主循环里跑了一些耗时很长的算法任务记得把Modbus协议处理放在高优先级的位置或者直接放到定时器中断里统一处理只留一个标志给主循环做其他事。第二个关于SCI的FIFO。设计时不要以为FIFO越大越好关键是让“帧结束”的判定时机和协议要求匹配。我实际测试下来接收FIFO阈值设成1字节配合定时器超时判断效果最稳定。如果阈值设得太高短帧要凑满才触发中断是能接收但超时判断就变得模糊了。第三个是发送和接收共用缓冲区的问题。我的代码里接收缓冲区rs485_rx_buf和发送缓冲区rs485_tx_buf是分开的。不要图省事共用一个数组否则发送响应时用Modbus_ProcessFrame里的数据还没处理完接收中断就把缓冲区内容覆盖了就会出现“响应帧发出去一堆乱码”的怪象。缓冲区分开后发送期间即使收到新数据也只写接收缓冲区两者互不干扰。写在最后这次把Modbus Slave RTU-485在AVP28335上的实现完整梳理了一遍。说句实话Modbus从站本身不算难难的是485方向切换、超时判断、CRC发送顺序这些“软件之外”的细节。我调试过程中最大的体会是不要急着往功能码上堆代码先把物理层的收发打通、把串口助手手动收发报文跑顺再上协议栈。物理层不稳定协议层怎么改都是白搭。还有一个经验可以分享在你自己的开发板上调通之后建议用两根较长的双绞线把两个设备拉到十几米外再测一轮很多在桌面上看不出问题拉长距离后就暴露出来了。这个项目做完以后这套代码我还在持续维护后面打算把广播写功能加上再扩展几个常用的功能码比如读输入寄存器04、读写多个寄存器23。如果你也在做类似的工作欢迎一起交流在实际调试中遇到的那些奇怪问题。