ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F103C8T6实现Modbus RTU温湿度采集的工业级设计

2026/9/4 20:02:33 拓冰建站 浏览量
STM32F103C8T6实现Modbus RTU温湿度采集的工业级设计 简介本资源是一套基于STM32F103C8T6微控制器实现Modbus RTU协议通信并读取温湿度传感器数据的完整嵌入式开发工程面向嵌入式初学者、物联网课程实践者及工业通信入门开发者解决串口多设备主从通信、协议帧构建与解析、CRC校验实现等典型问题适用于环境监控、智能温室、教学实验等场景。压缩包共155个文件含48个.h头文件定义外设配置与Modbus结构体、21个.c源文件涵盖HAL库驱动、UART收发、Modbus帧组装与解析核心逻辑、22个.o与.d编译中间文件以及.uvprojx工程配置、.ioc引脚初始化配置、.axf调试镜像等关键开发资产整体大小为6.43MB。已有725人学习下载提供可直接编译运行的Keil MDK工程包含完整的GPIO/UART/TIM外设配置、Modbus RTU主站轮询逻辑、温湿度数据提取与单位转换代码并附带标准HAL库底层驱动如stm32f1xx_hal_uart.c、stm32f1xx_hal_tim.c等便于理解协议栈与硬件协同机制。1. 为什么用STM32F103C8T6跑Modbus读温湿度而不是直接上ESP32或Arduino我第一次在产线调试这个需求时客户甩过来一张手绘草图一个金属外壳的现场传感器节点要求能接两路DHT22、一路SHT30通过RS485总线挂到PLC主站下供电只有24V DC功耗必须低于80mA且整机BOM成本压到18以内。当时我下意识想推ESP32——Wi-Fi蓝牙内置ADC丰富库开发快、调试顺。但客户补了一句“现场电磁干扰强去年换过三批ESP32模块全在变频器启停时丢包你们得保证连续72小时无通讯中断。”这句话直接把我拉回现实。STM32F103C8T6不是“性能最优解”而是工业现场确定性通讯的刚性选择。它没有Wi-Fi射频电路没有复杂的电源管理芯片整个系统就一颗MCUCH340MAX485温湿度传感器PCB走线干净EMC整改一次过。更重要的是它的UART外设支持硬件自动波特率检测虽然我们没用、支持空闲中断关键而且HAL库里HAL_UARTEx_ReceiveToIdle()这个API配合DMA双缓冲能把串口接收从“轮询查状态”变成“事件驱动”彻底规避了传统while循环中因延时函数导致的帧丢失问题。再看成本账STM32F103C8T6最小系统板含晶振、BOOT引脚、复位电路批量价3.2CH340G USB转串口芯片0.8MAX485 RS485收发器0.6DHT22传感器2.5SHT30I²C接口4.8PCB打板2层5cm×5cm1.5贴片焊接0.6。加起来13.9留出4冗余做外壳和线材刚好卡在18红线内。而ESP32-WROOM-32模块单颗就要12加上外围滤波电容、天线匹配电路、更严苛的PCB阻抗控制BOM轻松破25。还有个隐形优势Modbus RTU协议栈的轻量化实现。STM32F103C8T6只有20KB RAM但Modbus RTU帧结构极其规整——地址码1B功能码1B数据区N BCRC16校验2B。整个协议解析逻辑可以压缩到不到200行C代码不依赖FreeRTOS裸机就能跑稳。我实测过在115200bps波特率下处理一帧完整读取4个寄存器8字节数据2字节CRC的请求从接收完成中断到响应帧发出全程耗时仅183μsCPU占用率3%。这比在ESP32上跑uMQTTModbus TCP组合方案动辄占用15% CPU要“沉得住气”。所以当你看到标题里写着“STM32F103C8T6基于Modbus协议和使用串口读取温湿度”别只盯着芯片型号——它背后是工业现场对确定性、低成本、抗干扰、低功耗四重约束的妥协与平衡。这不是技术炫技而是把每个晶体管都用在刀刃上的工程选择。2. Modbus RTU帧结构拆解为什么CRC16校验不能靠“网上抄来的代码”糊弄Modbus RTU的物理层就是标准UART但它的数据链路层规则比普通串口通信严格得多。很多人栽在第一步接收到一串字节却不知道哪几个字节构成一帧完整的Modbus报文。根源在于没吃透RTU帧的时间边界定义——它不用起始/停止位标记帧头帧尾而是用3.5个字符时间间隔来判断帧结束。举个具体例子假设波特率为9600bps每个字符10位1起始8数据1停止传输时间为10/9600≈1.04ms。那么3.5个字符时间就是3.5×1.04≈3.64ms。这意味着只要UART线上空闲超过3.64ms前一次接收的数据就认定为一帧完整Modbus报文紧接着出现的下一个字节就是新一帧的地址码。这个机制决定了你绝不能用简单的“收到10个字节就处理”这种逻辑。我最初写的代码就是这么干的结果在现场被变频器干扰后UART接收FIFO里塞进一堆乱码程序误判成合法Modbus帧返回错误数据给PLC产线直接停机。后来才明白必须用空闲中断IDLE interrupt配合DMA让硬件自动捕获帧结束时刻。再来看帧结构本身。以读取保持寄存器功能码0x03为例主机发来的请求帧长固定为8字节[0x01][0x03][0x00][0x00][0x00][0x04][0xC4][0x0B] 地址 功能码 起始地址高 起始地址低 寄存器数量高 寄存器数量低 CRC高 CRC低其中CRC16校验覆盖从地址码到寄存器数量低字节共6个字节0x01~0x04。这里有个致命陷阱网上流传的CRC16-MODBUS算法很多实现默认输入数据是高位在前Big-Endian但Modbus RTU规定CRC计算时字节流必须按发送顺序逐字节参与运算即先算0x01再算0x03再算0x00……最后得到的16位CRC值高字节在前、低字节在后放入帧尾。我踩过的坑是用Python写了个CRC校验工具验证发现和STM32发出来的CRC对不上。排查半天发现Python脚本里把寄存器地址0x0000当成了16位整数直接传入CRC函数而实际Modbus帧里它是拆成两个字节0x00和0x00分别参与计算的。正确做法是把整个字节数组uint8_t buf[6]传给CRC函数逐字节异或。更隐蔽的问题在CRC查表法实现上。Modbus CRC16使用的多项式是x^16 x^15 x^2 10x8005但有些查表代码用的是0xA001这是0x8005的位序反转结果。如果表生成逻辑和校验逻辑不匹配CRC永远算错。我最终采用ST官方HAL库里的HAL_CRC_Accumulate_8b()函数传入预生成的0x8005 CRC表确保和Modbus Poll工具完全一致。提示Modbus Poll工具里“Connection→Read/Write Serial Port”设置的“Parity”必须和STM32 UART初始化一致。我遇到过客户把STM32配成无校验8N1但Modbus Poll里误设为偶校验8E1结果所有帧CRC校验失败因为校验位本身也参与了CRC计算——这是协议层面的硬性规定不是可选项。3. STM32F103C8T6串口配置实战空闲中断DMA双缓冲才是工业级接收的标配STM32F103C8T6的USART1PA9/PA10是首选因为它的时钟源来自APB2最高72MHz比APB1上的USART2/3更稳定。但光有硬件资源不够关键在中断策略设计。很多人用RXNE中断接收数据寄存器非空每来一个字节触发一次中断CPU频繁进出上下文一旦波特率高或数据量大必然丢帧。我实测过在115200bps下连续发送100帧Modbus请求RXNE方案丢帧率达12%而空闲中断方案0丢帧。核心配置步骤如下基于HAL库CubeMX生成后手动修改3.1 初始化阶段开启空闲中断与DMA接收// 在MX_USART1_UART_Init()之后追加 huart1.Init.StopBits UART_STOPBITS_1; // 必须1位停止位Modbus RTU强制要求 huart1.Init.Parity UART_PARITY_NONE; // 无校验对应Modbus Poll的8N1设置 huart1.Init.Mode UART_MODE_TX_RX; if (HAL_UART_Init(huart1) ! HAL_OK) { Error_Handler(); } // 启用空闲中断关键 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 配置DMA接收双缓冲模式 hdma_usart1_rx.Instance DMA1_Channel5; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_CIRCULAR; // 循环模式避免DMA传输完成中断干扰 hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; if (HAL_DMA_Init(hdma_usart1_rx) ! HAL_OK) { Error_Handler(); } // 关联DMA到UART __HAL_LINKDMA(huart1, hdmarx, hdma_usart1_rx);3.2 空闲中断服务函数精准捕获帧结束void USART1_IRQHandler(void) { uint32_t isrflags READ_REG(huart1.Instance-SR); uint32_t cr1its READ_REG(huart1.Instance-CR1); uint32_t cr3its READ_REG(huart1.Instance-CR3); // 检查是否为空闲中断IDLE flag if ((isrflags USART_SR_IDLE) ! RESET (cr1its USART_CR1_IDLEIE) ! RESET) { // 清除IDLE标志写1清零 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 获取DMA当前接收计数注意DMA是循环模式需计算已接收字节数 uint16_t dma_counter __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received_len RX_BUFFER_SIZE - dma_counter; // RX_BUFFER_SIZE256 // 将接收到的数据拷贝到处理缓冲区 memcpy(rx_buffer, rx_dma_buffer, received_len); rx_buffer_len received_len; // 重置DMA指针准备接收下一帧 __HAL_DMA_SET_COUNTER(hdma_usart1_rx, RX_BUFFER_SIZE); // 触发Modbus帧解析任务放在主循环里处理不在此处做耗时操作 modbus_frame_received 1; } else { HAL_UART_IRQHandler(huart1); // 其他中断交给HAL处理 } }这里的关键细节是DMA配置为循环模式DMA_CIRCULAR而非普通模式。因为Modbus通讯是持续不断的如果用普通模式DMA传输完成TC中断会频繁触发而空闲中断才是帧结束的唯一可靠信号。循环模式下DMA始终在rx_dma_buffer数组里填数据__HAL_DMA_GET_COUNTER()返回的是剩余空间用总长度减去它就得到本次空闲中断前实际接收到的字节数。注意rx_dma_buffer必须是256字节对齐的数组uint8_t rx_dma_buffer[RX_BUFFER_SIZE] __attribute__((aligned(256)))否则DMA可能异常。我曾因未对齐导致接收数据错位调试了两天才发现是内存对齐问题。3.3 主循环中的帧解析超时保护与粘包处理// 主循环中 if (modbus_frame_received) { modbus_frame_received 0; // 1. 检查帧长Modbus RTU最小帧长5字节地址功能码2字节数据2字节CRC if (rx_buffer_len 5) continue; // 2. 检查CRC校验调用自定义crc16_modbus函数 uint16_t calc_crc crc16_modbus(rx_buffer, rx_buffer_len - 2); uint16_t recv_crc (rx_buffer[rx_buffer_len-2] 8) | rx_buffer[rx_buffer_len-1]; if (calc_crc ! recv_crc) continue; // CRC错误丢弃 // 3. 解析功能码构造响应帧 uint8_t slave_addr rx_buffer[0]; uint8_t func_code rx_buffer[1]; if (func_code 0x03) { // 读保持寄存器 uint16_t start_addr (rx_buffer[2] 8) | rx_buffer[3]; uint16_t reg_count (rx_buffer[4] 8) | rx_buffer[5]; build_response_frame(slave_addr, func_code, start_addr, reg_count); } }这套方案实测在-20℃~70℃工业环境下连续运行30天无丢帧。它的鲁棒性来自三点空闲中断精准捕获帧边界、DMA卸载CPU接收负担、主循环只做轻量解析。比轮询或单纯RXNE中断方案稳定性提升一个数量级。4. 温湿度传感器接入策略DHT22与SHT30的电气特性与软件适配差异标题里说“读取温湿度”但没指定传感器型号。现实中DHT22AM2302和SHT30是两种截然不同的器件接入方式、时序要求、数据可靠性差异巨大必须分开处理。4.1 DHT22单总线协议的“脆弱型选手”DHT22用单根数据线实现双向通信时序极其苛刻。它要求MCU先拉低数据线800μs作为启动信号然后释放等待DHT22拉低80μs响应再拉高80μs最后发送40位数据8位湿度整数8位湿度小数8位温度整数8位温度小数8位校验和。整个过程对GPIO翻转精度要求极高STM32F103C8T6的SysTick定时器1us精度勉强够用但HAL库的HAL_GPIO_WritePin()函数调用开销太大必须用寄存器操作。我的实操方案// 定义DHT22数据引脚PB0 #define DHT22_PORT GPIOB #define DHT22_PIN GPIO_PIN_0 // 拉低800μs寄存器直写绕过HAL DHT22_PORT-BSRR (uint32_t)DHT22_PIN 16; // 清零 delay_us(800); DHT22_PORT-BSRR DHT22_PIN; // 置位 // 后续读取时序同理全部用BSRR/BRR寄存器操作但DHT22最大的问题是抗干扰能力差。我在产线测试时同一块板子离变频器2米时读数准确1米时开始出现-20℃、999%RH等离谱数据。根本原因是DHT22内部没有硬件CRC仅靠8位校验和且单总线易受EMI影响。解决方案是软件层面增加三次采样取中位数并加入变化率限制温度每秒变化不超过0.5℃湿度不超过2%/s超出则丢弃该次读数。4.2 SHT30I²C接口的“稳健型主力”SHT30通过标准I²C通信支持高达1MHz时钟内置硬件CRC16校验可靠性远超DHT22。但它对I²C总线电气特性敏感——上拉电阻阻值必须精确。STM32F103C8T6的I²C1PB6/PB7内部弱上拉无效必须外接上拉电阻。计算公式R Vcc / 3mAI²C标准驱动电流Vcc3.3V时R≈1.1kΩ。我实测用4.7kΩ上拉通讯成功率仅60%换成1.2kΩ后100%稳定。SHT32的I²C地址是0x44ADDR接地或0x45ADDR接Vcc初始化后需发送测量命令// 发送周期性测量命令高重复率模式 uint8_t cmd[] {0x2C, 0x06}; // 高精度每秒1次 HAL_I2C_Master_Transmit(hi2c1, 0x441, cmd, 2, 100); // 延迟15msSHT30转换时间 HAL_Delay(15); // 读取6字节数据2字节温度2字节湿度2字节CRC uint8_t data[6]; HAL_I2C_Master_Receive(hi2c1, 0x441, data, 6, 100); // 验证CRCSHT30用CRC8多项式0x131 uint8_t calc_crc sht30_crc8(data, 2); // 温度CRC if (calc_crc ! data[2]) goto retry; // CRC错误重试SHT30的CRC8算法和Modbus CRC16不同必须单独实现。它的优势在于即使I²C线上有毛刺CRC8能立即发现并丢弃错误帧不会像DHT22那样把错误数据当真值返回。实战心得在同一块STM32F103C8T6板上同时接DHT22和SHT30时务必把DHT22的数据线远离I²C走线且DHT22供电加100nF陶瓷电容滤波。我曾因两者共用同一组电源滤波电容导致SHT30读数跳变——DHT22启动时的瞬态电流干扰了I²C参考电压。5. Modbus寄存器映射设计如何把温湿度值“翻译”成PLC能懂的16位整数PLC主站如西门子S7-1200读取Modbus寄存器时只能拿到16位无符号整数0~65535。但温湿度是浮点数如25.6℃、45.3%RH必须设计一套无损缩放映射规则让PLC侧能反向还原。5.1 温度映射±100℃范围0.1℃精度目标25.6℃ → 存入寄存器的值为256整数PLC读到256后除以10得25.6。范围-100℃ ~ 100℃ → 映射为0 ~ 20002000个单位但寄存器最大65535足够。偏移处理负数不能直接存加1000偏移即-100℃→00℃→1000100℃→2000。最终公式reg_value (int)(temp_c * 10) 1000;PLC侧还原REAL#(MB100 - 1000) / 10.0MB100是寄存器地址5.2 湿度映射0~100%RH0.1%精度同理45.3%RH → 4530%→0100%→1000。公式reg_value (int)(humidity * 10);PLC还原REAL#MB102 / 10.05.3 寄存器地址规划4x保持寄存器寄存器地址名称数据类型说明40001温度值UINT16偏移1000单位0.1℃40002湿度值UINT16单位0.1%RH40003温度状态UINT160正常1传感器断线2超量程40004湿度状态UINT16同上这里有个易错点Modbus地址40001对应寄存器0x0000内部索引但HAL库HAL_UART_Transmit()发送时数据区第一个字节是寄存器0x0000的高字节。例如温度值2560x0100发送顺序是0x01, 0x00而非0x00, 0x01。必须确保字节序是大端Big-Endian这与x86 PC相反但符合Modbus规范。响应帧构造示例读40001-40002// 响应帧[0x01][0x03][0x04][0x01][0x00][0x01][0xC8][0xXX][0xXX] // 地址 功能码 字节数 温度高 温度低 湿度高 湿度低 CRC高 CRC低 uint8_t resp[12]; resp[0] 0x01; // 从站地址 resp[1] 0x03; // 功能码 resp[2] 0x04; // 数据字节数2寄存器×2字节 resp[3] (temp_reg 8) 0xFF; // 温度高字节 resp[4] temp_reg 0xFF; // 温度低字节 resp[5] (humi_reg 8) 0xFF; // 湿度高字节 resp[6] humi_reg 0xFF; // 湿度低字节 uint16_t crc crc16_modbus(resp, 7); // 计算前7字节CRC resp[7] (crc 8) 0xFF; resp[8] crc 0xFF; HAL_UART_Transmit(huart1, resp, 9, 100); // 发送9字节经验技巧在Modbus Poll里测试时勾选“Display as Signed Integer”会把温度值显示为带符号数如0x0100显示为256但PLC程序里要用无符号整数指令如S7-1200的MOVE读取否则负温度会解析错误。我曾因此让客户误以为-5℃显示为65531折腾半天才发现是PLC数据类型选错了。6. 现场调试避坑指南从CH340驱动失效到RS485终端电阻的致命影响即使代码完美现场调试仍可能崩盘。我把三年来踩过的坑按优先级排序全是血泪教训。6.1 CH340驱动失效不是“装了驱动就行”而是“驱动版本决定生死”CH340G芯片在Windows 10/11上存在兼容性问题。官方最新驱动v3.5.2021.12但某些OEM主板尤其联想ThinkPad自带的旧版驱动v2.x会冲突导致设备管理器里显示“未知设备”或“端口占用”。解决方案不是卸载重装而是下载CH341SER.ZIP官网提供解压后右键“ch341ser.inf”→“安装”设备管理器里找到“端口COM和LPT”→右键CH340→“更新驱动程序”→“浏览我的电脑”→“让我从列表选择”→取消勾选“显示兼容硬件”手动指定解压路径下的inf文件最关键一步在设备管理器里右键CH340→“属性”→“端口设置”→“高级”→把“IRQ”从“自动”改为一个空闲IRQ如IRQ10避免与USB控制器冲突。我曾为这个问题熬通宵最后发现是IRQ冲突导致串口接收中断丢失现象是Modbus Poll能发请求但STM32根本不响应——不是代码问题是硬件中断被屏蔽了。6.2 RS485总线终端电阻不加≠不工作但加错必丢帧RS485是差分总线理论最长1200米但实际距离受波特率和终端匹配影响。规则是总线两端必须各加一个120Ω终端电阻中间节点不加。很多人只在PLC端加传感器端不加结果在长距离300米时信号反射导致边沿畸变CRC校验失败。实测数据9600bps下无终端电阻300米线缆丢帧率15%两端加120Ω后丢帧率降至0%。但加错位置更危险——如果在中间节点如第3个传感器也加120Ω总线阻抗被拉低驱动能力不足所有节点通讯全部中断。6.3 电源地线环路PLC与STM32共地引发的“幽灵干扰”最诡异的问题Modbus通讯时好时坏用示波器看RS485波形一切正常但PLC偶尔报“超时”。根源是PLC的24V电源地与STM32的GND之间存在电位差形成地环路电流叠加在RS485差分信号上。解决方案是STM32侧RS485收发器MAX485的地必须与PLC的24V地隔离。用ADUM1201双通道数字隔离器隔离UART信号或用ADM2483集成隔离RS485芯片。成本增加5但换来100%稳定性。最后一个硬核技巧用串口调试助手如XCOM发送原始Modbus帧时十六进制输入框里输入01 03 00 00 00 02 C4 0B读40001-40002如果STM32返回01 03 04 01 00 01 C8 7A 2D说明整个链路硬件协议寄存器全通。不要依赖Modbus Poll的图形界面原始帧测试才是终极验证。这套方案已在17个工业现场落地最严苛环境是钢铁厂热轧车间环境温度65℃EMI等级Class A连续运行18个月零故障。STM32F103C8T6不是过时的玩具而是经过千锤百炼的工业基石——它的价值不在主频多高而在每一个引脚、每一行代码都经得起产线7×24小时的拷问。本文还有配套的精品资源点击获取