ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于STM32与DS18B20的无线温度计设计:从选型到组网实战

基于STM32与DS18B20的无线温度计设计:从选型到组网实战

1. 项目缘起与整体设计思路

无线温度计这个东西,听起来简单,做起来坑不少。我最早接触这类需求是在一个冷链仓储的场景里,客户需要在冷库的不同货架位置布点测温,布线成本高得离谱,而且低温环境下线缆外皮容易脆裂。当时第一反应就是上无线方案,主控选STM32,测温用DS18B20,显示用OLED,数据回传用NRF24L01,本地再加一颗FLASH做掉电存储。这套组合不是拍脑袋定的,而是经过了几轮选型和实测之后沉淀下来的方案。

先说说为什么是STM32。市面上做温度采集的可选主控很多,8位的51、32位的STM32、甚至ESP32都行。但如果你要考虑多路传感器轮询、OLED刷新、无线收发、FLASH读写这几个任务并行跑,51的算力和RAM就捉襟见肘了。STM32F103C8T6这颗芯片,72MHz主频、20KB RAM、64KB Flash,价格又便宜,社区资料铺天盖地,拿来做一个温度计绰绰有余。而且它的外设资源刚好够用:多个定时器可以做DS18B20的微秒级时序,SPI接口接NRF24L01和FLASH,I2C接口接OLED,分工明确不打架。

DS18B20的选择理由更直接。这颗传感器是单总线数字输出,不需要外部ADC,出厂校准过,测温范围-55到+125摄氏度,精度在-10到+85度区间是正负0.5度。对于仓储、农业大棚、室内环境监测这些场景完全够用。而且它是单总线协议,理论上一条IO口可以挂多个传感器,这对多点测温的场景非常友好。当然,单总线也意味着时序要求严格,后面会详细讲。

OLED选的是0.96寸I2C接口的SSD1306方案。为什么不用LCD?因为OLED自发光,可视角度大,低温下响应速度比LCD快,而且不需要背光电路,整体功耗低。I2C接口只占两个IO口,接线简单。分辨率128x64,显示温度值、时间、状态信息足够了。

NRF24L01是2.4GHz频段的无线收发模块,SPI接口,成本低,通信距离在空旷环境下能到几十米,室内穿墙大概十几米。对于一个小型仓储或者家庭环境监测来说,这个距离够用。它支持多点通信,可以组星型网络,一个接收端对应多个发送端。

FLASH用的是W25Q系列SPI FLASH,容量从1MB到16MB都有。加它的目的是做数据记录,温度数据按时间戳存进去,掉电不丢。有些场景需要追溯历史温度曲线,比如冷链运输、药品存储,这个功能就是刚需。

整套系统的逻辑是这样的:STM32定时轮询DS18B20获取温度,处理后在OLED上实时显示,同时通过NRF24L01把数据发到接收端,另外按一定间隔把温度和时间戳写入FLASH。接收端可以用另一块STM32加NRF24L01和OLED,也可以用电脑加USB转串口模块配合上位机。我这边两种方案都做过,后面会分别讲。

注意:NRF24L01和W25Q系列FLASH都是3.3V供电,STM32F103的IO也是3.3V电平,可以直接连。但DS18B20虽然供电范围是3.0到5.5V,数据线在上拉电阻的选择上要注意,3.3V供电时用4.7K上拉,5V供电时也用4.7K,这个阻值在两种电压下都能保证时序稳定。

2. 核心器件选型对比与关键参数解析

2.1 主控芯片的取舍:为什么是F103C8T6

STM32家族型号繁多,F103C8T6被称为“最小系统板之王”不是没有道理的。它属于F103中容量系列,64KB Flash、20KB RAM,LQFP48封装,37个可用IO口。对比同价位的F030、F042这些低端型号,F103C8T6的外设更全,定时器更多,社区资源也更丰富。

我实际对比过几款候选:

型号主频FlashRAM定时器数量SPII2C单价区间
STM32F103C8T672MHz64KB20KB4个2个2个8-12元
STM32F030F4P648MHz16KB4KB1个1个1个4-6元
STM32F042K6T648MHz32KB6KB2个1个1个7-10元
ESP32-C3160MHz4MB400KB多个多个多个10-15元

F030的RAM只有4KB,跑OLED缓冲区和FLASH读写缓冲就快满了,而且只有一个定时器,DS18B20时序和系统滴答定时器会冲突。ESP32虽然性能强,但开发环境相对复杂,功耗也偏高,对于纯温度采集场景有点杀鸡用牛刀。F103C8T6刚好卡在性能和成本的平衡点上。

2.2 DS18B20的时序本质与挂总线注意事项

DS18B20最让人头疼的就是单总线时序。它没有时钟线,数据线既要传数据又要传时钟,全靠拉高拉低的持续时间来区分0和1。STM32跑72MHz,一个机器周期约13.9ns,而DS18B20的时序要求是微秒级的,所以必须用精确延时或者硬件定时器来配合。

关键时序参数如下:

  • 复位脉冲:拉低至少480微秒,然后释放,等待15到60微秒后检测存在脉冲
  • 写0:拉低60到120微秒
  • 写1:拉低1到15微秒,然后释放
  • 读时隙:拉低至少1微秒,然后在15微秒内采样

用HAL库的HAL_Delay做微秒延时精度不够,我一般用DWT或者定时器做微秒延时。具体做法是启用DWT的CYCCNT计数器,然后根据主频算出1微秒对应的周期数,写一个delay_us函数。这个在F103上实测很稳。

挂总线的问题更隐蔽。理论上一条总线可以挂多个DS18B20,每个器件有唯一的64位ROM地址。但实际用的时候,如果总线长度超过5米,或者挂的器件超过4个,就需要考虑总线电容和上拉电阻的匹配。我遇到过挂8个传感器时部分器件读不到的情况,后来把上拉电阻从4.7K降到2.2K,同时缩短总线长度到3米以内,问题就解决了。

实操心得:DS18B20的ROM搜索算法(1-Wire Search)在器件多的时候容易出错,建议先用单个器件读出ROM地址,然后在代码里直接匹配地址读取,避免每次上电都搜索。这样既快又稳。

2.3 OLED的I2C地址冲突与显示缓冲策略

0.96寸OLED的SSD1306驱动芯片,I2C地址通常是0x78或0x7A,取决于模块上的电阻配置。有些模块背面有地址选择焊盘,可以改地址。如果你在同一个I2C总线上挂多个OLED,就必须确保地址不同,否则会冲突。

显示缓冲方面,SSD1306的显存是128x64位,也就是1024字节。STM32F103C8T6的20KB RAM里划出1KB做显存完全没问题。我一般用u8g2库或者自己写一个简单的驱动。u8g2功能全但代码量大,自己写的话只实现需要的功能,代码更精简。

自己写驱动的核心是三个函数:写命令、写数据、刷新显存。I2C的速率建议设在400KHz,太快了有些模块跟不上,太慢了刷新会闪。实测400KHz下全屏刷新大概20毫秒,肉眼看不到闪烁。

2.4 NRF24L01的频道选择与丢包处理

NRF24L01工作在2.4GHz频段,有125个频道可选,频道间隔1MHz。默认频道76对应2.476GHz。这个频段和WiFi有重叠,如果周围WiFi密集,丢包率会上升。我的做法是先用一个简单的扫描程序,看哪些频道底噪低,然后选一个相对干净的频道。

丢包处理方面,NRF24L01硬件支持自动重传和应答,配置好重传次数和重传延时后,底层会自动处理。但应用层还是要做超时判断,比如发送后等待应答超时,就认为这次通信失败,下次重发或者丢弃。温度数据不是关键数据,丢一两个包影响不大,但如果是报警数据,就需要应用层确认机制。

2.5 SPI FLASH的扇区管理与磨损均衡

W25Q系列FLASH的最小擦除单位是扇区,4KB一个扇区。写入前必须先擦除,擦除后所有位变成0xFF。如果频繁写同一个地址,那个扇区会先坏掉。W25Q的擦写寿命大概是10万次,按每分钟写一次算,大概69天就会写坏一个扇区。

解决办法是做磨损均衡。简单做法是准备两个扇区,轮流写,写满一个换另一个,同时记录当前写指针。复杂一点的做法是用文件系统,比如FatFS,但FatFS对FLASH的磨损均衡支持有限。我一般用双扇区轮换加环形缓冲的方式,实现简单,效果也够用。

3. 硬件搭建与核心代码实现

3.1 硬件连接清单与供电方案

先列一下完整的硬件连接。主控用STM32F103C8T6最小系统板,传感器DS18B20一个或多个,OLED用0.96寸I2C模块,无线模块NRF24L01,FLASH用W25Q64。

模块引脚STM32引脚备注
DS18B20DQPA04.7K上拉到3.3V
OLEDSCLPB6I2C1时钟
OLEDSDAPB7I2C1数据
NRF24L01SCKPA5SPI1时钟
NRF24L01MISOPA6SPI1主入从出
NRF24L01MOSIPA7SPI1主出从入
NRF24L01CSNPA4片选,普通IO
NRF24L01CEPB0使能,普通IO
NRF24L01IRQPB1中断,可选
W25Q64SCKPA5与NRF共用SPI1
W25Q64MISOPA6与NRF共用SPI1
W25Q64MOSIPA7与NRF共用SPI1
W25Q64CSPA3片选,普通IO

供电方面,STM32和所有外设都是3.3V。如果直接用USB供电,注意USB的5V要经过LDO降到3.3V。NRF24L01在发射瞬间电流可能到十几毫安,LDO的电流余量要留够。我一般用AMS1117-3.3,输出电流能到1A,带这几个模块绰绰有余。

注意:NRF24L01对电源噪声比较敏感,建议在它的VCC和GND之间并一个10微法和一个0.1微法的电容,位置尽量靠近模块引脚。这个细节很多人忽略,导致通信距离短或者丢包严重。

3.2 DS18B20驱动代码与微秒延时实现

先解决微秒延时。用DWT的方式最方便,不需要占用定时器资源。

// dwt_delay.h #ifndef __DWT_DELAY_H #define __DWT_DELAY_H #include "stm32f1xx.h" void DWT_Init(void); void delay_us(uint32_t us); #endif
// dwt_delay.c #include "dwt_delay.h" void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t cycles = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < cycles); }

这个delay_us在72MHz下实测误差在1%以内,足够DS18B20用了。

DS18B20的复位、写字节、读字节函数:

// ds18b20.c #include "ds18b20.h" #include "dwt_delay.h" #define DS18B20_GPIO_PORT GPIOA #define DS18B20_GPIO_PIN GPIO_PIN_0 static void DS18B20_SetOutput(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DS18B20_GPIO_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DS18B20_GPIO_PORT, &gpio); } static void DS18B20_SetInput(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DS18B20_GPIO_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DS18B20_GPIO_PORT, &gpio); } uint8_t DS18B20_Reset(void) { uint8_t presence; DS18B20_SetOutput(); HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_RESET); delay_us(480); HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_SET); DS18B20_SetInput(); delay_us(60); presence = HAL_GPIO_ReadPin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN); delay_us(420); return presence; } void DS18B20_WriteByte(uint8_t data) { DS18B20_SetOutput(); for (uint8_t i = 0; i < 8; i++) { HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_RESET); delay_us(2); if (data & 0x01) HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_SET); delay_us(60); HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_SET); delay_us(2); data >>= 1; } } uint8_t DS18B20_ReadByte(void) { uint8_t data = 0; DS18B20_SetInput(); for (uint8_t i = 0; i < 8; i++) { DS18B20_SetOutput(); HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_RESET); delay_us(2); HAL_GPIO_WritePin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN, GPIO_PIN_SET); DS18B20_SetInput(); delay_us(10); data >>= 1; if (HAL_GPIO_ReadPin(DS18B20_GPIO_PORT, DS18B20_GPIO_PIN)) data |= 0x80; delay_us(50); } return data; }

读温度的函数就是发跳过ROM命令0xCC,发转换命令0x44,等待转换完成,再发0xCC和0xBE读暂存器,取前两个字节组合成温度值。

float DS18B20_GetTemp(void) { uint8_t temp_l, temp_h; int16_t temp; float result; DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0x44); HAL_Delay(750); DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0xBE); temp_l = DS18B20_ReadByte(); temp_h = DS18B20_ReadByte(); temp = (temp_h << 8) | temp_l; result = temp * 0.0625f; return result; }

0.0625是DS18B20的分辨率,12位模式下最低位代表0.0625度。这个计算过程很简单,但要注意temp是有符号的,负温度时高位是1,用int16_t接收再乘系数就能正确处理。

3.3 OLED显示驱动与温度界面布局

OLED驱动我用的是自己写的精简版,只实现必要的功能。I2C写命令和写数据的函数:

// oled.c #include "oled.h" #include "i2c.h" #define OLED_ADDR 0x78 static void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100); } static void OLED_WriteData(uint8_t data) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); }

初始化序列按照SSD1306的数据手册来,主要是设置时钟分频、复用率、显示偏移、起始行、电荷泵、内存寻址模式、扫描方向、对比度这些。初始化完成后清屏,然后就可以写显存了。

显示温度的时候,我一般把屏幕分成三行:第一行显示“Temp:”,第二行显示温度值,第三行显示状态或者时间。字体用6x8或者8x16的ASCII字库。如果要显示中文,需要取模软件生成字库数组,这个后面会讲。

void OLED_ShowTemp(float temp) { char buf[16]; sprintf(buf, "%.1f C", temp); OLED_ShowString(0, 2, (uint8_t *)"Temp:", 16); OLED_ShowString(0, 4, (uint8_t *)buf, 16); }

3.4 NRF24L01通信配置与数据包格式

NRF24L01的配置分接收模式和发送模式。发送端的配置流程是:写配置寄存器设置地址宽度、频道、速率、发射功率,写发送地址,写接收地址(用于应答),使能自动应答,设置重传参数,然后拉高CE启动发送。

// nrf24l01.c #include "nrf24l01.h" #include "spi.h" #define NRF_CSN_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define NRF_CSN_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define NRF_CE_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET) #define NRF_CE_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) static uint8_t NRF_WriteReg(uint8_t reg, uint8_t value) { uint8_t status; NRF_CSN_LOW(); status = SPI_ReadWriteByte(reg | 0x20); SPI_ReadWriteByte(value); NRF_CSN_HIGH(); return status; } static uint8_t NRF_ReadReg(uint8_t reg) { uint8_t value; NRF_CSN_LOW(); SPI_ReadWriteByte(reg); value = SPI_ReadWriteByte(0xFF); NRF_CSN_HIGH(); return value; } void NRF_TxMode(uint8_t *tx_addr) { NRF_CE_LOW(); NRF_WriteReg(0x00, 0x0E); // 配置寄存器 NRF_WriteReg(0x01, 0x3F); // 自动应答所有通道 NRF_WriteReg(0x02, 0x01); // 使能通道0 NRF_WriteReg(0x03, 0x03); // 地址宽度5字节 NRF_WriteReg(0x04, 0x03); // 重传延时和次数 NRF_WriteReg(0x05, 76); // 频道76 NRF_WriteReg(0x06, 0x26); // 速率1M,功率0dBm NRF_WriteReg(0x10, (uint8_t *)tx_addr, 5); // 发送地址 NRF_WriteReg(0x11, (uint8_t *)tx_addr, 5); // 接收地址0 NRF_CE_HIGH(); }

数据包格式我定义了一个结构体,包含温度值、时间戳、设备ID和校验和。温度用float占4字节,时间戳用uint32_t占4字节,设备ID用uint8_t占1字节,校验和用uint8_t占1字节,总共10字节。NRF24L01单包最多32字节,10字节完全够用。

typedef struct { float temperature; uint32_t timestamp; uint8_t device_id; uint8_t checksum; } TempPacket_t;

发送的时候把结构体转成字节数组发出去,接收端再转回来。校验和用简单的异或校验就行,温度数据不需要太复杂的校验。

3.5 SPI FLASH数据记录与读取实现

W25Q64的操作包括读ID、擦除扇区、写数据、读数据。写之前必须先擦除,擦除是按扇区4KB来的。

// w25qxx.c #include "w25qxx.h" #include "spi.h" #define W25Q_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET) #define W25Q_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET) void W25Q_WriteEnable(void) { W25Q_CS_LOW(); SPI_ReadWriteByte(0x06); W25Q_CS_HIGH(); } void W25Q_SectorErase(uint32_t addr) { W25Q_WriteEnable(); W25Q_CS_LOW(); SPI_ReadWriteByte(0x20); SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); W25Q_CS_HIGH(); HAL_Delay(100); } void W25Q_PageWrite(uint32_t addr, uint8_t *data, uint16_t len) { W25Q_WriteEnable(); W25Q_CS_LOW(); SPI_ReadWriteByte(0x02); SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) SPI_ReadWriteByte(data[i]); W25Q_CS_HIGH(); HAL_Delay(5); } void W25Q_Read(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q_CS_LOW(); SPI_ReadWriteByte(0x03); SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) buf[i] = SPI_ReadWriteByte(0xFF); W25Q_CS_HIGH(); }

数据记录的策略是:每30秒写一条记录,每条记录10字节,一个4KB扇区能存约400条,也就是约3.3小时。两个扇区轮换的话能存6.6小时。如果要存更久,就增加扇区数量,或者降低记录频率。

4. 常见问题排查与实操避坑指南

4.1 DS18B20读不到温度怎么办

这是新手遇到最多的问题。排查顺序如下:

第一步,检查硬件连接。DS18B20三根线,VCC、GND、DQ。DQ必须接上拉电阻,4.7K到3.3V。如果接的是5V,上拉也接5V,但STM32的IO是3.3V容忍的,F103的IO可以耐5V,所以没问题。但如果你用的是其他3.3V主控,就要注意电平匹配。

第二步,用示波器或者逻辑分析仪看DQ线上的波形。复位脉冲应该是480微秒的低电平,然后释放,DS18B20会拉低60到240微秒作为存在脉冲。如果看不到存在脉冲,说明传感器没响应,可能是接线问题或者传感器坏了。

第三步,检查延时精度。用HAL_Delay做微秒延时肯定不行,必须用DWT或者定时器。我见过有人用for循环做延时,编译器优化等级一变,延时就不准了。

第四步,检查供电。DS18B20在转换温度时电流会增大,如果供电不足,转换会失败。可以在VCC和GND之间并一个0.1微法电容。

实操心得:如果多个DS18B20挂一条总线,读不到某个器件,先单独接那一个,确认它能工作,再挂回总线。有时候是ROM地址冲突或者搜索算法的问题。

4.2 OLED不亮或花屏的排查思路

OLED不亮的原因通常有三个:I2C地址不对、初始化序列不对、供电不对。

I2C地址用扫描程序确认一下,HAL库有现成的I2C扫描例程。地址一般是0x78或0x7A,如果扫描不到,检查SCL和SDA有没有接反,上拉电阻有没有。

初始化序列要严格按照SSD1306的数据手册来。有些模块用的是SH1106芯片,初始化序列略有不同,如果按SSD1306的序列初始化SH1106,可能会花屏或者显示偏移。区分方法是看模块背面芯片型号,或者试一下SH1106的初始化序列。

花屏的另一个原因是显存刷新方式不对。SSD1306支持页寻址和水平寻址,如果寻址模式设置错了,写入的数据会错位。我一般用页寻址模式,每页8行,共8页,写的时候按页写。

4.3 NRF24L01通信距离短或丢包严重

先检查电源。NRF24L01对电源噪声非常敏感,VCC和GND之间必须并电容,10微法和0.1微法各一个,越靠近模块越好。如果用的是开发板上的3.3V,可能电流不够,建议单独用LDO供电。

再检查频道。2.4GHz频段很拥挤,WiFi、蓝牙、微波炉都在这个频段。用NRF24L01的载波检测功能扫描一下,选一个底噪低的频道。或者干脆避开WiFi常用的1、6、11频道对应的频段。

然后检查天线。PCB天线的NRF24L01模块,通信距离大概十几米。如果要更远,换带SMA天线的模块,或者加PA功放。但加PA的话功耗会上去,供电要跟上。

最后检查配置。速率设1Mbps比2Mbps距离远,发射功率设0dBm比-18dBm距离远。自动重传次数设3到5次,重传延时设500微秒左右。

4.4 FLASH写入失败或数据丢失

FLASH写入失败最常见的原因是写之前没擦除。W25Q的规则是只能把1写成0,不能把0写成1。擦除后所有位是1,写入就是把对应的位改成0。如果目标地址之前写过数据,没擦除就直接写,写进去的数据是错的。

另一个原因是写使能没发。每次写操作之前都要发Write Enable命令0x06,否则芯片会忽略写操作。

数据丢失可能是磨损均衡没做好。如果一直写同一个扇区,那个扇区很快就会坏。用双扇区轮换或者环形缓冲,能大大延长寿命。

还有一个隐蔽的问题:SPI时钟太快。W25Q64支持的最高SPI时钟是104MHz,但那是理想情况。实际用的时候,如果走线长、干扰大,建议降到10MHz以下。我一般用STM32的SPI1,分频到9MHz左右,很稳。

4.5 常见问题速查表

现象可能原因排查方法解决措施
DS18B20读不到上拉电阻缺失万用表测DQ对VCC电阻加4.7K上拉
DS18B20读不到延时不准逻辑分析仪看时序改用DWT延时
OLED不亮I2C地址错误I2C扫描程序确认地址0x78或0x7A
OLED花屏初始化序列不匹配查芯片型号换SH1106序列
NRF丢包电源噪声示波器看VCC纹波加10uF+0.1uF电容
NRF距离短频道干扰载波检测扫描换干净频道
FLASH写失败未擦除读回数据对比写前先擦扇区
FLASH数据丢失磨损不均检查写地址分布双扇区轮换

5. 系统联调与功能扩展思路

5.1 发送端与接收端的联调步骤

联调的时候不要一上来就把所有模块都接上,分步来。

第一步,单独调DS18B20。接一个传感器,串口打印温度值,确认能正确读出室温。用手捏住传感器,温度应该上升。

第二步,单独调OLED。显示一个固定的字符串,确认屏幕能亮、能显示。

第三步,DS18B20加OLED。温度值实时显示在屏幕上,刷新率1秒一次。

第四步,加NRF24L01。发送端发数据,接收端用串口打印接收到的数据。确认数据包格式正确,温度值一致。

第五步,加FLASH。确认能写入、能读出,数据一致。

第六步,全部联调。发送端采集、显示、发送、存储,接收端接收、显示。跑一段时间,看有没有丢包、死机、数据错乱。

注意:联调的时候建议用串口打印调试信息,但正式产品里要把串口打印去掉或者用宏控制,否则会影响时序。特别是DS18B20的微秒级延时,串口打印会打断时序。

5.2 低功耗优化与电池供电方案

如果要做电池供电的无线温度计,功耗优化是必须的。STM32F103C8T6在72MHz全速运行的时候电流大概30到50毫安,加上NRF24L01发射时的十几毫安,OLED显示的十几毫安,整体功耗在70毫安左右。用2000mAh的电池,大概能跑28小时,显然不够。

优化思路是间歇工作。STM32平时进入Stop模式,RTC定时唤醒,唤醒后采集温度、发送数据、刷新OLED,然后继续睡。Stop模式下电流大概20微安,RTC走时大概1微安。如果每30秒唤醒一次,每次工作200毫秒,平均电流大概1毫安左右,2000mAh电池能跑2000小时,约83天。

OLED在不需要显示的时候可以关闭,用SSD1306的睡眠命令0xAE。NRF24L01在不发送的时候也可以进入Power Down模式,电流降到1微安以下。

DS18B20在不需要测温的时候可以断电,用一个IO口控制它的VCC。但要注意,断电后重新上电需要重新初始化。

5.3 多节点组网与上位机数据可视化

多个发送端和一个接收端组成星型网络,每个发送端有唯一的设备ID。接收端轮询接收各个节点的数据,通过串口发给电脑。电脑上用Python或者LabVIEW做一个简单的上位机,实时显示各节点的温度曲线。

NRF24L01支持6个通道同时接收,每个通道可以设不同的地址。发送端用不同的发送地址,接收端使能所有通道,就能同时接收多个节点的数据。但要注意,如果两个节点同时发送,可能会冲突。解决办法是给每个节点分配不同的发送时隙,或者用应答机制避免冲突。

上位机这边,Python用pyserial读串口数据,用matplotlib画实时曲线。数据格式可以定义成“设备ID,温度,时间戳”,上位机解析后分别存储和显示。

5.4 从温度计到环境监测站的扩展

这套框架很容易扩展成环境监测站。DS18B20测温度,再加一个DHT11或者SHT30测湿度,加一个BH1750测光照,加一个MQ-2测烟雾。STM32的IO口和SPI、I2C资源足够接这些传感器。

数据包格式扩展一下,把湿度、光照、烟雾值都加进去。FLASH存储的记录也相应扩展。OLED显示可以分页,第一页显示温度湿度,第二页显示光照烟雾。

供电方面,如果传感器多了,功耗会上去,电池方案可能不够,可以考虑太阳能板加锂电池的组合。太阳能板白天充电,晚上电池供电,配合低功耗策略,可以做到长期免维护运行。

5.5 外壳设计与安装注意事项

外壳设计要看应用场景。室内用的话,3D打印一个简单的盒子就行,注意留出OLED窗口和传感器透气孔。DS18B20的探头最好伸出外壳,避免主控发热影响测温精度。

室外或者冷库用的话,要考虑防水防尘。用IP65的防水盒,传感器用防水探头,线缆出口用防水接头。低温环境下,电池性能会下降,锂电池在零下20度容量只剩一半,要考虑保温或者用低温电池。

安装位置也有讲究。DS18B20不要贴在金属表面,金属导热快,会影响测温响应。也不要放在阳光直射的地方,否则测的是太阳晒热的温度,不是环境温度。最好放在通风良好、避免直射的位置。

实操心得:我在冷库项目里遇到过传感器结霜的问题。DS18B20的探头在低温高湿环境下会结霜,霜层影响热传导,测温会偏低。解决办法是给探头套一个透气但不透水的PTFE膜,既能防结霜,又不影响透气。

6. 写在最后的一些个人体会

这套无线温度计方案我从最早的单点测温做到多点组网,从有线供电做到电池供电,踩过的坑比写过的代码还多。最大的体会是,嵌入式项目里硬件和软件是分不开的,很多软件上看起来莫名其妙的问题,根源都在硬件上。比如NRF24L01丢包,我调了三天代码,最后发现是电源滤波电容没加。DS18B20读不到,查了半天时序,结果是上拉电阻用了10K,换成4.7K就好了。

另一个体会是,不要迷信库。HAL库方便是方便,但有些地方封得太死,反而不如直接操作寄存器来得直接。比如DS18B20的微秒延时,用HAL库的HAL_Delay根本做不了,必须自己写。OLED的I2C通信,HAL库的HAL_I2C_Mem_Write每次都要重新发起始信号,效率不高,自己写的话可以连续写多个字节。

最后说一句,做项目要留有余量。Flash容量留一半,RAM留一半,IO口留几个,电源电流留一倍。这样后面加功能的时候才不会捉襟见肘。我第一版的时候Flash用了90%,后来想加个数据记录功能,发现没空间了,只能换芯片重新画板,费时费力。

RELATED READING

延伸阅读

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