STM32F407多主站RS485总线仲裁与Modbus RTU实现解析
简介这是基于STM32F407开发的MODBUS RTU多主站源程序面向工业自动化与嵌入式通信方向开发者重点解决在RS-485/RS-232串行链路上同时管理多个主站、避免总线冲突、提升并行处理能力的问题。压缩包约7.32MB共2434个文件以HTML说明文档、JS脚本、C/H源文件以及汇编文件为主辅以PDF手册、工程配置与固件文件既可支撑工程编译与烧录也便于查阅协议实现与调试。目前已有2589人浏览学习。源码实现了USART初始化、MODBUS请求帧与响应帧解析、多主站轮询调度、定时器分时管理及中断服务程序等核心模块并配有清晰的目录结构与工程配置适合需要深入理解MODBUS RTU协议、或在其基础上扩展更多主站与功能模块的开发者直接参考复用。1. 一条RS485串口同时挂两个主机MODBUS RTU 多主站到底在解决什么MODBUS RTU 从协议诞生那天起就是一主多从的模型一个主站轮询总线上的一堆从站。但现场经常出现两套控制单元要同时采集一批仪表数据的场景——中控要读就地触摸屏也要读两套上位机都要往同一根 RS485 线上发读请求。直接把两个主站并到总线上的结局就是帧错乱、响应丢失、电平互踩设备时不时复位。这个 MODBUS_RTU_Master.zip 源程序包解决的就是在 STM32F407 上让多个主站共用一条总线、按调度交替发请求的落地问题。它把总线占用这件事从协议层的死角里捞出来靠仲裁和时间片机制让两个主站轮流说话。适合已经在用 F407 做过 MODBUS 项目、或者想把一个单片机节点做成能跟别的控制器抢总线的主站扩展场景拿来改比从零写省太多事。2. 从单主到多主的总线仲裁先搞清楚三种机制的取舍2.1 单主轮询为什么在多主场景下会直接撞车MODBUS RTU 的物理层走的是 RS485 半双工差分信号总线上任意时刻只允许一个节点驱动总线。单主轮询时主站发完请求后立刻把 RS485 驱动器切换到接收态从站在规定时间内应答一来一回天然错峰。多主站场景里最大的问题是两个主站之间没有任何内在协调——它们在各自的时钟节拍下发起通信可能同时把发送使能DE引脚拉高总线立刻出现电平碰撞。解决思路的大方向有两条。一条是硬件互锁通过外加仲裁电路或网关设备来强制排队这条与单片机源程序关系不大。另一条是软件调度让每个主站自己约束发送时机这正是这个源码包里多主站模块做的事情。软件调度的核心问题只有一个你怎么知道总线现在空不空你该不该发。回答方式不同就派生出了下面的几种机制。2.2 时间片调度工业现场最稳妥的方案时间片调度是最容易在 STM32F407 上落地的方案。每个主站节点在本地维护一个固定的时隙表比如主站 A 在偶数槽位发请求主站 B 在奇数槽位发请求槽位宽度按最长的从站响应时间加裕量来定。实现里一般会有一个 Tick 定时器比如用 TIM4 产生 10ms 中断在中断里对一个槽位计数器做自加然后轮询槽位计数器的奇偶来决定当前是否允许本节点发送。它的优点是简单、确定、好排查。缺点是如果两个主站的时隙表没有对齐比如一个用 10ms 一个用 15ms冲突照样会出现。所以真正可靠的做法是让每个主站都从总线上获取一个时间基准——最常见的是监听总线上其他主站的帧间隔以此估算槽位起点。这个源码包里的实现走的就是这条路空闲监听加上延后发送窗口两个主站点不同时开机也能自己慢慢对齐。2.3 令牌环和主动退避绕不开的两种补充手段时间片解决了固定节奏下的冲突但现场总有突发需求——比如某个主站发现从站批量数据刷新很慢想临时加大发送频率。这时纯时间片会卡死灵活性。令牌环方案是让一个虚拟令牌在主站间传递拿到令牌的才有权发请求发完把令牌广播给下一个。这个方案在源码包里作为高级模式保留代价是令牌帧本身也要占用总线时间从站侧还得做一帧特殊功能码过滤。主动退避则是借鉴以太网的思路发送前先检测总线空闲如果检测到正在通信就随机退避一段时间再试。三个机制可以叠用——基础调度用时间片突发重发用退避跨主站协调用令牌。我在实际项目中一般只用时间片加退避两层令牌环只在两个主站都有大量写请求、且需要严格公平时才用。源码包的主站状态机里保留了这三种模式的切换入口关键宏定义在mb_multi_cfg.h里后面移植章节会细说。3. 源码包结构拆解四层模块看懂 F407 多主站工程3.1 文件布局与模块分层这类源码包通常不会把代码堆在单个 main.c 里。拆开看的标准分层是硬件适配层、协议栈层、多主站仲裁层、应用入口层。硬件适配层处理串口、收发切换、定时器协议栈层负责帧打包、解析、CRC 校验仲裁层才真正算多主站的核心——决定何时发、何时等、何时让。我见过的工程里仲裁层经常被合进主站状态机文件里但独立出来的可读性好很多。// mb_port.c 中的串口引脚与发送方向切换配置以 USART1 为例 void MB_PortInit(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin GPIO_PIN_9; // USART1_TX gpio.Mode GPIO_MODE_AF_PP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio); gpio.Pin GPIO_PIN_10; // USART1_RX gpio.Mode GPIO_MODE_AF_INPUT; HAL_GPIO_Init(GPIOA, gpio); // RS485 方向控制引脚常见接法为 PC0 高电平发送 gpio.Pin GPIO_PIN_0; gpio.Mode GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init(GPIOC, gpio); MB_DIR_LOW(); // 默认接收态 }这段初始化里最需要注意的是串口的 UART 引脚要配到正确的复用功能上F407 的 USART1 复用在 PA9/PA10如果板子把外设引到别的引脚得去查数据手册的重映射表。MB_DIR_LOW()宏对应的是把 RS485 收发器的 DE/RE 拉低切到接收方向。发送方向切换不能只靠 GPIO 置高低要在驱动库里保证切换后有足够的建立时间否则发出去的第一位会分叉。3.2 串口帧收发的缓冲与空闲检测多主站工程里的接收不能靠一字节一中断地拼那样 CPU 占用太高且容易在中断里丢帧。常见做法是 DMA 接收加上串口空闲中断IDLE Line Detection一帧数据结束后硬件会置空闲标志这时候再从 DMA 缓冲区里把整帧拿过来解析。// mb_port.c 中 DMA 空闲接收的初始化片段 void MB_UartRxStart(UART_HandleTypeDef *huart) { // 先复位 DMA 接收计数再开启空闲中断 __HAL_DMA_CLEAR_FLAG(hdma_usart1_rx, DMA_FLAG_TC | DMA_FLAG_HT | DMA_FLAG_FE); HAL_UART_Receive_DMA(huart, mb_rx_buf, MB_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); }空闲中断触发后要判断到底收了多长办法是记录 DMA 当前剩余计数用MB_RX_BUF_SIZE减去剩余量就得到实际字节数。有一点要特别提醒DMA 是循环模式时count 会从缓冲区末尾跳回开头对帧长度跨边界的情况要单独判一下。// 空闲中断里算帧长度注意循环 DMA 的跨边界情况 uint16_t MB_GetRxLen(DMA_Stream_TypeDef *dma_stream) { uint16_t remain dma_stream-NDTR; uint16_t len; if (remain last_remain) { len MB_RX_BUF_SIZE - last_remain remain; // 绕回 } else { len remain - last_remain; } last_remain remain; return len; }这个函数是典型的坑位所在。NDTR的值在 DMA 搬运过程里是递减的但用了暴力减法会算错负数所以必须结合上一次记录值做差。写这行逻辑的时候最好加个注释不然过两个月你自己都看不懂为什么不是MB_RX_BUF_SIZE - NDTR。3.3 主站状态机与事务对象组织协议栈层最核心的是一张状态表。空闲、发送、等待应答、超时回收、重发这五个状态必须严格互斥。多主站的麻烦在于状态机不仅仅属于这一个主站还要考虑总线上另外的主站是否正在通信。所以状态机里一般在MB_IDLE之前插一层MB_MULTI_BACKOFF状态只有确保总线空闲才进入真正的发送。// mb_master.c 中的主站状态机流转示意 MB_State MB_ProcessEvent(MB_Transaction *tr) { switch (state) { case MB_IDLE: if (MB_Multi_CanSend()) { // 仲裁层判断时隙/退避是否满足 MB_UartSendFrame(tr); state MB_WAIT_RESP; MB_StartTimer(tr-timeout_ms); } break; case MB_WAIT_RESP: if (MB_RxComplete()) { MB_ParseResp(tr); state MB_IDLE; } else if (MB_IsTimeout()) { if (tr-retry_cnt-- 0) { // 重发逻辑 MB_UartSendFrame(tr); MB_StartTimer(tr-timeout_ms); } else { tr-err MB_ERR_TIMEOUT; state MB_IDLE; } } break; default: state MB_IDLE; break; } return state; }状态机的调度入口一般放在主循环里轮询或者在 RTOS 里挂一个任务。这里的超时重发逻辑要注意计数递减的时机先把retry_cnt减一再去判断否则可能出现重发次数多一次或少一次的差池。4. 移植到自己的 F407 板三步接入与关键参数调法4.1 换引脚和时钟的接入顺序拿到源码包先不要急着往工程里复制粘贴。第一步确认你的板子用的是哪个 USART、哪个 DMA 通道。F407 的 USART1 挂在 DMA2 上USART2/3 挂在 DMA1 上这就牵涉到 DMA 中断向量名不一样HAL_DMA_IRQHandler要对应使能。第二步是改mb_port.c里的 GPIO 初始化把引脚按你的原理图定义走。第三步是确认定时器分配多主站时间片一般用 TIM4 或者 TIM5如果有别的功能占用需要换定时器且重新配置中断优先级。硬件上用 RS485 收发器的话特别注意 DE/RE 两个脚是不是连在一起的。很多模块只有一个方向脚高电平发送、低电平接收这是最简单的接法。如果 DE 和 RE 分开控制要保证拉低 DE 的同时把 RE 拉高不能存在中间态否则总线空闲时会一直处于发送态彻底堵死别人的通信。4.2 波特率、帧间隔与超时三组参数换算这是移植阶段最关键的环节三组参数算错一组整个链路就跑不通。波特率不用多说关键是帧间隔的换算。MODBUS RTU 规定一帧结束后必须保持 3.5 个字符时间的静默而 3.5 字符时间取决于波特率。在 F407 上通常用定时器来度量这个间隔而不是简单地在 DMA 空闲中断后加一个延时。// mb_port.c 中帧间隔与超时时间的毫秒换算宏 #define MB_BAUDRATE 9600u #define MB_CHAR_TIME_MS (11u * 1000u / MB_BAUDRATE) // 每字符约 1.146ms #define MB_FRAME_GAP_MS (4u * MB_CHAR_TIME_MS) // 取 3.5~4 字符间隔作裕量 #define MB_DEFAULT_TIMEOUT_MS (100u) // 响应超时按现场调11乘进去是因为串口一帧有 1 个起始位、8 个数据位、1 个停止位再加可选的校验位按 11 位来算最稳妥。9600 波特率下算出来每字符约 1.146ms3.5 字符大概 4.01ms所以帧间隔取4 * 1.146约 4.6ms 是合理的。波特率提高到 115200 时这个值缩到 0.38ms 左右如果还是按原来 9600 的参数移植响应判定会慢一个数量级多主站调度会白白空转。4.3 CRC16 校验字节顺序与拼接边界CRC 校验的细节容易出问题。MODBUS RTU 的 CRC16 是多项式 0xA001 的 CRC-16/IBM计算思路是初始值 0xFFFF低字节在前的算法。源码包里通常会有现成的查表法实现比按位计算快得多。移植时最常犯的错误是把 CRC 结果高低字节拼反导致从站不认。// mb_crc.c 查表法 CRC16计算完按 MODBUS RTU 要求低字节先发 uint16_t MB_CRC16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (uint8_t i 0; i 8; i) { crc (crc 0x0001) ? (crc 1) ^ 0xA001 : (crc 1); } } return crc; } // 发送缓冲区装配的最后一步 void MB_AppendCRC(uint8_t *buf, uint16_t len) { uint16_t crc MB_CRC16(buf, len); buf[len] (uint8_t)(crc 0xFF); // 低字节在前 buf[len] (uint8_t)(crc 8); }查表法和位运算法算出来的 CRC 值完全一样区别只在速度。F407 跑 168MHz计算短帧的按位 CRC 也就几微秒用哪种都行。但你要是把 CRC 的初始值改成 0x0000 或者多项式用错 0x8005从站大概率会回一个异常码 0x03这个时候排查的优先方向就是 CRC 实现而不是通信链路。4.4 自测用 PC 端从站模拟器验证多主站切换代码移植完先别连真实设备。我一般用 PC 端常见的 Modbus 从站模拟工具起一个虚拟从站设置好地址和寄存器表然后把 F407 开发板的 RS485 转 USB 模块接到电脑上。先用单主站模式跑通一主一从的读保持寄存器确认帧格式没问题再烧入多主站版本用第二个 USB 转串口工具模拟第二路主站同时往总线上发包观察两路主站的请求是否冲突。用两个串口调试助手同时开的时候重点是看帧间隙。正常情况下总线上应该是A 发一帧→从站应答→静默一段→B 发一帧→从站应答的节奏。如果你看到 A 和 B 的请求帧重叠说明时间片没对齐或者退避参数没生效。这时候把MB_MULTI_BACKOFF的延时扩到 10ms 级多半能压住冲突之后再逐步缩小找临界值。5. 多主站调试避坑六个高频故障与排查记录5.1 两个主站同时发请求总线电平出现毛刺现象用逻辑分析仪抓总线能看到两个请求帧的起始位叠在一起波形在中间出现一段不确定电平。原因两个主站的发送使能同时拉高RS485 驱动器同时驱动总线。解决先确认时间片是否生效再看MB_Multi_CanSend()的判断逻辑有没有覆盖到所有入口。我翻过车的是初始化阶段——上电时两个主站都认为自己是第一个上线的结果同时抢总线必须加一个随机初始退避时间谁退避完谁先发。5.2 响应帧到了但 CRC 校验不通过现象从站明明回了正确数据程序却报 CRC 错误重试次数用尽后报超时。原因最常见是 CRC 计算范围不对把从站的从站地址一起算进去或者漏算功能码。其次是接收帧长度判断有误把空闲中断后残留的上一帧尾字节也算进来了。解决在解析函数里加个帧长度打印对比抓包工具里的实际帧内容把 CRC 校验的起止位置对齐。5.3 切换主站后第一帧必然超时现象两路主站轮流工作单独跑都正常一旦主站 A 连续跑了几十轮后交给主站 BB 发的第一帧请求永远没回应。原因从站响应 A 的帧之后总线上的静默时间不够B 以为总线空闲就开始发结果从站还没释放发送缓冲区帧冲突。解决把帧间隔从 3.5 字符放宽到 4 字符以上且在主站切换前强制插入一段固定静默窗口时间长度必须大于从站最大响应保护时间。5.4 长时间运行后总线卡死主站不复位也不恢复现象系统跑五六个小时后总线完全静止偶尔抓包能看到主站还在定时发请求但没有任何回应复位主站后恢复正常。原因从站被某个异常帧锁住或者总线上的某个节点进入持续发送状态霸占线路。解决排查方向先看从站侧有没有看门狗复位机制再检查主站超时重试逻辑是不是把超时标记清掉了。我在代码里加了总线活性检测——连续 10 次超时后自动拉低方向引脚最长 100ms强制让总线静默再重新初始化收发状态。5.5 中断优先级配置不当导致帧错位现象高波特率如 115200 时偶尔出现收到的帧内容整体错位从站地址和功能码混到数据位里。原因串口中断和 DMA 中断优先级配成同级接收处理还没执行完发送完成中断进来把缓冲区内容改了。解决把串口空闲中断设为高优先级DMA 传输完成中断略低一级且收发缓冲区用双缓冲避免发送期间接收覆盖同一块内存。优先级分组我一般用 NVIC_PriorityGroup_2抢占优先级分别设 1 和 2。5.6 多主站模式下从站响应偶尔会被吃掉现象从站明明发出了响应总线上能抓到但主站没有进入接收处理。原因从站响应到达时主站刚好在发送方向切换的临界区方向引脚还没完全切到接收态。解决发送结束后加一个 2~3 字符时间的方向切换延时这个延时在代码里不能省也不能用简单延时函数替代要用定时器计数保证精度。调试时你在示波器上能清楚看到 DE 下降沿和 RX 数据第一位之间有毛刺就是切换延时给的太短的铁证。6. 进阶总线空闲检测做主站热切换的验证手法6.1 用逻辑分析仪抓总线波形确认时隙边界多主站有没有真正生效别只看程序跑没跑要看总线波形。我把逻辑分析仪串联进 RS485 总线采样率设到 1MHz 以上抓 50 帧以上数据。观察点有三个两个主站请求帧时间间隔是否稳定、间隔是否大于设定帧间隙、方向切换毛刺是否出现在帧头。稳定之后把时隙窗口缩到理论值的 80%如果还能不冲突说明你的调度逻辑余量留够了。// 用逻辑分析仪导出的 CSV 里简单判断帧间隔node.js 脚本思路 const fs require(fs); const lines fs.readFileSync(bus.csv, utf8).trim().split(\n); let lastEdge 0, gaps []; for (let i 1; i lines.length; i) { const t parseFloat(...); // 取边沿时间 if (lastEdge) gaps.push(t - lastEdge); lastEdge t; } const minGap Math.min(...gaps); console.log(最小帧间隔: ${minGap.toFixed(2)}ms);这个脚本的用意不是用来精确定位而是快速筛掉明显重叠的帧。你在实际项目里可以把最小帧间隔和理论值放一起打出来间距小于理论值的多半是仲裁逻辑漏掉了某个发送入口。6.2 热切换的自测习惯与我的收尾建议每次改完参数坚持同一个验证流程单主站模式跑通读写双主站模式跑满 2000 次连续轮询热切换 500 次再用逻辑分析仪抓 50 帧看时隙。这套流程走完代码基本能交给现场。调多主站这几年踩坑最多的始终是方向切换和时间片对齐数值上再玄学的问题最后都能归结到定时器计数不准或 RS485 收发器建立时间不够上。写代码时给自己留个调试钩子——用 GPIO 翻转来标记主站进入发送态的位置接上示波器就能直接看到仲裁判决是否合理。希望这个思路能帮到你少走我当年趟过的那些坑。本文还有配套的精品资源点击获取