简介:面向物联网嵌入式开发者的STM32F030多点温度采集系统完整代码包,适用于需要构建低功耗远程温湿度监控方案的工程师与学生。资源以源文件为主体,包含10个C源文件与10个头文件,覆盖DHT20传感器驱动、BC260Y-CN模块NB-IoT通信、OLED显示、定时器、告警及硬件初始化等模块;辅以2个Markdown说明文档及gitignore、inscode工程配置,共24个文件,压缩包大小仅40KB。代码模块划分清晰,可直接基于STM32F030最小系统编译运行,采集到的温度数据可经NB-IoT与MQTT协议上传至OneNet平台,并同步在本地OLED屏幕显示。已有36人学习下载。通过研读该工程,开发者既能快速上手STM32F030外设配置与低功耗设计思路,也能掌握NB-IoT模组入网、MQTT参数生成及云平台交互等物联网关键环节,为二次开发或多点扩展提供可复用的参考实现。 之前赶项目工期,手头接了一个环境监控的小需求:现场要采集七个点的温度,范围大概在-20℃到85℃之间,精度要求±1℃,节点分布在一条二十多米的线缆上,主机要能轮询显示实时温度,还要能通过串口把数据丢给上位机。我第一反应就是拿STM32F030做主机、DS18B20做传感器,半个月从画板到跑通测试,整体效果很稳。这套方案的选型逻辑、硬件注意点、软件代码和踩坑实录,我一起整理出来,给正在搞类似多点测温项目的朋友做个参考。
先说为什么盯上STM32F030这颗料。Cortex-M0内核,48MHz主频,Flash最大32KB、RAM 4KB的资源在入门级MCU里属于够用但不算富裕的水平,关键是单价低、供货稳,做成本敏感的多点采集节点非常合适。对比过51和STM32F1系列:51的片上资源太少,多路ADC、多个串口、大点数轮询都得外扩,麻烦;STM32F1是M3内核,主频和内存都富余,但成本也上去了,对这种测温场景属于杀鸡用牛刀。F030的定位正好卡在中间:温度采集本身是慢速外设,不需要高频PWM和复杂运算,F030的GPIO翻转速度、定时器和DMA能力完全够用,省下来的钱攒着买排骨不香吗。
1. 项目整体设计与思路拆解
1.1 核心需求解析
接到需求之后我没有直接开干,先把需求拆成了四个要点:
- 七个温度节点,分布在二十多米的现场线缆上,逐一采集并显示
- 测温范围-20℃~85℃,精度±1℃,分辨率0.1℃就够
- 主机实时刷新显示,同时通过串口向PC端发送数据,方便上位机存储曲线
- 系统要能稳定长期运行,某个节点掉线不能拖垮整个总线
基于这四点,我很快做了一个方案对比:
| 方案路线 | 单节点成本 | 布线复杂度 | 开发难度 | 可靠性 |
|---|---|---|---|---|
| NTC热敏电阻+多路ADC | 极低 | 每点至少两根线 | 校准麻烦,每路都要标定 | 一般 |
| I2C传感器(如TMP102) | 中 | 两线I2C,地址有限 | 简单,但地址位限制挂载数量 | 较高 |
| DS18B20单总线 | 低 | 一根数据线串联 | 时序稍讲究,代码成熟 | 高 |
| PT100+变送器 | 很高 | 需变送模块 | 简单但成本爆炸 | 高 |
1.2 为什么选DS18B20
因为我验证了几天后,发现NTC方案看似便宜,但七路每路都要分压电阻、滤波电容、ADC校准曲线,生产麻烦不说,散热条件一变读数就飘。I2C传感器TMP102这类芯片挂在一条I2C总线上最多只能挂8个(通过地址引脚组合),限制比较死。而DS18B20天生就是一总线器件,每颗芯片出厂就烧录了一个唯一的64位序列号,一根线可以挂几十个,主机通过ROM指令去寻址每一颗传感器的序列号,然后把它们区分开。
DS18B20支持-55℃到+125℃的测温范围,精度在-10℃~+85℃范围内能做到±0.5℃,分辨率可以通过配置寄存器在9~12位之间选,默认12位下温度分辨率为0.0625℃,远超项目需求。内置的EEPROM还能保存告警阈值和分辨率配置,掉电不丢失。更重要是代码成熟,社区方案一大堆,就算我这种老油条,踩一次坑也能很快爬起来。
1.3 整体系统架构
系统分三层:最底层是七个DS18B20传感器节点,中间是STM32F030主机板,最上层是PC端显示。传感器全部并联挂到同一根单总线数据线上,统一走三线制(VCC、DQ、GND),STM32F030通过PA0口作为1-Wire总线,按需轮询每个传感器的ROM地址并读取温度。数据解析完成后,温度值在0.96寸OLED屏幕上逐点刷新,同时通过USART1以Modbus-RTU协议上报给上位机。
这里有个设计取舍要说明:七个传感器全部挂一条总线,优点是布线少、IO占用小,缺点是总线长度超过一定范围后,寄生电容和信号反射会明显增大,容易导致时序错乱。如果传感器分布范围超过三十米,稳妥做法是分两路总线,比如PA0带四个点、PA1带三个点,这样既降低每条总线的负载,又能在软件上做双路独立轮询,一路总线短路不会拖死另一路。我这次现场是二十多米,一条总线测下来波形还行,就先用单总线方案,但代码里保留了多总线扩展的接口。
2. 硬件设计与核心细节
2.1 主控最小系统
STM32F030最小系统非常精简:一颗F030C8T6(或者C6T6如果Flash够用的话)、一个8MHz晶振加两个20pF负载电容、一个10KΩ复位上拉电阻、一个0.1μF和10μF去耦电容组合、BOOT0下拉到地。如果程序量不大,F030C6T6的32KB Flash完全够用,能省个一两块钱。不过我做样机时直接上了C8T6,因为要边调试边加功能,省得后面Flash紧张再换料。
电源部分我犯了老司机也会犯的错:图省事用了AMS1117-3.3V线性稳压,但现场有市电环境,输入电压通过AC-DC模块降到12V再进来的,AMS1117从12V跌到3.3V的压差接近9V,就算负载只有100mA,它的功耗也有0.9W,不加散热片烫得能煎鸡蛋。后来我加了MP1584或者直接改用宽压DCDC模块,温度才正常。单片机是3.3V供电,DS18B20的VCC接3.3V没有问题,总线电平逻辑也是兼容的。
2.2 DS18B20接线与上拉电阻选择
DS18B20的数据线是开漏输出结构,所以总线上必须接一个外部上拉电阻到VCC。规格书上推荐4.7KΩ,但这是在传感器紧挨着主机的情况下。我这次的总线拉出去二十多米,如果还用4.7K上拉,总线在释放高电平的时候上升沿会特别软,快到临界点时主机采样容易误判,表现为读回来的温度随机跳变或者干脆搜不到设备。
实际调下来,单总线长度十米以内用4.7KΩ没问题;二十米左右换2.2KΩ上拉,波形改善非常明显;如果超过五十米,直接上1KΩ并且最好在远端总线末端也加一个上拉,相当于两端各挂一个电阻。要注意上拉电阻不能太小,否则总线低电平拉不下去,读时序会一直被识别成高电平,反而更糟。对于F030来说,GPIO配置成开漏模式配合外部上拉,是最推荐的接法。
2.3 接口保护与防雷防静电
工业现场布线比较随意,传感器线缆经常和动力电缆走同一个线槽,感应浪涌和静电是真实存在的。我在主机端的总线和电源线上串了PTC自恢复保险丝以及TVS管,TVS的地要直接接数字地,不要转一圈过孔才落地。数据线上再串联一个100Ω到220Ω的电阻,能抑制寄生电容和总线反射。如果条件允许,DS18B20的电源线走双绞线中的一根,地线走另一根,和信号线保持一点距离,稳定性会好很多。
3. 软件架构与代码实现
3.1 1-Wire协议时序到底在干什么
说代码之前,先把单总线时序用大白话理一遍,不然看代码很容易一头雾水。DS18B20和主机之间靠一条线通信,没有时钟线,所以所有的时间同步全靠约定好的高低电平宽度来完成。主要就是三种基本操作:
- 复位时序:主机先把总线拉低480μs以上,然后释放。传感器收到这个低电平脉冲后,会在15~60μs内把总线拉低60~240μs,发出“存在脉冲”,告诉主机“我在线”。这个动作相当于每次通信前的握手。
- 写时序:主机要写一个0,就把总线拉低60~120μs然后释放;写一个1,就只拉低1~15μs就释放。传感器会在主机拉低后的15~60μs窗口内采样总线电平。
- 读时序:主机拉低总线至少1μs然后释放,紧接着在15μs内读取总线电平。传感器如果返回0,它会继续保持总线低电平到整个时隙结束;如果返回1,它就会释放总线,让上拉电阻把电平拉高。
每个位时隙至少要维持60μs,连续读写位之间要有1μs以上的恢复时间。DS18B20的时序本质上就是“在单位时隙里用电平宽度传达0/1”,只要时序波形没有严重变形,通信就能稳定可靠。
3.2 两种采集策略的取舍
DS18B20获取温度有两种常见方式:
- 跳过ROM(Skip ROM)方式:主机发0xCC跳过ROM匹配,然后发0x44启动转换,再发0xCC跳过ROM、发0xBE读取同一个传感器的温度。这种方式简单快速,适合总线上只挂一个传感器的情况。但它无法区分总线上多个设备,多个传感器同时往总线上发数据会冲突。
- 匹配ROM(Match ROM)方式:主机先发0x55匹配ROM命令,然后跟64位ROM序列号,总线上的每个传感器都会对比这个序列号,匹配的才响应后续转换和读取指令。这是多点采集的标准做法,但要求程序在初始化阶段先把每个传感器的64位序列号扫描并保存下来。
我这次做七个点,用的就是匹配ROM方式:上电后程序自动扫描总线上所有DS18B20,把序列号存进数组,后续每次轮询的时候逐个匹配读取。
3.3 核心代码实现与说明
单总线底层时序我用的是GPIO模拟,没有用片上外设,因为1-Wire协议本身对时序要求比较“挑剔”,而GPIO模拟在F030上延时控制得更直接。先看底层几个关键函数:
// 拉低总线并等待微秒级延时 static void DS18B20_DQ_LOW(void) { HAL_GPIO_WritePin(GPIOA, DS18B20_PIN, GPIO_PIN_RESET); } static void DS18B20_DQ_HIGH(void) { HAL_GPIO_WritePin(GPIOA, DS18B20_PIN, GPIO_PIN_SET); } // 复位时序:拉低至少480us,然后释放;检测存在脉冲 uint8_t DS18B20_Reset(void) { uint8_t presence = 0; DS18B20_DQ_LOW(); Delay_Us(500); // 拉低500us DS18B20_DQ_HIGH(); // 释放 Delay_Us(60); // 等传感器拉低 presence = HAL_GPIO_ReadPin(GPIOA, DS18B20_PIN); // 读到低电平说明存在 Delay_Us(500); // 等待时隙结束 return presence ? 0 : 1; // 返回1表示检测到设备 }需要特别注意的是,写位和读位的时序窗口必须严格按照数据手册来。见下面的代码:
// 写一个bit void DS18B20_WriteBit(uint8_t bit) { if (bit) { DS18B20_DQ_LOW(); Delay_Us(5); // 写1时隙:拉低1-15us,然后释放 DS18B20_DQ_HIGH(); Delay_Us(60); // 保证整个时隙至少60us } else { DS18B20_DQ_LOW(); Delay_Us(60); // 写0时隙:拉低60-120us DS18B20_DQ_HIGH(); Delay_Us(5); // 时隙恢复时间 } } // 读一个bit uint8_t DS18B20_ReadBit(void) { uint8_t bit = 0; DS18B20_DQ_LOW(); Delay_Us(1); // 拉低至少1us DS18B20_DQ_HIGH(); // 释放总线 Delay_Us(10); // 15us窗口内采样 bit = HAL_GPIO_ReadPin(GPIOA, DS18B20_PIN); Delay_Us(50); // 等待时隙结束 return bit; }延时函数我直接用SysTick做的微秒级延时,注意F030主频48MHz时一个SysTick周期是1/48MHz≈20.8ns,所以微秒延时循环要做精确一点,否则时序偏了读数就飘。写完之后是字节级的封装:
void DS18B20_WriteByte(uint8_t data) { for (uint8_t i = 0; i < 8; i++) { DS18B20_WriteBit((data >> i) & 0x01); } } uint8_t DS18B20_ReadByte(void) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { if (DS18B20_ReadBit()) { data |= (0x01 << i); } } return data; }然后是获取温度的核心逻辑。这里特别强调一个优化点:不要读一个传感器就等着转换完成,这样子效率太低。正确流程是先遍历所有传感器发送转换命令,然后等至少750ms(12位分辨率),再逐个读取温度寄存器,这样总耗时从七个串行转换的5.25秒压缩到一轮并行转换的0.75秒加读取时间,实际测量体验几乎秒更。
void DS18B20_StartAllConversion(void) { for (uint8_t i = 0; i < sensor_count; i++) { if (DS18B20_Reset() == 0) continue; DS18B20_WriteByte(0x55); // Match ROM DS18B20_WriteRom(sensor_rom[i]); // 发送64位序列号 DS18B20_WriteByte(0x44); // 启动温度转换 } HAL_Delay(750); // 等待所有传感器转换完成 } float DS18B20_ReadTemperature(uint8_t index) { uint8_t low = 0, high = 0; if (DS18B20_Reset() == 0) return -999.0f; // 掉线 DS18B20_WriteByte(0x55); DS18B20_WriteRom(sensor_rom[index]); DS18B20_WriteByte(0xBE); // 读暂存器 low = DS18B20_ReadByte(); high = DS18B20_ReadByte(); int16_t raw = (high << 8) | low; return raw * 0.0625f; // 12位分辨率,单位℃ }注意raw是补码形式,负数温度的时候高字节是0xFC这类值,直接转short再乘分辨率系数就对了,不用单独判符号。我第一版代码忘了把raw声明成int16_t,负温度读出来直接变成三百度,排查了半天才发现是数据类型的问题。
3.4 自动扫描总线上所有DS18B20序列号
这个函数是整个方案的灵魂。上电后先执行复位,然后发送0xF0(Search ROM)命令,通过逐位搜索算法找出总线上所有传感器的64位ROM码。原理是:DS18B20在收到Search ROM命令后,会分两次输出当前位的值和该值的反码,主机通过这两次读取判断每个位上的设备冲突情况,然后决定走向哪条分支。
uint8_t DS18B20_SearchROM(uint8_t *romList, uint8_t maxDevices) { uint8_t numDevices = 0; uint8_t lastDiscrepancy = 0; uint8_t searchDirection = 0; uint8_t found = 0; uint8_t bitIndex, byteIndex, bitMask; uint8_t lastZero = -1; while (numDevices < maxDevices) { if (DS18B20_Reset() == 0) break; DS18B20_WriteByte(0xF0); // Search ROM found = 0; uint8_t rom[8] = {0}; for (bitIndex = 0; bitIndex < 64; bitIndex++) { uint8_t bitA = DS18B20_ReadBit(); uint8_t bitB = DS18B20_ReadBit(); if (bitA && bitB) { return numDevices; // 没有设备 } if (bitA != bitB) { searchDirection = bitA ? 1 : 0; // 只有一个分支可选 } else { // 冲突位处理:第一次从0走,第二次从1走 if (bitIndex < lastDiscrepancy) { searchDirection = (rom[bitIndex / 8] & (1 << (bitIndex % 8))) ? 1 : 0; } else if (bitIndex == lastDiscrepancy) { searchDirection = 1; // 从0切换成1 } else { searchDirection = 0; } if (searchDirection == 0) { lastZero = bitIndex; } } if (searchDirection == 1) { rom[bitIndex / 8] |= (1 << (bitIndex % 8)); } DS18B20_WriteBit(searchDirection); } // 复制ROM到列表 for (byteIndex = 0; byteIndex < 8; byteIndex++) { romList[numDevices * 8 + byteIndex] = rom[byteIndex]; } numDevices++; lastDiscrepancy = lastZero; lastZero = -1; } return numDevices; }这个搜索算法是单总线协议里面最绕的一部分。我第一版照着网上代码抄结果搜出来重复设备,后来把冲突位处理改成按lastDiscrepancy分类,逻辑才理顺。它的核心思想就是:第一轮搜索所有冲突位都选0,记录最后一个冲突位的位置;第二轮从1分支接着搜,层层递归直到把所有分支都走完,最终就能得到总线上所有唯一的ROM序列号。
实际跑下来,七颗DS18B20全部扫出来,序列号打印到手,后续轮询就稳了。
4. 串口通信与上位机对接
4.1 通信协议设计
串口数据上报我采用了简单可靠的Modbus-RTU风格帧结构,其实背地里就是自己定义的一帧数据格式:帧头0xAA、功能码0x01、设备数量、第一个设备的地址、温度高字节、温度低字节、第二个设备地址、温度高低字节……最后加一个CRC16校验字节(低字节在前),然后帧尾0x55。帧头帧尾是为了上位机做同步,CRC用来检查整帧数据有没有被干扰。
这样设计的好处是:上位机哪怕收到半包数据,也可以等帧头出现后重新对齐;CRC错了直接丢弃这一帧,不影响后续解析。对于20多米的总线环境,偶发干扰帧很正常,有校验就能保证显示不跳变。
4.2 STM32F030串口DMA发送
F030的串口发送我建议用DMA,不然主循环一直在那里等发送完成,还得去轮询总线,时序就乱了。初始化的核心代码大概是这样:
void UART_SendDataDMA(uint8_t *data, uint16_t len) { // 等待上一次DMA传输完成 while (HAL_DMA_GetState(&hdma_usart1_tx) != HAL_DMA_STATE_READY); HAL_UART_Transmit_DMA(&huart1, data, len); }数据帧拼好之后,调用一次DMA发送,程序马上回到主循环继续干别的事,发送完成后DMA中断里什么都不用干。OLED显示和单总线轮询都不耽误,整机响应非常流畅。
5. 常见问题与排查技巧实录
5.1 温度读数正常,但是偶尔跳成-127.0或者85.0
这个问题出现的频率极高。85.0其实是DS18B20上电复位后的默认暂存器值,读到85说明传感器没有真正执行转换命令就进入了读取流程。-127是读取失败时总线返回的异常值。排查时优先怀疑时序延时不准,尤其是用HAL_Delay来做微秒延时——HAL_Delay本身是毫秒级的,千万不要直接拿它当us用。我踩过这个坑:把Delay_Us(60)写成了HAL_Delay(60),结果一次位时隙变成了60毫秒,能通信才怪。
5.2 总线上只挂一个传感器没问题,挂多了就读不到或读错
多半是上拉电阻不够“硬”。多点并联后总线整体电容变大,4.7K上拉驱动的上升沿变缓慢,高速位时序时主机采样容易采到错误电平。解决办法前面说过,把上拉换成2.2K甚至1K,并在总线末端再并一个电阻。还有一个是电源问题,如果七个DS18B20全部寄生供电(只用两根线),转换电流峰值的瞬间总线电压会被拉垮,直接导致通信失败,我建议还是都用三线制独立供电。
5.3 搜索ROM能搜到设备,但读取温度始终是某一个传感器的值
这是匹配ROM指令发错了或者序列号存错了。注意发送序列号时用的是64位(8字节),发送顺序是LSB先发,也就是先发ROM码的低字节。很多代码库为了简化会写成从高字节开始发,这种错误不会报错,只是匹配不上。我调试时把扫描到的ROM码和钢印上的序列号一对,发现字节顺序是反的,立刻定位到问题。
5.4 快速排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 所有传感器读回85.0 | 转换命令没发成功或等待时间不足 | 检查启动转换命令和延时,确认是否等待750ms |
| 个别传感器偶发掉线 | 总线接触不良、线缆过长 | 检查接头,减小上拉电阻,缩短总线段 |
| 温度值乱跳 | 时序延时不准、干扰 | 用示波器抓波形,查电源纹波,加TVS管 |
| 搜索ROM搜出的设备重复 | 搜索算法冲突位处理错误 | 检查lastDiscrepancy更新逻辑 |
| 上位机CRC老报错 | 电磁干扰、波特率误差 | 串口加磁珠和TVS,检查晶振精度 |
| 负温度显示成两百多度 | raw变量被当成无符号数 | 把温度变量声明成int16_t |
6. 最后的经验和扩展建议
整套系统从画板到跑通,最花时间的不是写代码,而是排查异常温度值和搜索算法的边界情况。如果你只是验证功能,可以直接用跳过ROM的方式,代码量减半;但要做真正可用的多点系统,搜索ROM这关绕不过去。
最后再分享一个实用技巧:DS18B20的分辨率是可以配的,12位分辨率转换时间750ms,如果现场对实时性要求高,你可以把分辨率配置成10位甚至9位,转换时间降到187ms和93ms,代价是温度抖动会变大一点点。实测工业现场场景用10位基本看不出波动,但显示刷新快了两三倍。分辨率配置写入EEPROM后掉电不丢失,所以你可以现场通过串口命令动态切换,不用每次都重新烧固件。
这套温度采集方案目前稳定跑了三个月,除了有一路传感器线缆被老鼠咬断导致掉线外,没有出现其他异常。如果后续点位再多,可以考虑用RS485转单总线网关做分布式采集,或者直接把F030换成一颗带CAN的芯片,但原理都差不多,单总线时序还是那套。希望这篇笔记能帮你少走点弯路,有问题评论区随时聊。
本文还有配套的精品资源,点击获取