尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于STM32F103的RS485 Modbus RTU开关量采集从站实现

基于STM32F103的RS485 Modbus RTU开关量采集从站实现 简介一份基于STM32F103的RS485串口Modbus协议开关量采集与控制完整工程适合嵌入式开发者和单片机学习者参考用于实现工业现场开关量信号的远程采集与继电器/指示灯控制。项目基于FreeModbus协议栈实现RTU模式从机从机地址为1串口USART1波特率9600无校验通过eMBPoll循环处理协议请求代码内含LED与按键轮询逻辑可直观看到Modbus线圈寄存器与开关量I/O的映射关系。压缩包共106个文件包含48个C源码、45个头文件以及Keil工程文件、hex固件、备份与说明文档等体积仅374KB源码工程目录清晰可直接编译烧录验证。目前已有1412人学习浏览是快速上手Modbus工业通信的实用案例。通过阅读源码可掌握STM32标准库外设初始化、FreeModbus移植步骤、开关量输入输出轮询方法并能基于工程扩展更多Modbus功能码对开发物联网网关、工业数据采集设备很有帮助。1. RS485 串口 Modbus 与开关量采集stm32f103 在工业侧的本职工作一个车间里反复出现的需求控制柜里十几个按钮、限位开关和继电器需要把开关状态送到几十米外的 PLC还要接受远程启停命令。第一反应是 WiFi 或直接拉 IO 线但在工业环境里WiFi 不稳定且难维护长距离 GPIO 电平连抗干扰都谈不上。真正长期稳定在产线上跑的方案是 stm32f103c8t6 最小系统板加一块 RS485 收发器用 Modbus RTU 串口协议完成开关量采集与控制。这背后是三个成熟部件的组合USART 硬件负责字节收发RS485 差分总线解决远距离和干扰Modbus RTU 提供 PLC、触摸屏、组态软件原生支持的语义。整套方案按协议定义、电路配置、代码实现、联调排错的顺序展开新手能照做老手也能对着边界条件核对一遍。2. Modbus RTU 帧结构与开关量地址映射先盘协议再写代码2.1 开关量位对象线圈和离散输入用不同功能码Modbus 的开关量不是用寄存器存数值而是以位对象为单位。位对象分两类线圈Coil和离散输入Discrete Input。线圈是可读写的输出位对应继电器、接触器、指示灯离散输入是只读的状态位对应限位开关、按钮、光电开关的输出。主站读离散输入用功能码 0x02读线圈用 0x01写单个线圈用 0x05写多个线圈用 0x0F。从站侧要做的就是把 DI 点映射到 0x02把 DO 点映射到 0x01/0x05/0x0F。功能码名称对象方向请求 PDU 内容成功响应 PDU 内容0x01读线圈DO可读可写起始地址(2B)数量(2B)数据字节数(1B)位数据(NB)0x02读离散输入DI只读起始地址(2B)数量(2B)数据字节数(1B)位数据(NB)0x05写单线圈DO单个写线圈地址(2B)状态值(2B)原样回显请求帧0x0F写多线圈DO批量写首址(2B)数量(2B)字节数(1B)数据首址(2B)数量(2B)表里最容易出错的是 0x05 的“状态值”字段ON 必须写 0xFF00OFF 必须写 0x0000写 0x0001 会被从站回异常 0x03。同时 0x05 的成功响应是原样回显主站靠回显帧确认写操作被接受0x0F 的成功响应反而只有起始地址和数量不回显数据内容两者别记混。2.2 位打包8 个开关量一字节LSB 在前Modbus 响应里开关量不按位逐个传输而是按 LSB 优先打包。主站请求“从地址 0 读 8 个离散输入”返回的数据字节里 bit0 对应 DI0bit1 对应 DI1以此类推如果只读 3 个点高位补 0。这个规则在 0x0F 写多线圈时同样成立主站下发 8 个位数据时也是 bit0 对应第一个线圈。很多解析错乱的问题都出在字节序上Modbus 的数据区没有任何字节序转换直接按位填充。打包过程在 stm32f103 侧写成一个通用函数传入点位数组、起点和数量uint8_t pack_bits(const uint8_t *values, uint16_t start, uint16_t qty, uint8_t *out) { uint8_t byte_cnt (qty 7) / 8; /* 不足 8 位也占用一个字节 */ memset(out, 0, byte_cnt); for (uint16_t i 0; i qty; i) { if (values[start i]) out[i / 8] | (uint8_t)(1u (i % 8)); } return byte_cnt; }i / 8算出该点位落在第几个字节i % 8算出落在这个字节的第几位整体就是 LSB 优先。这个函数不关心点位来源是 DI 还是 DO只要调用时传入正确的数组和起点。解包方向正好相反0x0F 写多线圈时逐个取出对应位再写入 do_value 数组即可。2.3 从站地址分配和异常响应协议按 0 地址上层界面差 1在协议层所有地址从 0x0000 开始上层的触摸屏和 PLC 会在这些地址上做偏移。同一个物理点位在 Modbus Poll 里填协议地址在 PLC 工程里可能要填 00009 这样的绝对地址调试时必须分清。本项目把地址区规划如下协议地址逻辑对象物理点位方向0x0000 ~ 0x0007离散输入 0~7DI0~DI7光耦隔离输入接 GPIOA0~7只读0x0008 ~ 0x000F线圈 0~7DO0~DO7继电器输出接 GPIOB0~7读写DI 和 DO 放在两个独立地址段主站读 DI 填 0写 DO 填 8十进制而不是填 0规避两个地址段重叠。从站维护两个位数组 di_value[8] 和 do_value[8]地址值换算成数组下标用不需要真正分配寄存器空间。请求合法但无法执行时从站回异常帧而不是丢弃。异常帧格式地址码 (功能码 | 0x80) 异常码 CRC16。三个最常见异常码异常码名称本项目触发条件0x01非法功能码主站请求 0x03、0x04、0x06、0x10 等未实现功能码0x02非法数据地址起始地址或“地址数量”越过 0~7 的映射区0x03非法数据值0x05 写入的值既不是 0xFF00 也不是 0x0000异常响应构建函数如下CRC 计算在第 4 章实现uint8_t build_exception(uint8_t func, uint8_t exc_code, uint8_t *tx) { tx[0] DEV_ADDR; /* 从站地址本项目固定为 1 */ tx[1] func | 0x80; /* 功能码最高位置位表示异常 */ tx[2] exc_code; uint16_t crc crc16_modbus(tx, 3); tx[3] crc 0xFF; tx[4] crc 8; return 5; }主站识别到功能码高位为 1 时不会再按正常响应解析数据而是直接进异常处理分支。现场设备如果主站一直超时重试先回过头把异常帧打开地址越界还是功能码缺失在报文里一眼就能区分。3. RS485 电路与 stm32f103 串口配置方向切换和 DMAIDLE 接收3.1 半双工方向切换GPIO 控制 DE/RE务必等 TCRS485 是半双工同一时刻总线上只能有一个节点发送。以 SP3485 为例RO 接 MCU 的 RXDI 接 MCU 的 TXRE 和 DE 短接后由一根 GPIO 控制低电平是接收、高电平是发送。选收发器时注意 stm32f103 是 3.3V 器件SP3485 这类 3.3V 供电的直接连MAX485 的 RO 输出 5V 高电平需要电平转换。方向控制最容易踩的坑是发送完没有等传输完成就拉低 GPIO。USART 发送时 TXE 标志只表明数据从数据寄存器进了移位寄存器最后一位还在移位输出。此时切方向会截断帧尾主站收到帧的 CRC 永远是错的。正确写法void rs485_send_frame(USART_TypeDef *USARTx, uint8_t *buf, uint16_t len) { GPIO_SetBits(GPIOA, GPIO_Pin_8); /* DE1进入发送 */ for (uint16_t i 0; i len; i) { while (USART_GetFlagStatus(USARTx, USART_FLAG_TXE) RESET); USART_SendData(USARTx, buf[i]); } while (USART_GetFlagStatus(USARTx, USART_FLAG_TC) RESET); /* 等最后一位移出 */ GPIO_ResetBits(GPIOA, GPIO_Pin_8); /* 回到接收 */ }也见过有人用三极管和二极管搭自动收发电路省一根 GPIO但 115200 以上边沿质量明显变差现场还有误触发风险。GPIO 切换多花几微秒在 Modbus RTU 的 9600 波特率下完全可忽略。3.2 终端电阻、偏置电阻和防护总线两端各接一只 120Ω 终端电阻匹配双绞线特征阻抗抑制反射。这个“两端”指物理链路的端点不是每块从机板都焊节点多了会拉低驱动能力。距离很近、只有两三台设备时可以不接但线缆超过 100 米或从机超过 8 台必须按规范来。终端电阻解决反射但总线空闲时没有任何节点驱动 A/B 线差分电压接近 0接收器可能乱输出。规范做法是A 线上拉到 VCC、B 线下拉到 GND。一般用 4.7kΩ 上拉和下拉各一只放在主站侧从机板留焊盘或者不焊避免每块板都加造成偏置过大。再往下就是防护A/B 之间并 TVS 管常用 SMBJ6.0CA再串共模电感或磁珠滤高频。RS485 芯片的供电与 MCU 之间也可以加隔离但那是另一套成本。至少要把 TVS 加上这是通得过现场的底线。3.3 USART 初始化与 DMAIDLE 帧接收Modbus RTU 帧长度随功能码变化固定长度 DMA 中断不好使。常见做法是 DMA 循环接收配合 USART 的 IDLE 中断线路上没有数据超过一个字符时间就认为一帧结束。以 USART1 DMA1_Channel5 为例初始化代码void rs485_uart_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef uart; DMA_InitTypeDef dma; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); gpio.GPIO_Pin GPIO_Pin_9; /* PA9 USART1_TX */ gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_10; /* PA10 USART1_RX */ gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_8; /* PA8 485 方向脚 */ gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); GPIO_ResetBits(GPIOA, GPIO_Pin_8); /* 默认接收态 */ uart.USART_BaudRate 9600; uart.USART_WordLength USART_WordLength_8b; uart.USART_StopBits USART_StopBits_1; uart.USART_Parity USART_Parity_No; uart.USART_HardwareFlowControl USART_HardwareFlowControl_None; uart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, uart); DMA_DeInit(DMA1_Channel5); /* USART1_RX DMA1_Channel5 */ dma.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; dma.DMA_MemoryBaseAddr (uint32_t)rx_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize RX_BUF_SIZE; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; dma.DMA_Mode DMA_Mode_Circular; dma.DMA_Priority DMA_Priority_High; dma.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, dma); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_Cmd(USART1, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE); }初始化里有几个关键点GPIOA 的 PA9 要配置成推挽复用输出PA10 是浮空输入PA8 是普通推挽输出用于方向控制DMA 的方向是外设到内存循环模式让接收缓冲写满后自动回卷缓冲大小 RX_BUF_SIZE 建议 256保证容纳最长的 Modbus RTU 帧。IDLE 中断服务程序完成帧边界判定void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) RESET) return; USART_ReceiveData(USART1); /* 读 DR 清除 IDLE 标志 */ DMA_Cmd(DMA1_Channel5, DISABLE); uint16_t remain DMA_GetCurrDataCounter(DMA1_Channel5); rx_len RX_BUF_SIZE - remain; if (rx_len 8) /* 地址功能码数据CRC */ rx_ready 1; DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); }读 DMA 计数器前先 DISABLE是因为计数器在 DMA 运行中会实时变化直接读很可能拿到中间值。禁用窗口不到 1 微秒对 9600 波特率的时序毫无影响。严格按 Modbus 标准帧间隔应是 3.5 个字符时间IDLE 中断 1 个字符时间就触发比标准偏短主站按规范发帧时不会出问题但如果你接的第三方主站帧间间隔大DMAIDLE 仍然是最省事的选择。3.4 波特率误差9600 零误差HSI 时钟要避开Modbus 常用波特率在 stm32f103 上误差都很小。USART1 挂 APB272MHzUSART2/3 挂 APB136MHz只要外接 8MHz 晶振9600~38400 的分频结果是整数。以 9600 为例USARTDIV72MHz/16/9600468.75BRR 的 4 位小数部分 0.75×1612实际波特率正好 9600。波特率USART1APB272MHzUSART2/3APB136MHz96000.00%0.00%192000.00%0.00%384000.00%0.00%1152000.00%0.16%4608000.16%0.16%115200 在 36MHz 的 APB1 上会出现 0.16% 误差原因是分频小数部分为 8.5只能圆整到 8 或 9。Modbus 长帧在 115200 下十几个字节累计位误差还撑得住但 460800 以上就建议换其他方案。另外代价最小的教训是不要用内部 HSI 振荡器跑 RS485。HSI 出厂校准误差 1%~2%帧字节一多末尾的 CRC 对不上是常态换外部 8MHz 晶振后同样代码就能稳定跑。4. Modbus 协议栈实现CRC16 校验、功能码分发与开关量读写4.1 CRC16-Modbus初值 0xFFFF反射多项式 0xA001Modbus RTU 的 CRC 计算和标准 CRC16 的区别在于它要求 LSB 优先所以多项式要做反射计算时用 0xA001 而不是 0x8005。初值固定 0xFFFF对地址、功能码和数据区逐字节计算最终结果低字节先发、高字节后发。逐位实现uint16_t crc16_modbus(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; uint8_t j; for (i 0; i len; i) { crc ^ buf[i]; for (j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }帧很小的时候用查表法收益不高逐位循环在 72MHz 主频下连 1 微秒都用不到。校验接收帧时要把帧尾两字节组合成整数再比不能直接 memcmp因为收进来的 CRC 是低字节在前uint16_t crc_rx rx_buf[rx_len - 2] | (rx_buf[rx_len - 1] 8); uint16_t crc_calc crc16_modbus(rx_buf, rx_len - 2); if (crc_rx ! crc_calc) { rx_len 0; /* 静默丢弃不回异常 */ return; }CRC 错误属于物理层噪声或波特率误差从站错误回异常也没意义标准行为是丢弃。4.2 功能码分发四个分支处理开关量读写协议栈入口在主循环里轮询 rx_ready置位后把 rx_buf 交给 dispatch收到响应帧再通过 rs485_send_frame 发回去。dispatch 函数一个 switch 处理四种功能码每种都先做长度、地址、值三层校验。功能码请求最短长度成功响应数据段映射区0x01/0x02 读位8 字节字节数位数据DI 区 / DO 区0x05 写单线圈8 字节回显整帧DO 区单个0x0F 写多线圈10N 字节起始地址数量DO 区批量具体代码#define DI_BASE 0x0000 #define DO_BASE 0x0008 uint8_t modbus_dispatch(uint8_t *rx, uint16_t rx_len, uint8_t *tx) { uint8_t func rx[1]; uint16_t reg, qty, off, i; uint8_t bc; switch (func) { case 0x01: /* 读线圈 DO */ case 0x02: /* 读离散输入 DI */ if (rx_len ! 8) return build_exception(func, 0x03, tx); reg (rx[2] 8) | rx[3]; qty (rx[4] 8) | rx[5]; if (qty 1 || qty 8) return build_exception(func, 0x03, tx); if (func 0x01) { if (reg DO_BASE || reg qty DO_BASE 8) return build_exception(func, 0x02, tx); off reg - DO_BASE; } else { if (reg qty 8) return build_exception(func, 0x02, tx); off reg; } tx[0] DEV_ADDR; tx[1] func; tx[2] (qty 7) / 8; memset(tx[3], 0, tx[2]); for (i 0; i qty; i) { uint8_t bit (func 0x01) ? do_value[off i] : di_value[off i]; if (bit) tx[3 i / 8] | (uint8_t)(1u (i % 8)); } bc 3 tx[2]; break; case 0x05: /* 写单线圈 */ if (rx_len ! 8) return build_exception(func, 0x03, tx); reg (rx[2] 8) | rx[3]; if (reg DO_BASE || reg DO_BASE 8) return build_exception(func, 0x02, tx); off reg - DO_BASE; if (rx[4] 0xFF rx[5] 0x00) { do_value[off] 1; } else if (rx[4] 0x00 rx[5] 0x00) { do_value[off] 0; } else { return build_exception(func, 0x03, tx); } memcpy(tx, rx, 8); /* 成功响应回显 */ return 8; case 0x0F: /* 写多线圈 */ if (rx_len 10) return build_exception(func, 0x03, tx); reg (rx[2] 8) | rx[3]; qty (rx[4] 8) | rx[5]; bc rx[6]; if (qty 1 || qty 8 || bc ! (qty 7) / 8) return build_exception(func, 0x03, tx); if (reg DO_BASE || reg qty DO_BASE 8) return build_exception(func, 0x02, tx); off reg - DO_BASE; for (i 0; i qty; i) do_value[off i] (rx[7 i / 8] (i % 8)) 1; tx[0] DEV_ADDR; tx[1] 0x0F; tx[2] rx[2]; /* 回显起始地址 */ tx[3] rx[3]; tx[4] rx[4]; tx[5] rx[5]; /* 回显数量 */ bc 6; break; default: return build_exception(func, 0x01, tx); } uint16_t crc crc16_modbus(tx, bc); tx[bc] crc 0xFF; tx[bc 1] crc 8; return bc 2; }代码层面有三个细节值得说明。0x01 和 0x02 的请求都是 8 字节差别只在映射区不同读线圈时要把地址减去 DO_BASE 换算成 do_value 的下标读离散输入时直接用寄存器地址当下标因为 DI 区从 0 开始。0x05 的校验顺序是先查功能码、再查地址、最后查值这三类错误分别对应异常 0x03、0x02、0x03。0x0F 的请求长度不是固定值先判断 rx_len 至少 10 字节再校验 rx[6] 是否等于 (qty7)/8防止畸形帧读到缓冲区外。4.3 底层 I/O 映射DI 去抖采集、DO 批量输出协议层操作的是 di_value 和 do_value 两个位数组底层 I/O 与协议解耦。PA0~PA7 需要单独初始化成输入模式DI 在定时器中断里以 5ms 周期扫描 GPIOA连续 5 次电平一致才更新状态等效 25ms 去抖窗口void di_scan(void) { static uint8_t cnt[8], last[8]; uint16_t pa GPIOA-IDR 0x00FF; /* PA0~PA7 */ for (uint8_t i 0; i 8; i) { uint8_t now (pa i) 1; if (now last[i]) { if (cnt[i] 5) { di_value[i] now; cnt[i] 0; } } else { last[i] now; cnt[i] 0; } } }DO 不要求在协议响应里立刻写 GPIO先在 do_value 置位再由周期任务统一刷到 GPIOB。这样一帧里写多个线圈时各路继电器动作时间点一致。输出用读改写而不是直接赋值整个端口避免覆盖 PB8~PB15 上可能存在的其他信号void do_flush(void) { uint16_t pb GPIOB-ODR; for (uint8_t i 0; i 8; i) { if (do_value[i]) pb | (1u i); else pb ~(1u i); } GPIOB-ODR pb; }主循环把接收、转发、I/O 刷新串起来while (1) { if (rx_ready) { rx_ready 0; uint16_t tx_len modbus_dispatch(rx_buf, rx_len, tx_buf); if (tx_len) rs485_send_frame(USART1, tx_buf, tx_len); } di_scan(); /* 也可放到 SysTick 定时中断 */ do_flush(); }do_value 被协议层和输出层同时访问如果 do_flush 运行在定时器里modbus_dispatch 写在主循环里两个任务之间加临界区保护防止位更新被拆开。5. 开关量联调与排错Modbus Poll 验证和 RS485 干扰处理5.1 用 Modbus Poll 验证从站协议栈代码烧进 stm32f103 后先用 USB 转 RS485 接到电脑Modbus Poll 当主站。新建连接选 CH340 或 FTDI 对应的 COM 口波特率 9600、8N1Read/Write Definition 里填从站地址 1、功能码 02、起始地址 0、数量 8轮询周期 200ms。界面出现 8 个位拨动现场开关看对应位是否变化这一步就证明了从站的 USART、DMA、IDLE、CRC、0x02 分发链路全部正常。写回路把功能码切到 05地址填 8写 0xFF00看继电器吸合再用 0x0F 一次写多个线圈验证批量点位控制。Modbus Poll 的 Display → Communication Traffic 窗口能逐帧查看请求和响应十六进制数据CRC 错误标红这是最快定位方向脚时序和极性反接问题的工具。从站完全无响应时优先查 A/B 是否接反、方向脚默认电平、地址和波特率是否匹配这三项每一项都比协议代码更常出错。Modbus Poll 免费版有使用时长限制开发阶段够用别把生产环境依赖在试用工具上。5.2 输入去抖与现场干扰处理机械触点开关抖动在开关量采集中最隐蔽。限位开关动作时触点会在几毫秒内反复通断不做去抖主站看到的就是位状态快速闪动甚至引发联锁误动作。di_scan 里的连续 5 次确认已经能挡住大部分抖动继电器感性负载两端要加续流二极管或 RC 吸收防止断开瞬间反峰耦合到 RS485。RS485 现场干扰的典型症状是主站偶发 CRC 错误、开关量状态随机跳变。按顺序查两端 120Ω 终端电阻接上没有空闲时 A/B 间差分电压是否大于 200mV不够在 A 上拉、B 下拉各接 1kΩ屏蔽层是否只在控制柜侧单端接地A/B 对地是否并了 TVS推荐 SMBJ6.0CA。线缆改用屏蔽双绞线A、B 各一根不要和动力线同槽敷设。示波器挂 A-B 看波形正常发送时差分幅值应在 ±1.5V 以上波形毛刺多、幅值偏低先补硬件再回来调软件软件层的 CRC 报错大多是硬件层问题的延迟暴露调通硬件后的协议栈往往一次就能通过。本文还有配套的精品资源点击获取
返回列表