ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM8S与SHT30实现Modbus RTU温湿度传感器从站完整指南

STM8S与SHT30实现Modbus RTU温湿度传感器从站完整指南 简介基于STM8S单片机与SHT30温湿度传感器构建的MODBUS通信工程源码面向嵌入式开发者和物联网环境监测项目解决8位MCU上温湿度采集与标准化协议输出的问题。压缩包共82个文件以.h头文件和.c源文件为主含IAR工程配置.ewp/.ewd、批处理脚本、调试日志及STM8标准外设库文档整体仅3.81MB目录结构清晰便于按模块查阅。已有407人学习查看。工程为V2.01版本完整实现SHT30的I2C驱动、温湿度读取、MODBUS RTU从站通信、CRC校验与错误处理并配有中断服务函数和GPIO底层代码可以直接在IAR for STM8中编译调试也可以快速移植到其他STM8项目或作为MODBUS从站开发模板是理解工业传感器节点开发并快速落地的实用参考。1. 为什么一个温湿度传感器还要跑 Modbus 从站协议WSD_04_V2_STM8S_SHT30 这个名字把整条链路写得明明白白STM8S 单片机挂一颗 SHT30 温湿度传感器再用 Modbus 协议通过外部接口把测量结果送出去。很多做 IOT 项目的人第一反应是传感器模块不是现成的吗其实大量成品温湿度探头内部正是用一颗小单片机做数据采集和协议翻译对外只开放 RS485 总线。你拿到的不是一个“读 I2C”的裸传感器方案而是一个“让现场 DCS 或组态软件能直接识别”的从站设备。真正决定交付速度的不是库函数而是三件事SHT30 的 16 位原始数据在什么时序下有效、Modbus RTU 的帧边界怎么确认、IAR 工程里编译出来的代码能不能稳定跑在目标芯片上。我按实际做从站的方式从 I2C 读数讲到 CRC 校验再到 485 方向控制最后给出三个验收通信的土办法。2. 先定传输底座SHT30 在 STM8S 上的 I2C 读法和数据换算2.1 SHT30 的从机地址与测量命令SHT30 是 Sensirion 的数字温湿度传感器I2C 从机地址由 ADDR 引脚决定接地时是 0x44接 VCC 时是 0x45。STM8S 的 I2C 地址寻址是 7 位模式而习惯性写法里经常能看到addr 1那是因为库函数里仍要留出最低位作为读写方向位。第一次对接的人常在这里把地址写反拿 0x88 当从机地址发命令结果总线上一直等不到 ACK。单次测量模式里我最常用 0x2C 0x06 这条命令代表“带时钟延展的高重复性测量”。SHT30 收到命令后把 SCL 拉低表示正在转换转换结束后释放 SCL主机再读 6 字节数据。对 STM8S 这种没有复杂 I2C 状态机调试界面的主控我一般用带时钟延展的方式代码里多检查一次忙标志即可不需要掐表算延迟。命令字含义适用场景0x2C 0x06高重复性带时钟延展仓储、机房等需要稳定读数的场景0x2C 0x0D中重复性带时钟延展电池供电、轮询间隔短0x2C 0x10低重复性带时钟延展快速扫描功耗最低0x24 0x00高重复性不带时钟延展需要自己保证 SCL 时序0xE0 0x00软复位传感器死机后恢复要注意测量周期和重复性有关。高重复性典型转换时间约 12.5ms低重复性约 4.5ms两次单次测量间隔太短时读回来的值还是上一轮的旧数据。程序里要么维护一个measure_tick要么靠 I2C 时钟延展判断转换结束不能在读函数外面随意加一个 1ms 延时了事。2.2 I2C 读 6 字节的 STM8S 代码STM8S 的标准外设库把 I2C 封装得比较接近寄存器。我习惯写一个sht30_read函数把启动、地址、命令和读数据集中在一起。关键点是 SHT30 要求先发两个字节命令再用重复起始位切到读方向不能像 EEPROM 那样发送寄存器地址后直接连续读。#include i2c.h #define SHT30_ADDR_7BIT 0x44u uint8_t sht30_read(uint16_t cmd, uint8_t *buf, uint8_t len) { uint8_t i; I2C1_GenerateSTART(ENABLE); while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_MODE_SELECT)); I2C1_Send7bitAddress(SHT30_ADDR_7BIT, I2C1_Direction_Transmitter); while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C1_SendData((uint8_t)(cmd 8)); /* cmd 高字节先发 */ while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_BYTE_TRANSMITTED)); I2C1_SendData((uint8_t)(cmd 0xFF)); while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_BYTE_TRANSMITTED)); I2C1_GenerateSTART(ENABLE); /* repeated START */ while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_MODE_SELECT)); I2C1_Send7bitAddress(SHT30_ADDR_7BIT, I2C1_Direction_Receiver); while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_RECEIVER_MODE_SELECTED)); for (i 0; i len; i) { if (i len - 1) { I2C1_AcknowledgeConfig(I2C1_ACK_Disable); /* 读最后一字节前关 ACK */ } while (!I2C1_CheckEvent(I2C1_EVENT_MASTER_BYTE_RECEIVED)); buf[i] I2C1_ReceiveData(); } I2C1_AcknowledgeConfig(I2C1_ACK_Enable); I2C1_GenerateSTOP(ENABLE); return 0; }代码里的I2C1_ACK_Disable很多人会漏。I2C 主模式读多个字节时除最后一字节外都要回 ACK最后一字节要回 NACK 通知从机停止发送。如果一直开着 ACKSHT30 会认为主机还要继续读SCL 释放逻辑就乱了。另一个容易忽略的是命令字高字节先发SHT30 数据手册里所有 16 位命令都标了 MSB first。读回来的温度若在 0xFFFF 附近跳优先检查 SCL 上拉电阻是否过小或者 I2C 速度是否超过了 400k。2.3 从原始 16 位数据换算到温度湿度SHT30 的 6 字节数据格式为温度高字节、温度低字节、CRC、湿度高字节、湿度低字节、CRC。前两个 16 位数据都是无符号整数范围 0 到 65535对应物理量程 0% 到 100% RH 以及 -45℃ 到 175℃。温度和湿度的线性换算公式为int16_t temp_x100; uint16_t hum_x100; int32_t raw_t; uint32_t raw_h; raw_t ((int32_t)buf[0] 8) | buf[1]; raw_h ((int32_t)buf[3] 8) | buf[4]; /* 温度0xFFFF 对应 175℃0x0000 对应 -45℃ */ temp_x100 (int16_t)(((int32_t)17500 * raw_t) / 65535L - 4500); /* 湿度0xFFFF 对应 100%0x0000 对应 0% */ hum_x100 (uint16_t)(((int32_t)10000 * raw_h) / 65535L);换算时我统一用×100的定点方式是因为 Modbus 寄存器里只放整数而 float 在 STM8S 这种没有硬件 FPU 的 8 位机上会拖慢主循环。temp_x100相当于 26.53 存成 2653上位机除以 100 即可。如果产品确实要 0.01℃ 分辨率直接用 uint16_t 乘 100不需要引入浮点。读回来之后也不要跳过 CRC 校验。SHT30 的 CRC 是 CRC-8多项式 0x31初值 0xFF温度低字节后和湿度低字节后各跟一个校验字节。校验不过说明总线干扰或芯片损坏。没有校验的代码在实验室能正常放到有变频器的现场就会出现温湿度偶尔跳到 -40 度的假故障。3. 用 Modbus RTU 把温湿度装进寄存器帧解析与 CRC 校验3.1 RTU 帧从站地址、功能码、数据区、CRC 的边界Modbus 协议分 ASCII 和 RTU 两种传输模式8 位单片机上的温湿度仪表九成用 RTU。RTU 的每帧之间靠静默间隔区分标准定义是大于 3.5 个字符时间帧内相邻字节间隔必须小于 1.5 个字符时间。如果帧内间隔超过 1.5T接收端会把这一帧拆成两段虽然 16 位 CRC 能拦截绝大多数错帧但错误帧已经进入了解析流程现场会表现为偶发超时。帧字段长度方向例子从站地址1 字节主机 - 从机0x01功能码1 字节主机 - 从机0x03起始寄存器地址2 字节主机 - 从机0x00 0x01寄存器数量2 字节主机 - 从机0x00 0x02CRC162 字节双向低字节在前响应字节数1 字节从机 - 主机0x04数据区N 字节从机 - 主机温度高字 温度低字 湿度高字 湿度低字RTU 的 CRC 在总线上是低字节在前。也就是说响应里的 CRC 应该先发低 8 位再发高 8 位。用上位机对照调试时很多人把 CRC 字节顺序搞反导致从站发出的帧在上位机侧一直通不过校验。3.2 功能码 03 和 06保持寄存器的最小实现温湿度传感器从站不需要完整 Modbus 协议栈把 03 读保持寄存器和 06 写单寄存器做好就能对接组态软件。我习惯把温度放在寄存器 0x0001湿度放在 0x0002从站地址放在 0x0100这样既方便默认值统一也方便后期用组态软件直接改地址。#define REG_TEMP 0x0001 #define REG_HUM 0x0002 #define REG_ADDR 0x0100 void modbus_process_frame(uint8_t *rx, uint8_t rx_len, uint8_t *tx, uint8_t *tx_len) { uint16_t crc_calc; uint8_t slave rx[0]; uint8_t func rx[1]; *tx_len 0; if (slave ! g_slave_addr slave ! 0xFF) /* 0xFF 为广播地址 */ return; crc_calc crc16_modbus(rx, rx_len - 2); if ((rx[rx_len - 2] ! (uint8_t)(crc_calc 0xFF)) || (rx[rx_len - 1] ! (uint8_t)(crc_calc 8))) { return; /* CRC 错不回响应 */ } if (func 0x03) { uint16_t start (rx[2] 8) | rx[3]; uint16_t count (rx[4] 8) | rx[5]; if (count 2) return; tx[(*tx_len)] slave; tx[(*tx_len)] 0x03; tx[(*tx_len)] (uint8_t)(count * 2); if (start REG_TEMP) { tx[(*tx_len)] g_temp_x100 8; tx[(*tx_len)] g_temp_x100 0xFF; tx[(*tx_len)] g_hum_x100 8; tx[(*tx_len)] g_hum_x100 0xFF; } else if (start REG_ADDR) { tx[(*tx_len)] 0x00; tx[(*tx_len)] g_slave_addr; } } else if (func 0x06) { /* 写单个寄存器可用于修改从站地址 */ } }这段代码刻意省略了异常响应只保留两个关键判断地址不符直接返回CRC 错误不回响应。Modbus 从站在坏帧上回异常码容易让上位机误以为通信已经建立正确做法是坏帧直接丢弃。广播地址 0xFF 只接受功能码不产生响应如果把它也当成普通从站地址主机会一直等不到回复。3.3 CRC16-MODBUS 的实现逐位比查表更可控CRC16-MODBUS 的生成多项式是 0x8005初值 0xFFFF。逐位实现占用 flash 少很适合 STM8S 这种小容量芯片查表法速度快但 256 字节表在 8KB flash 的项目里会带来压力。Modbus 帧最长不过几十字节逐位计算时间远小于 3.5T 的帧间间隔所以我选逐位算法。uint16_t crc16_modbus(uint8_t *data, uint8_t len) { uint16_t crc 0xFFFF; uint8_t i; while (len--) { crc ^ *data; for (i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; /* 0x8005 的逆序形式 */ else crc 1; } } return crc; }注意len的类型是 uint8_t调用时确保数组长度不要超过 255否则高位会被截掉。CRC 需要覆盖从地址开始到数据区结束的全部字节不包括 CRC 本身。现场调试最快的方法是把接收帧和计算后的 CRC 一起打印到串口再和 Modbus 工具显示的 CRC 逐字节比对。3.4 用 UART 空闲中断还是定时器帧边界判断的取舍RTU 接收有 1.5T 和 3.5T 两个时间参数但 8 位单片机用两个独立定时器去卡时间点有点浪费。我试过 UART 空闲中断也试过定时器超时最后长期保留的是定时器方式。STM8S 的 UART1 有 IDLE 中断总线空闲时触发这个方案的坑是空闲中断在接收第一个字节前也可能触发需要在中断里用 RXNE 标志过滤空帧。定时器方案是在每个字节接收中断里重置一个 4ms 软件定时器主循环扫描定时器是否超时。9600 波特率下 3.5T 约 3.65ms取 4ms 不会误判19200 波特率下可以改到 2ms。如果间隔过短上位机偶尔卡顿帧会被拆成两段CRC 错误率上升。INTERRUPT_HANDLER(UART1_RX_IRQHandler, 18) /* 向量号以芯片头文件为准 */ { rx_buf[rx_len] UART1_ReceiveData8(); timeout_tick 0; /* 每收一字节重置超时 */ } INTERRUPT_HANDLER(TIM2_UPD_OVF_BRK_IRQHandler, 13) { if (timeout_tick 0xFFFF) timeout_tick; if (timeout_tick 4 rx_len 0) { frame_ready 1; /* 4ms 无新字节判定一帧完成 */ } }这里timeout_tick 4是以定时器中断周期为单位的具体数值要按实际波特率换算。主循环处理完帧后必须主动清零 rx_len 和 frame_ready否则下一次帧会拼接在旧帧后面。把帧边界判断全部交给中断虽然简单但也要控制中断里做的事情越少越好否则接收间隙会被其他中断挤掉。4. 把工程放到 IAR 里STM8S 时钟、RS485 方向和常见报错4.1 时钟配置为什么跑 2MHz 反而比 16MHz 更稳STM8S 内部 HSI 是 16MHz复位后默认 2MHz。很多人一上来就把主频切到 16MHz然后发现 I2C 时序漂、Modbus 收发误码。对于 SHT30 和 Modbus RTU 这两种外设我建议系统时钟保持 2MHz 或 4MHz把分频误差降到最低。用 IAR 工程初始化时直接切 HSI 再设置分频CLK_HSIPrescalerConfig(CLK_PRESCALER_HSIDIV4); /* HSI 16MHz / 4 4MHz */ CLK_SYSCLKConfig(CLK_SYSCLKSOURCE_HSI); CLK_SYSCLKDivConfig(CLK_SYSCLKDiv2); /* 4MHz / 2 2MHz */时钟频率直接决定 UART 和 I2C 的预分频系数。以 115200 波特率为例2MHz 主频下误差比较大16MHz 下反而容易整分频9600 波特率则两种主频都很准。所以在工程里选波特率要注意和主频配对9600 以下用 2MHz19200 用 4MHz115200 再考虑 16MHz。盲目冲高频并不会让传感器更快只会让信号线变成天线。4.2 RS485 方向控制用硬件自动切换还是 GPIO 手动切外部接口大多是 RS485 总线A/B 差分信号经过 MAX3485 或 SP485 转成 TTL。方向控制有两种DE/RE 接同一个 GPIO发送前置高发送后置低或者把 DE 和 UART 的 TX 反相连接硬件自动切换。硬件切换省一路 GPIO但起始位会让总线短暂出现回声长线缆时从站容易把自己的发送内容误收进来所以我更常手动控制。正确顺序不是“拉高 DE 后延时再发”而是“把要发的字节写入发送寄存器后置高 DE清 TC 标志等最后一个字节移位完成后再拉低”。如果每字节都去拉一次 DE485 芯片在换向时会在总线上多发出毛刺主站会收到异常帧。void uart_send_frame(uint8_t *data, uint8_t len) { uint8_t i; DE_PIN_HIGH(); UART1_ClearFlag(UART1_FLAG_TC); for (i 0; i len; i) { UART1_SendData8(data[i]); while (UART1_GetFlagStatus(UART1_FLAG_TXE) RESET); } while (UART1_GetFlagStatus(UART1_FLAG_TC) RESET); DE_PIN_LOW(); /* 必须等最后一个字节移位完 */ }这里要特别区分 TXE 和 TC。TXE 表示发送数据寄存器已空但移位寄存器可能还在工作TC 表示整个字节已经从 TX 引脚发完。只等 TXE 就拉低 DE最后一个字节的后半段会被 485 芯片截断。高低电平切换本身也有建立时间所以 DE 控制引脚尽量靠近 485 芯片的 DE 引脚走线越短越好。4.3 IAR 下烧录调试时容易撞上的三个问题用 IAR for STM8 打开一个现成工程最容易卡在芯片型号和链接文件不匹配。Project - Options - General Options - Target 里选择具体型号比如 STM8S103F3Linker 配置会跟着变。如果复制了别人的工程但换了芯片链接时会有大量 undefined symbol那多半是芯片配置没改。现象常见原因IAR 里的落点fatal error[lms001]: license check failed许可证绑定失效或试用过期Help - License Manager 查看本机状态后重新处理授权烧录时报 Device is busy复位脚被调试器占用或低功耗模式没唤醒按住复位再点 Download或先关闭低功耗程序跑到 0x01 地址就复位中断向量被复用或链接脚本配错 flash 段检查 Linker 的 Vector Table 起始地址lms001 错误不一定是环境坏了也可能是把工程拷贝到另一台电脑后许可证机器码变了。处理授权时不要改系统时间那会让许可证计入离线模式并锁死。最稳妥的是使用 IAR 自带的 License Manager 重新生成绑定文件再覆盖到安装目录下的 license 配置。4.4 外部接口的 TVS 和终端电阻不要让软件去扛硬件缺陷Modbus 工程里如果波特率不高但数据偶尔错一位很大概率不是程序问题而是 A/B 线上缺了终端电阻。规范做法是链路两端各并一个 120 欧姆电阻从站 485 芯片 A/B 之间加 10k 欧姆偏置电阻A 上拉到 5VB 下拉到 GND。终端电阻过小会让驱动电流增大过大会失去匹配作用120 欧姆是在标准双绞线特性阻抗下的折中值。软件层面能配合的只有错误重试。从站收到 CRC 错误帧后不回响应主站会在下一轮询周期重试这能掩盖大多数瞬态干扰。但从站侧不要自己做“重发上次响应”那会把主站状态机弄乱因为主站重发的是请求不是响应。5. 用 Modbus Poll 当主站验收温湿度从站的三种方法5.1 用 Modbus Poll 建立连接先看 CRC 错误Modbus Poll 是调试主站工具适合从站开发完后的第一轮验证。新建串口连接时选择 RTU 模式、串口号和波特率从站地址填 1功能码选 03寄存器地址填 0x0001数量填 2。Modbus Poll 默认 1000ms 轮询一次先跑 5 分钟右下角的通信计数里 CRC 错误应始终为 0。如果看到 Time Out不一定是从站没回复也可能是地址或功能码不匹配。打开 Debug 报文日志看原始帧里从站是否回了相同请求号和功能码。回复帧长度不对时Modbus Poll 会提示 Received Bytes less than requested这时去查从站发送缓冲区多半是把字节数填在了错误位置。5.2 通过 RS485 转串口抓裸帧核对 CRC 低字节在前在没有现成主站的场合用 USB 转 RS485 适配器接到 A/B 差分线上串口助手以十六进制显示。先给模块发一条读温湿度请求01 03 00 01 00 02 ?? ??0x01 是从站地址0x03 是功能码0x0001 是起始寄存器0x0002 是寄存器数量最后两个问号是用第 3.3 节crc16_modbus()算出来的 CRC发送顺序是低字节在前。抓回来的响应最后两个字节要和 CRC 计算工具算出的结果一致。这个验证能暴露上位机不报错的软故障。比如从站把温度存成有符号数上位机虽然能收到但显示值在 0℃ 附近会从 65.5 跳回 -45。把原始寄存器值和现场实测值放在同一张表里偏差如果超过 0.1℃就要回第 2.3 节检查换算公式。5.3 波动性测试一分钟内看温湿度是否跳变最后做的是波动测试。给传感器通电放在空调出风口或者用手捂住让 SHT30 每 500ms 更新一次Modbus Poll 连续记录 60 个点。正常从站输出应是平滑曲线如果出现 26.53 - 58.12 - 26.54 这类瞬间跳变先拉长 I2C 时钟延展等待时间再确认每次测量前是否清理了 busy 标志。另一种常见跳变来自波特率偏差。把主站波特率改成 9600长帧读多个寄存器时最后一个字节会随机出错。这时不要怀疑 SHT30用逻辑分析仪接在 TX 引脚上量一个字节的实际位宽和理论位宽对比。实测位宽偏大或偏小超过 2%回到第 4.1 节重新核对主频分频而不是在代码里加延时。这个方法还能验证地址冲突两台从站设成同一个地址时主机读请求会收到两个从站响应混在一起后 CRC 必然失败。把备用从站断电CRC 错误立刻消失就说明地址没烧进去或默认地址没改成 1。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进