
做嵌入式的朋友应该都有同感通讯协议这东西接触得越深越会发现真正在工业现场跑得最稳的往往不是那些看起来很新的协议而是MODBUS这种“老家伙”。我最早接触MODBUS是在一个环境监测项目上STM32采集温湿度、气压和PM2.5数据通过RS485总线传给上位机。当时想着MODBUS这么成熟应该随便一调就能通结果从帧解析到CRC校验再到RS485的方向切换前前后后折腾了快一周。现在回头看那周的坑踩得值。MODBUS RTU在STM32上的实现几乎涵盖了嵌入式通讯的所有基本功串口中断处理、定时器超时判断、状态机设计、CRC校验、半双工总线切换、异常应答。把这条链路吃透了再去看CAN、EtherCAT、PROFINET这些协议你会发现很多思路是相通的。这篇文章我不打算把MODBUS协议文档从头到尾翻译一遍而是以一个真实项目为背景讲清楚三件事MODBUS各变体怎么选、STM32上怎么把RTU从站程序写稳、以及调试过程中那些文档里不会写的坑。适合刚接触STM32通讯开发的初学者也适合已经会点串口但没系统搞过MODBUS的同学。1. 先从“为什么老设备还在用MODBUS”说起1.1 40年协议为何还没退休MODBUS是Modicon公司在1979年提出的最初就是为了让PLC和人机界面之间能通讯。它的设计目标非常朴实报文格式简单实现门槛低运行在串行链路上就能工作。到今天工业现场还有大量传感器、仪表、变频器、温控器支持MODBUS原因不是它技术多先进而是没有人有动力替换它——设备厂家改协议意味着改固件、改手册、改售后培训上位机软件那边也得跟着改这成本远高于继续兼容MODBUS。所以你会发现一个很有趣的现象很多新设备明明有以太网口支持MODBUS TCP但它依然保留了一路RS485的MODBUS RTU接口。为什么因为现场的PLC还是老系统老系统只认RTU。这就决定了你作为嵌入式开发者跑不掉MODBUS。1.2 MODBUS的三大变体RTU、ASCII、TCP我该学哪个很多人刚开始看MODBUS资料会被绕晕因为MODBUS不是单一协议而是一个家族。最常见的有三个变体变体物理层数据编码帧间隔判断典型场景MODBUS RTURS232/RS485二进制紧凑3.5字符静默时间工业现场传感器、PLC、仪表MODBUS ASCIIRS232/RS485ASCII字符可读特定起始/结束符老式设备、调试场景MODBUS TCP以太网二进制无CRC报文长度域上位机、边缘网关、现代PLC我的建议很直接优先学RTU有时间再看TCP。理由有三点。第一RTU在工业现场覆盖率最高几乎所有支持MODBUS的设备都支持RTU但并不是所有都支持TCP。第二RTU的帧解析涉及CRC校验、超时判断、字节间间隔处理这些东西是通用技能TCP版因为有了TCP本身的可靠性反而把CRC去掉了解析逻辑简化不少你学会了RTU再回头写TCP就是降维打击。第三ASCII版基本只在老设备或者对数据可读性有特殊要求的场合出现新项目选型不会优先选它。1.3 STM32做从站还是主站各自的技术侧重点MODBUS网络里有主站和从站两个角色。主站发起请求从站响应请求。在STM32的实际项目中两种角色都有应用场景STM32做主站典型场景是STM32作为采集器周期性轮询多个传感器或电表。这种模式下技术难点在于轮询调度逻辑——什么时候该发哪条命令、从站超时了怎么办、多个从站的优先级如何安排。主站代码写起来不复杂但工程上要处理非常多超时重试的边界情况。STM32做从站典型场景是STM32作为设备端响应上位机或PLC的读写请求。这种模式下技术难点在于实时性——你必须在主站发来请求后很快给出响应否则主站会判超时。从站的代码核心是中断驱动的帧接收和快速响应。说实话我推荐初学者先写从站。为什么因为从站的流程更固定收帧→校验→解析→执行→回复整个链路短容易闭环调试。等你从站写明白了再写主站去轮询它正好用你自己的从站代码做测试目标比一开始用Modbus Poll模拟从站直观得多。2. 硬件链路STM32接RS485的正确姿势2.1 从USART到RS485中间还差一个收发器很多新手会被“USART转RS485”这句话误导以为STM32的USART引脚直接就能输出RS485电平。实际上STM32的USART引脚输出的是TTL电平逻辑高是3.3V逻辑低是0V。而RS485是差分信号靠A、B两线之间的电压差表达逻辑A-B大于某个阈值表示逻辑1A-B小于某个阈值表示逻辑0。所以中间必须加一个RS485收发器芯片常见的有MAX34853.3V供电、SP3485、MAX4855V供电。收发器内部集成了驱动器把TTL转成差分和接收器把差分转回TTL你只需要把STM32的USART_TX接到收发器的DI引脚USART_RX接到RO引脚再控制一个DE/RE引脚决定当前是发送还是接收。这里有个非常关键的细节DE和RE在多数芯片上是独立的但实际使用中几乎总是把它们接在一起控制。MAX3485的RE是低电平有效DE是高电平有效如果你把两个引脚短接后接到STM32的一个GPIO上那么这个GPIO输出高电平发送模式输出低电平接收模式。这种接法最简单我所有的项目都这么干。2.2 DE/RE方向控制半双工最容易翻车的地方RS485是半双工总线同一时刻只能有一方在发送。发送方在发送完毕后必须立刻把DE拉低切回接收模式否则总线会被占住其他设备无法应答。这个“发送完毕立刻切换回接收”的操作看起来简单实际翻车率极高。最典型的错误是在主循环里调用串口发送函数后紧接着就拉低DE。但USART发送是“先写入数据寄存器再移位出去”的你调用发送函数只是把数据填进了寄存器并没有真正发完。如果这时候拉低DE数据可能才发了一半总线就被切断了。正确的做法有两种在发送完成中断里切换方向USART发送空TC中断触发时说明数据已经全部移出此刻拉低DE最安全。用DMA发送DMA传输完成回调里切换DMA发送完成意味着最后一个字节已经移入移位寄存器并发送完毕这时候拉低DE同样安全。我实际项目中用的就是“串口空闲中断DMA发送完成回调”这套组合。发送的时候把DE拉高启动DMA传输DMA完成回调里把DE拉低这样方向切换时机天然准确不需要用延时去凑。2.3 隔离与保护工业现场的可靠性设计如果你只是实验室里两块板子对接收发器和两个电阻就够了。但工业现场不一样RS485总线可能长几十米甚至几百米电机启停、变频器都在旁边雷击浪涌随时可能出现。这种情况下硬件上至少要加三样东西终端电阻120Ω接在总线物理末端作用是匹配传输线阻抗减少反射。注意是“物理末端”才接不是每台设备都接。总线中间节点接了反而会加大负载。TVS管或压敏电阻并在A、B线上把浪涌电压钳位住。常见的选型是SMBJ6.0CA这种双向TVS。隔离电源和数字隔离器如果设备供电和总线侧地电位差较大一定要用隔离。常用的方案是ADUM1201加隔离电源模块或者直接用带隔离的RS485模块。我知道很多同学做毕业设计或者学习项目用了MAX3485加一个120R电阻就通了就觉得够了。在实验室环境确实够但你要把这个项目往真实产品上推这几块钱的保护成本一定不能省。我亲眼见过一个客户现场的设备因为RS485总线没有加任何保护变频器一启动通讯板直接烧了一片。3. 软件核心MODBUS RTU从站怎么从零写出来3.1 帧结构拆解地址、功能码、数据和CRCMODBUS RTU一帧数据的结构非常清晰字段长度说明从站地址1字节0x01~0xF70x00是广播地址功能码1字节读/写操作类型数据0~252字节寄存器地址、数据内容等CRC162字节低字节在前高字节在后举个例子上位机要读取从站1的保持寄存器起始地址是0x0000读2个寄存器那么请求帧就是01 03 00 00 00 02 C4 0B01从站地址03功能码读保持寄存器00 00起始寄存器地址16位00 02寄存器数量16位C4 0B前面所有字节的CRC16低字节C4在前高字节0B在后从站收到后应该怎么应答如果正常应答帧的格式是01 03 04 01 02 03 04 ...01从站地址03功能码原样返回04后续数据的字节数2个寄存器×2字节401 02 03 044个字节的寄存器值最后是CRC如果从站发现请求有错误比如读的寄存器地址不存在或者数量超范围需要返回异常帧01 83 02 C0 F101从站地址83功能码最高位置10x03 | 0x8002异常码02表示非法数据地址C0 F1CRC看到这里你会发现MODBUS的帧格式其实就这么点东西真正的复杂度在于怎么从一串连续到达的字节流里正确切分出完整的一帧——这就是后面要说的状态机和帧超时。3.2 CRC16-MODBUS的实现查表法和逐位法对比CRC16-MODBUS的校验计算是RTU实现的核心部分。计算规则是多项式0x8005初始值0xFFFF结果异或0x0000输出时低字节在前。这个名字叫MODBUS的CRC算法和通用的CRC16有很多区别千万别拿标准CRC16的代码硬套。最常见的实现方式有两种查表法和逐位法。逐位法代码简单每字节需要循环8次处理几十个字节的数据在STM32上其实也不算慢适合刚上手时用来理解原理uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc crc 1; } } } return crc; }查表法是预计算一张256项的CRC表每个字节直接查表一次减少8次循环速度快很多。在数据量大或者中断里频繁校验的场合用查表法更稳。查表法的核心代码如下uint16_t crc16_modbus_table(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc_table[(crc ^ *data) 0xFF]; } return crc; }其中crc_table是预生成的256个uint16_t。我在STM32F103上实测过60字节左右的数据帧逐位法大约耗时几十微秒查表法只有它的八分之一对从站响应时间影响不大。所以我的建议是学习时写逐位法理解原理项目里用查表法。还有一个容易忽视的细节是CRC的字节序。MODBUS规定CRC16的低字节先发送。比如计算结果是0x0BC4发送顺序就是C4 0B。如果你是从站收到数据后先收低字节要把两个字节拼回uint16_t时必须做小端拼接不然校验永远不过uint16_t crc_received rx_buffer[frame_len - 1] 8 | rx_buffer[frame_len - 2];注意是低字节在前所以拼回uint16_t时低字节要放到低8位。3.3 状态机解析如何优雅地处理一帧数据串口接收数据通常是一个字节一个字节地进入中断的MODBUS又是无头无尾的变长协议RTU没有起始符和结束符所以你必须自己判断一帧数据从哪里开始、到哪里结束。我的做法是建立一个简单的接收状态机配合空闲中断和字节超时判断。状态机的状态定义如下IDLE空闲状态等待第一个字节RECEIVING接收状态持续往缓冲区存字节FRAME_DONE一帧接收完毕等待主循环处理在IDLE状态下收到任意非零字节就把它当作从站地址存入缓冲区状态切到RECEIVING。在RECEIVING状态下持续接收后续字节并存入缓冲区一旦检测到3.5字符时间没有新字节到来就认为一帧结束状态切到FRAME_DONE。**为什么是3.5字符时间**这是MODBUS RTU规范里定的帧间隔标准。两个连续字节之间的间隔如果超过3.5个字符传输时间接收方就认为一帧数据结束了。如果发送方发完一帧后间隔不足3.5字符时间就又发下一帧接收方可能把两帧当成一帧处理导致解析错误。状态机的好处在于它把“数据的到达”和“数据的处理”解耦了。中断里只负责收字节和判断帧完成主循环里再去做CRC校验、功能码解析、应答和寄存器读写。这样即使主循环处理耗时较长也不会因为丢字节而破坏帧结构。3.4 功能码实现03、06、16这三个最常用MODBUS的功能码很多01、02、03、04、05、06、15、16是基础但实际项目里最常用的就是三个03读保持寄存器、06写单个寄存器、16写多个寄存器。03读保持寄存器是从站最基础的功能。请求帧里带寄存器起始地址和数量从站需要在应答帧里把寄存器的数据依次填进去。需要注意边界检查——如果请求的寄存器地址超出了你定义的范围必须返回异常码02。06写单个寄存器是上位机下置参数最常用的方式。接收方需要解析出寄存器地址和要写入的值写入对应位置后把请求帧原样返回表示写入成功。这个“原样返回”是MODBUS的规矩主站会把收到的响应和请求比对不一致会判错。16写多个寄存器适合批量下置参数比如PID参数整定、多通道阈值设置。请求帧里会携带寄存器起始地址、数量、字节数以及实际数据。数据长度需要做校验字节数必须等于寄存器数量×2否则就是非法请求。这三个功能码实现完后从站的基本功能就通了。后续需要扩展的话01、05这些位的读写逻辑和寄存器几乎一样改改操作位即可。4. 时序与中断3.5字符间隔为什么这么关键4.1 一帧结束的判断逻辑前面说过MODBUS RTU用3.5个字符的静默时间来判断一帧结束。但“3.5个字符”到底是多少毫秒计算方法很简单。一个字符在标准串行帧里包含1个起始位、8个数据位、1个停止位共10个bit没算校验位即使有校验位也是11bit按实际配置来。假设波特率是9600bps那么每bit传输时间是1/9600秒一个字符是10个bit即10/9600≈1.0417ms3.5个字符就是约3.6458ms。波特率字符时间3.5字符时间96001.0417ms3.6458ms192000.5208ms1.8229ms384000.2604ms0.9115ms1152000.0868ms0.3038ms看到没有波特率越高3.5字符时间越短。9600波特率下有3.6ms的裕量去判断帧结束但115200波特率下只有0.3ms。这里有个实践上的矛盾点用单片机来做帧超时判断如果采用“启动一个定时器等超时”的方式在低波特率下还行高波特率下中断响应稍慢就可能误判。所以我更推荐使用字节间间隔超时的方法——每个字节到达时启动/重置定时器定时器溢出时标志帧结束。这样不需要你精确计算“3.5字符时间”的毫秒数只需要保证定时时长略大于3.5字符时间即可留出裕量。4.2 用定时器还是滴答定时器实现帧超时STM32上实现帧超时判断有三种常见方案方案一专门的定时器如TIM基础定时器优点定时独立不依赖SysTick时间精度高可在中断里精确控制。 缺点额外占用一个定时器外设。如果项目里定时器资源紧张可能不够用。方案二用SysTick做软定时器把SysTick配成1ms中断维护一个uint16_t modbus_tick计数每字节到达时把modbus_tick清零。主循环或中断里判断modbus_tick是否超过阈值若是则帧结束。这种方法的特点是通用性很好不占额外定时器资源。但问题是1ms的分辨率在低波特率下够用在高波特率115200上会有些粗糙——因为3.5字符时间只有0.3ms1ms才走一次计数误差太大。方案三字节间间隔直接用串口的空闲中断STM32的USART有一个IDLE中断当串口线上出现一个字节的空闲状态时触发。这个空闲中断天然就适合判断MODBUS帧结束而且无需额外定时器实现也很简单。不过实际使用有一个讲究串口空闲帧时间的默认长度常常是1个字符时间左右对于9600波特率下1ms的空闲就能触发而MODBUS要求3.5字符3.6ms才能算帧结束。这在低波特率下会导致提前切帧。所以一些老工程师会避免仅用IDLE中断。我的做法是IDLE中断辅助DMA接收把IDLE当作帧结束的快速信号同时在DMA传输完成中断里再做一次校验两个条件都能触发帧解析。STM32的HAL库本身提供了HAL_UARTEx_ReceiveToIdle_DMA这种API可以在收到一帧且串口空闲时自动停在DMA接收上配合DMA半满/中断回调来处理非常顺手。4.3 波特率与字符时间的计算实例让我给一个实际计算例子。假设项目用115200波特率8数据位无校验1停止位。字符时间10bit/115200≈86.8μs3.5字符时间≈303.8μs。如果用它来实现DMA接收DMA回调里收到空闲中断后需要立即去拷贝DMA缓冲区并启动下一轮接收那么这个回调的执行时间必须远小于303.8μs。对STM32F103这种72MHz主频的芯片来说一次回调拷贝几十个字节的耗时远低于303.8μs所以没问题。但对于非常低端的芯片或者主频只有8MHz的MCU这就要注意优化了。这个计算不是纸上谈兵我遇到过一个案例某产品从9600波特率升级到115200后通讯偶发失帧排查了很久最后发现问题出在中断优先级——USART中断被低优先级任务打断了导致字节间隔超过了3.5字符时间被误判为帧结束。解决办法是把USART中断优先级提到最高并减少中断服务函数里的处理量。5. 联调与排错那些年我在MODBUS上踩过的坑5.1 配置Modbus Poll/Slave测试的完整流程代码写完后联调是检验成果的关键一步。我推荐用Modbus Poll作为主站测试工具配合STM32从站程序进行验证。具体步骤如下确认串口参数与设备地址USB转RS485接好设备地址设置成1波特率1152008N1和Modbus Poll配置一致。建立连接Modbus Poll选择Serial端口设置COM口号和波特率等。读取保持寄存器选择一个功能码为03的命令起始地址0数量10。观察响应如果配置正确Modbus Poll会显示寄存器值同时右侧会有循环请求。如果通讯失败会显示“Timeout”或者错误码。这里有个小技巧先用回环测试确认链路没问题。把RS485收发器的A接AB接B打开Modbus Poll发送一条读命令发完后把发收两端短路看收到的数据如果回环也收不到说明不是固件问题而是硬件链路问题。回环测试正常后再进入协议联调。Modbus Poll可以设置显示“16进制”模式方便你对照请求帧和应答帧的每一个字节。如果CRC对不上、寄存器地址偏移一位一眼就能看出来。5.2 线接对了通讯还是不正常的三个排查方向通讯不正常时先别急着改代码按下面三个方向排查大多数情况能快速定位。方向一观察物理层电平用示波器或逻辑分析仪挂在A、B线上看波形。RS485在空闲状态时A线应该比B线高约200mV以上。如果A、B线电压差接近0或者反转说明接线反了或者收发器供电有问题。另外要看传输波形是不是方波如果有明显畸变多为终端电阻缺失或阻抗不匹配。方向二检查DE/RE方向切换这是最隐蔽的坑。如果方向切换逻辑出问题现象是上位机能收到请求从站收不到或者从站发了应答但没发完总线上出现半截波形。排查方法用示波器看GPIO方向和A、B线波形的时序关系——发送期间DE应该为高发送完毕后应该立刻变低。如果DE变低前有毛刺或延迟就得查发送完成回调是否被正确触发。方向三校验CRC是否正确模式对了时序也对了但就收不到从站应答多半是CRC拼写或字节序不对。拿一条已知请求帧用Modbus Poll自带的CRC计算器验证一下。常见错误是发送时CRC高低字节顺序反了——低字节在前很多人容易搞反。5.3 错误帧、半帧和粘包异常场景的处理经验MODBUS报文在工业总线上传输时经常会出现一些想象不到的情况。最常见的三个异常是错误帧、半帧、粘包。错误帧指的是数据没问题但CRC校验不过。这种帧必须丢弃不能做任何处理。如果CRC错误率很高要怀疑是波特率误差太大或有干扰。STM32的USART通常允许±1%~2%的波特率偏差使用外部晶振、高精度时钟时问题不大但使用内部RC振荡器时如果环境温度变化大误差会放大造成偶发CRC错误。半帧指主站发的数据中途被总线断开或干扰只收到一部分字节。这种半帧没有CRC或长度不对需要在处理逻辑里加长度下限校验比如至少6个字节才可能是合法MODBUS帧否则直接丢弃。粘包指两帧数据连续到达接收方把它们当成一帧。解决粘包的关键就是帧超时判断。只要3.5字符间隔判断准确正常不会粘包。但如果你的系统里用了操作系统且串口中断被更高优先级中断频繁打断导致字节间间隔被拉长那么正常的帧也可能被误判成两帧这时候需要优化中断处理性能或者调高串口中断优先级。还有一个容易被忽视的场景主站发出的请求帧长度其实并不固定。有些从站的程序会固定认为“收到8个字节就是一帧”这种写法在特定的请求下可能是对的但换一个请求类型比如16功能码数据长度变了就会出错。所以代码里一定要先判定功能码再根据功能码去校验后续数据长度不要用固定长度判断。6. 从站程序的工程化组织寄存器表、回调函数和代码分层6.1 用寄存器表统一管理数据MODBUS从站的核心是寄存器。不论是读保持寄存器还是写保持寄存器底层都对应着一块连续的内存区域。最好的做法是定义一个全局寄存器表并实现两个底层函数read_reg(addr)和write_reg(addr, value)。#define HOLDING_REG_NUM 50 uint16_t holding_regs[HOLDING_REG_NUM]; uint16_t read_holding_reg(uint16_t addr) { if (addr HOLDING_REG_NUM) { return holding_regs[addr]; } return 0xFFFF; // 异常地址返回 }这种方式的好处是应用层代码只需要往holding_regs这个数组里读写业务数据MODBUS协议层只负责按地址映射寄存器值。比如一个温度传感器的采集值你让它更新到holding_regs[0]上位机用MODBUS读地址0就能拿到。业务和协议彻底解耦维护起来非常清晰。6.2 功能码分发switch-case和函数指针表当功能码多起来之后别用一堆if-else嵌套而是用分发表或者switch-case清晰处理。我习惯写一个分发函数uint8_t modbus_handle_frame(uint8_t *rx_buf, uint16_t len, uint8_t *tx_buf) { uint8_t addr rx_buf[0]; uint8_t func rx_buf[1]; // 校验地址是否匹配 if (addr ! MODBUS_ADDR addr ! 0x00) { return 0; // 不是发给自己的 } switch (func) { case 0x03: return modbus_read_holding_regs(rx_buf, len, tx_buf); case 0x06: return modbus_write_single_reg(rx_buf, len, tx_buf); case 0x10: return modbus_write_multiple_regs(rx_buf, len, tx_buf); default: return modbus_exception(rx_buf, tx_buf, 0x01); // 非法功能码 } }每个功能码的处理函数只关心自己需要的数据部分。这样即使后续要加01、05、15这些功能码只需要新增一个case和处理函数不会影响原有逻辑。6.3 中断服务函数和主循环的分工很多初学者会把所有处理逻辑放到中断里。这在低速场景下可能能跑但在工业现场绝对不推荐。我坚持的原则是中断里只做接收和置标志主循环里做解析和应答。中断里的代码如下uint8_t rx_buffer[256]; volatile uint16_t rx_len 0; volatile uint8_t frame_done 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t ch USART_ReceiveData(USART1); if (rx_len sizeof(rx_buffer)) { rx_buffer[rx_len] ch; } } }主循环里的判断while (1) { if (frame_done) { frame_done 0; // 拷贝当前帧重置接收缓冲 uint16_t cur_len rx_len; rx_len 0; // 校验CRC - 解析功能码 - 生成应答 respond_to_frame(rx_buffer, cur_len); } // 其他业务逻辑 }这种分工会遇到一个问题如果主循环里有耗时操作从站响应就可能超时。解决办法一是把耗时操作拆成多个小任务轮流执行二是把MODBUS处理函数放到一个优先级较高的任务里甚至可以直接在主循环最前面处理MODBUS保证响应实时性。如果用了RTOS则可以把MODBUS接收和解析放在独立任务中并用信号量通知。6.4 不同从站地址的广播处理与站号过滤一个RS485总线上往往会挂多个MODBUS从站每个从站有一个唯一的地址。STM32从站程序里接收到一帧后要先做地址过滤——只处理发给自己的帧或者地址为0的广播帧。主站发送地址为0的广播帧时所有从站都要执行命令但不做应答。这主要用于同步启动或停止之类的一对多操作。所以代码里要有一个标志如果地址是0x00执行命令但跳过发送应答。另外实际项目中一定要允许从站地址可配置一般通过拨码开关或者写EEPROM实现。原因很简单两个设备如果地址冲突整个总线的通讯就乱了。拨码开关读取逻辑非常简单每个开关对应一个二进制位组合起来就是地址。7. 实测案例基于STM32F103C8T6的MODBUS温湿度监控节点7.1 项目需求与硬件设计去年我做了一个粮库环境监控的DEMO硬件有STM32F103C8T6最小系统板、DHT22温湿度传感器后来换成SHT30、MAX3485收发器、DC-DC隔离电源。功能是传感器每2秒采集一次温湿度存到内部寄存器上位机通过RS485的MODBUS RTU协议读取当前温湿度同时也可以通过16功能码设置温湿度告警阈值。硬件设计上STM32的USART1复用引脚PA9、PA10接MAX3485的DI和ROPA8接DE/RE短接后的方向控制脚。传感器接I2C接口PB6、PB7。我额外在A、B线上加了TVS管和120Ω终端电阻虽然这个DEMO只是桌面测试但顺手把工业级的保护电路画上去了后面如果要改板子直接复用。7.2 从站代码的关键数据结构传感器数据需要映射到MODBUS寄存器表我的寄存器地址规划是寄存器地址内容权限0x0000温度值放大10倍存储只读0x0001湿度值放大10倍存储只读0x0002温度告警上限读写0x0003湿度告警上限读写0x0004设备状态只读温度值为什么要放大10倍存储因为MODBUS寄存器是16位不支持浮点。直接把浮点转成整数比如25.6℃存成256上位机拿到后除以10这是工业现场最常见的做法。也可以在寄存器里用两个字节分别存储整数部分和小数部分但放大法最简单。7.3 实测波形和调试记录联调的时候我用逻辑分析仪挂在A、B线上抓取了一帧完整的收发过程波形上能明显看到上位机发送请求时总线电平跳变是规则的一个数据包约2ms后从站开始应答同样是一个数据包。中间有一段静默时间正好对应从站内部的处理耗时。让我印象最深的是第一次测试时从站收到请求后有时会不回复概率性的。排查过程很痛苦CRC校验是对的功能码解析是对的寄存器地址合理最后用示波器看DE波形发现发送结束时DE拉低的位置滞后了约500μs超过了上位机的超时阈值。原因是我在发送完成后调用了HAL_Delay等待一段固定时间去“确保”发送完毕而这多出来的延时导致DE拉低太晚虽然数据发出去了但上位机从站切换的时间窗口错过了。去掉延时改成用DMA发送完成回调拉低DE之后问题就消失了。这也验证了硬件时序和软件中断配合的重要性。8. 进阶从RTU到TCP的无痛迁移8.1 MODBUS TCP的帧格式差异如果项目往后走需要把STM32通过以太网接入局域网实现MODBUS TCP通讯你会发现它和RTU的区别没有想象中那么大。MODBUS TCP帧格式如下字段长度说明事务标识符2字节用于匹配请求和响应协议标识符2字节固定为0x0000长度2字节后面剩余数据的字节数单元标识符1字节相当于RTU里的从站地址功能码1字节同RTU数据可变和RTU一样最大的区别是TCP版没有CRC16因为TCP协议本身已经保证了传输可靠性。帧结束也不再依赖3.5字符间隔而是靠报文头里的“长度”字段来判断一帧的边界。所以实现思路上有本质变化——不再需要状态机判断帧结束只需要解析头部长度字段再等够这么多字节就完事。8.2 一套业务逻辑复用两端我建议的工程做法是把MODBUS的核心业务逻辑——寄存器读写、功能码分发、异常应答——封装成一组与传输层无关的函数。RTU版和TCP版只是“换了一个传输层接口”。比如RTU版的处理流程是串口收帧→CRC校验→调用modbus_handle_frame()→发送应答。TCP版的处理流程是网口收包→TCP粘包拆包→解析MBAP头→调用modbus_handle_frame()→发送应答。中间的modbus_handle_frame()可以完全复用。这也是为什么我说“学好RTU是基础”。你把RTU从站写熟了协议的业务逻辑已经沉淀在那里往后不管是移植到TCP、CAN总线还是无线LORA核心代码都不用推倒重来。8.3 常见TCP实现中的坑第一个坑是粘包和半包。MODBUS TCP的数据包在TCP流里没有天然边界你必须根据长度字段自己拆包。如果一次recv能读到多个MODBUS TCP帧要循环处理到数据读完如果读取到的数据不足一帧要等下一次收包再拼起来。这就是经典的TCP粘包拆包问题。第二个坑是多客户端接入。RTU是一主多从总线上只有主站发起。但TCP模式下多个上位机可以同时连接同一个从站设备。这种情况下从站内的寄存器访问可能发生并发冲突。解决办法简单粗暴用一个互斥锁保护寄存器读写过程或者干脆要求所有写操作在同一个连接里串行处理。第三个坑是连接超时。如果设备端的TCP服务器一直没有收到数据需要设置一个心跳超时机制超时后主动断开连接否则半开连接会耗尽资源。写在最后的几条经验我个人在实际项目里积累了几条关于MODBUS和STM32通讯的小经验分享出来供参考第一条是无论RTU还是TCP版一定要在代码里加上详细的调试日志打印能力。我一般会把收发的原始帧用HEX格式打印出来打开开关调试关闭开关生产。否则现场出了问题你只能对着示波器和逻辑分析仪发呆很难直接定位到协议层问题。第二条是不要迷信硬件高容量买最贵的芯片。MODBUS RTU这种通讯协议对MCU的资源要求很低哪怕是STM32F103C8T6这种64KB Flash的芯片跑RTU从站加基本业务Flash和RAM绰绰有余。把精力放在代码结构、异常处理和时序稳定性上比买更贵的片子有意义得多。第三条是任何时候都做好协议层的容错。就算你实现很完美也总会遇到网上随便找的Modbus Poll版本、别的厂家写的不规范的从站程序、或者某个现场老工程师改过的上位机。把非法帧一律丢掉、异常应答写得明确、功能码范围严格检查这几点做好了MODBUS通讯在工业现场才会真的“省心”。说实话MODBUS这套协议本身已经四十多年了技术上没有任何玄机。但恰恰是这种“简单”最适合用来培养嵌入式通讯开发的底层能力。从STM32串口收一个字节到完整地处理一帧MODBUS请求再到把它做成一个可以稳定运行的从站节点这一条链路走通了你对中断、时序、状态机、数据链路层的理解会上一个台阶。后面再碰到任何通讯协议都能用这套思路去快速上手。