简介:基于STM32与DS18B20的温度监控报警仿真项目,面向单片机初学者及嵌入式课程设计人员,解决温度采集、OLED显示与阈值控制联调的常见问题。系统以STM32为主控,DS18B20采集环境温度,通过IIC接口驱动OLED实时显示,并支持按键设定上下限阈值;当温度低于阈值时加热继电器吸合,高于阈值时断开,形成完整的闭环控制逻辑。压缩包共191个文件,包含43个.h头文件、39个.c源码、Proteus仿真工程(.pdsprj/.pdsbak)、Keil工程配置(.uvprojx/.uvoptx)以及可直接烧录的.hex文件等,整体约3.89MB,目录结构清晰,方便按模块查阅与二次开发。已有783人学习浏览,适合用来对照学习STM32外设初始化、DS18B20时序驱动、OLED的IIC通信及继电器控制程序的编写思路。
1. 直接用STM32+DS18B20+OLED+按键报警的Proteus仿真项目,先分清硬件关系
很多嵌入式初学者拿到“基于STM32-DS18B20-按键设置报警阀值-OLED显示-proteus仿真”这串名字,第一反应是“这又是一个把所有外设堆在一起的课设”。实际上这套系统拆开看就是三层:DS18B20负责温度采集,STM32负责读传感器、处理按键和比较阈值,OLED显示温度与设置界面,报警输出可以挂在LED或蜂鸣器上。题里的“阀值”是错别字,工程上叫“阈值”。Proteus仿真的价值在于,不需要买开发板也能把STM32的GPIO时序、按键状态机和I2C OLED显示串起来跑通。这套方案适合做单片机课设、毕业设计预研,也适合想从51过渡到STM32的开发者。要注意的是,Proteus里的DS18B20和OLED模型跟实物存在差异,时序参数不能照搬,后面会专门讲怎么调。
2. DS18B20单总线时序解析与STM32驱动封装
2.1 DS18B20复位和存在脉冲的时序要求
DS18B20是Dallas单总线器件,所有通信都由主机发起。每次读写之前都要先复位:主机把总线拉低至少480us,然后释放,等待器件拉低60-240us表示存在。在STM32里通常用GPIO开漏模式配合外部上拉电阻,在Proteus仿真里则要注意总线浮空问题。下面是常见的时序参数表,来自数据手册,仿真和实物都适用:
| 参数 | 最小 | 典型 | 最大 | 单位 |
|---|---|---|---|---|
| 复位低电平时间 | 480 | 960 | 960 | us |
| 存在脉冲低电平 | 60 | 120 | 240 | us |
| 写0时隙低电平时间 | 60 | 90 | 120 | us |
| 写1时隙低电平时间 | 1 | 6 | 15 | us |
| 读时隙低电平时间 | 1 | 6 | 15 | us |
| 时隙总时间 | 60 | 90 | 120 | us |
注意复位低电平和写0低电平都要求不低于60us,很多网上代码用了“延时xx微秒”,但在Proteus仿真里由于模型时钟频率和指令周期不同,容易出现时序偏差。建议直接使用DWT计数器做微秒延时,而不是简单的软件空循环。DWT是Cortex-M内核里的调试监视跟踪单元,它能提供不受中断影响的us级延时,这在时序敏感的1-Wire通信上很关键。
2.2 STM32用HAL库GPIO模拟单总线的读写函数
这里以STM32F103C8为例,用PB0作为单总线数据脚。HAL库的GPIO配置为开漏输出,外部上拉4.7k。在Proteus仿真里如果没有外加上拉电阻,可以配置内部上拉,但为了贴近实物,最好在原理图上放一个上拉电阻。核心代码分四部分:复位、写位、读位、读取温度。先看复位函数:
#define DS18B20_PORT GPIOB #define DS18B20_PIN GPIO_PIN_0 static void DS18B20_DelayUs(uint32_t us) { // 使用DWT计数器实现1us精度延时,不依赖SysTick CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while (DWT->CYCCNT - start < ticks); } static uint8_t DS18B20_Reset(void) { uint8_t presence = 0; GPIO_InitTypeDef gpio = {0}; // 配置为开漏输出并拉低 gpio.Pin = DS18B20_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DS18B20_PORT, &gpio); HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET); DS18B20_DelayUs(500); // 释放总线,配置为输入模式,等待存在脉冲 HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET); gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DS18B20_PORT, &gpio); DS18B20_DelayUs(70); presence = HAL_GPIO_ReadPin(DS18B20_PORT, DS18B20_PIN); // 等待剩余存在脉冲时间,再切回输出模式 DS18B20_DelayUs(410); gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(DS18B20_PORT, &gpio); return presence; // 存在脉冲应为0,若为1则未检测到器件 }这段代码的关键点在于:复位时的500us低电平和释放后70us处读存在脉冲。如果读到0表示器件存在,读到1表示没检测到。HAL_GPIO_Init在每次模式切换时会有额外开销,虽然会引入几百ns延迟,但在Proteus仿真里通常还能容忍。如果要求更精确,可以直接操作GPIO的CRL/CRH寄存器。
写位和读位是1-Wire通信的核心。写位时,写1和写0的时序都以拉低总线开始,但释放时间不同;读位时,主机拉低总线2us后释放,然后在6-9us窗口内采样电平。具体实现如下:
void DS18B20_WriteBit(uint8_t bit) { HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET); if (bit) { DS18B20_DelayUs(6); HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET); DS18B20_DelayUs(64); } else { DS18B20_DelayUs(60); HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET); DS18B20_DelayUs(10); } } uint8_t DS18B20_ReadBit(void) { uint8_t bit = 0; GPIO_InitTypeDef gpio = {0}; // 拉低总线,然后切换输入模式 HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET); DS18B20_DelayUs(2); HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET); DS18B20_DelayUs(6); gpio.Pin = DS18B20_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DS18B20_PORT, &gpio); bit = HAL_GPIO_ReadPin(DS18B20_PORT, DS18B20_PIN); // 恢复输出模式并等待时隙结束 gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(DS18B20_PORT, &gpio); DS18B20_DelayUs(60); return bit; }读位时,如果采样太早,读到的可能是总线释放后的高电平;太晚,又可能读到下一时隙。这里选择拉低2us、释放6us后采样,落在数据手册要求的15us窗口内,这种写法在Proteus和实物上都能稳定工作。读写字节只需要循环8次,把位拼起来即可。
启动温度转换和读取结果:
static void DS18B20_WriteByte(uint8_t byte) { for (int i = 0; i < 8; i++) { DS18B20_WriteBit(byte & 0x01); byte >>= 1; } } static uint8_t DS18B20_ReadByte(void) { uint8_t byte = 0; for (int i = 0; i < 8; i++) { byte >>= 1; if (DS18B20_ReadBit()) { byte |= 0x80; } } return byte; } void DS18B20_Start(void) { DS18B20_Reset(); DS18B20_WriteByte(0xCC); // Skip ROM,跳过ROM匹配 DS18B20_WriteByte(0x44); // Start conversion,启动温度转换 } float DS18B20_ReadTemp(void) { uint8_t low = 0, high = 0; int16_t raw = 0; DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0xBE); // Read scratchpad,读取暂存寄存器 low = DS18B20_ReadByte(); high = DS18B20_ReadByte(); raw = (high << 8) | low; return raw * 0.0625f; // 12位分辨率,LSB对应0.0625度 }0xCC是跳过ROM指令,适用于单点测温;0x44是启动转换指令,温度转换最长需要750ms;0xBE是读取暂存寄存器的指令。温度计算结果乘以0.0625是因为默认12位分辨率下,最低有效的bit代表0.0625摄氏度。在Proteus仿真中,如果温度显示固定为85度,往往就是复位时序不满足,存在脉冲没读到。
2.3 Proteus中DS18B20仿真对时序的影响
Proteus的DS18B20模型支持1-Wire时序,但有几个细节要注意。第一,模型对复位低电平的最小时间要求比实物高,实测要拉低600us左右才稳定;第二,读时隙的采样点必须靠近15us,否则容易读到上次的电平;第三,仿真默认的STM32晶振频率要和keil工程里的代码定义一致,否则微秒延时偏差会让温度一直读为85度。我的做法是在Proteus原理图里给DS18B20加一个4.7k上拉电阻到3.3V,同时把STM32的PLL配置从8MHz外部晶振改成8MHz,确保时钟树正确。这样读出来的温度值才能和实物趋势一致。
另外,Proteus的STM32模型对开漏输出的行为模拟得比较粗糙。如果发现总线电平始终为高,可以在DS18B20的D Q脚和GND之间再并联一个0.1uF电容,用来抑制仿真中的数字噪声,这也能让存在脉冲更稳定。很多同学在Proteus里仿真DS18B20失败,不是程序问题,而是模型参数和环境配置不对。
3. 按键设置报警阈值的状态机与掉电保存
3.1 按键去抖与长按短按识别
按键在这个项目里不只是简单的电平读取,因为要设置报警阈值,需要区分短按、长按和连续步进。常见做法是用10ms定时器扫描按键端口,连续读到3次稳定电平才算有效。下面是一个适合STM32HAL库的按键扫描代码片段:
typedef enum { KEY_RELEASED, KEY_PRESSED, KEY_LONG_PRESSED } KeyState; KeyState KEY_Scan(void) { static uint8_t key_buf = 0xFF; static uint32_t press_time = 0; uint8_t key_level = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); // PA0接按键,低电平有效 key_buf = (key_buf << 1) | (key_level & 0x01); key_buf = (key_buf << 1) | (key_level & 0x01); // 连续读两次,实际为8位移位 if ((key_buf & 0xFF) == 0x00) { // 连续8次低电平,认为按键已稳定按下 if (press_time == 0) { press_time = HAL_GetTick(); } if (HAL_GetTick() - press_time > 1000) { press_time = HAL_GetTick(); return KEY_LONG_PRESSED; } return KEY_PRESSED; } else { key_buf = 0xFF; press_time = 0; return KEY_RELEASED; } }这个代码用了8次移位取连续低电平的方式去抖,比单纯延时方式响应快。注意这里PA0配置为下拉输入,按键另一端接3.3V,按下为高电平,代码里也用高电平当作有效。长按返回KEY_LONG_PRESSED,在阈值设置界面里可以用长按快速加减,短按则每次加1或减1。这里的HAL_GetTick()默认是1ms节拍,刚好适合做超时判断。
3.2 阈值设置菜单状态机设计
报警阈值一般有上限和下限两个值,简单实现可以用三个状态:正常显示、设置上限、设置下限。用一个枚举定义状态,按键KEY1切换状态,KEY2增加阈值,KEY3减小阈值。状态机切换表如下:
| 当前状态 | 按键KEY1 | 按键KEY2 | 按键KEY3 |
|---|---|---|---|
| NORMAL | 进入SET_HIGH | 无动作 | 无动作 |
| SET_HIGH | 进入SET_LOW | 高阈值+1 | 高阈值-1 |
| SET_LOW | 返回NORMAL | 低阈值+1 | 低阈值-1 |
阈值调整步进默认是1度,但长按KEY2或KEY3时每100ms连续加减,适合从25度调到80度的场景。这里要注意,上限阈值必须大于下限阈值,否则在判断报警逻辑时会出现矛盾。我在状态机里加入了一个矫正逻辑:当设置高阈值时,如果当前值小于低阈值,就把低阈值强制设置为高阈值减1;同理,设置低阈值时也做反向校验。这样能避免用户在Proteus仿真里把阈值设成不可用数值。
状态机的核心代码不复杂,重点在于每次按键事件只处理一次,不能长按自动重复触发导致阈值跳动太快。所以KEY_Scan的返回值只在跳变沿有效,长按则单独用一个计数器控制步进速度。实际测试中,10ms扫描周期加300ms长按初始延迟,再100ms重复,手感最接近实物。
3.3 阈值用STM32内部Flash模拟EEPROM保存
阈值参数不能每次上电都从25度默认值开始,需要使用STM32内部Flash模拟EEPROM。STM32F103的Flash按页组织,一页1KB。这个项目只需要保存4个字节,直接写到最后一页,比如主Flash的127页。写入前必须先擦除整页,然后HAL库写数。下面是保存阈值的代码:
#define FLASH_USER_PAGE 127 // 使用Flash最后一个1KB页 #define FLASH_BASE_ADDR 0x0801FC00 // 最后一页起始地址 void SaveThreshold(uint8_t high, uint8_t low) { FLASH_EraseInitTypeDef erase = {0}; uint32_t page_err = 0; uint64_t data = 0; data = ((uint64_t)high << 8) | low; HAL_FLASH_Unlock(); erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = FLASH_BASE_ADDR; erase.NbPages = 1; HAL_FLASHEx_Erase(&erase, &page_err); HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, FLASH_BASE_ADDR, data); HAL_FLASH_Lock(); }这里用64位双字写入,STM32的Flash编程最小单位是双字,所以即使只用2个字节,也要按8字节对齐。读取时直接通过指针强转:
uint16_t read_val = 0; uint64_t *p = (uint64_t *)FLASH_BASE_ADDR; read_val = (uint16_t)(*p & 0x00FF);注意Flash页擦除会把这个页里所有数据清零,如果项目里还有其他需要保存的参数,最好提前规划好布局。另外Flash擦写寿命约1万次,仿真阶段频繁调试阈值会消耗擦写次数,但Proteus仿真不会真正磨损芯片,只有实物长期跑才需要考虑磨损均衡。每次上电初始化时,先读取Flash里的值,然后判断是否在合理范围内,比如高阈值在30-90之间、低阈值在10-60之间,超出范围就重新初始化为默认值。
4. OLED显示与Proteus仿真联调
4.1 HAL库下I2C驱动OLED屏幕的全流程
这个项目里OLED用的是I2C接口,常见的是0.96寸128x64屏,驱动芯片为SSD1306。在STM32CubeMX中把I2C1配置为快速模式,速率400kHz,引脚PB8-PB9。OLED的I2C地址由SA0引脚决定,通常为0x78(7位地址0x3C左移一位)。HAL库驱动的关键是写命令和写数据的函数:
#define OLED_ADDR 0x78 static void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 20); } static void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 20); }第一个字节是控制字节,0x00表示后面跟命令,0x40表示后面跟显存数据。每次写入都需要发送完整两字节,不能拆开。OLED初始化序列很长,主要是设置显示开关、时钟分频、对比度、寻址模式等。核心设置是寻址模式设为页寻址,每页128列。显示温度数值时,需要把数字转成字符串,再通过字库点阵写入显存。
显示一帧内容的流程是:先选择一个页地址,再设置列地址低高位,然后连续写入该页128字节数据。如果显示内容需要动态刷新,通常的做法是维护一个128x8的显存数组,每次修改数组后整屏刷新。但整屏刷新会占用不少I2C时间,在温度显示这类低速场景里完全够用。Proteus仿真中I2C速度可以降到100kHz,避免模型响应不过来。
4.2 温度、阈值和报警状态的界面布局
OLED界面可以分三行:第一行显示当前温度,第二行显示上限阈值和下限阈值,第三行显示报警状态和设置菜单指示。比如:
- 第一行:Temp:25.5C
- 第二行:H:30 L:10
- 第三行:Alarm:Off
在设置状态下,用一个小箭头标记当前正在修改的项。这个界面不需要复杂的GUI库,用字库提取工具生成ASCII字符点阵即可。下面是在主循环中刷新的逻辑:
char temp_str[16]; sprintf(temp_str, "Temp:%.1fC", temp_value); OLED_ShowString(0, 0, temp_str); sprintf(temp_str, "H:%d L:%d", high_th, low_th); OLED_ShowString(8, 2, temp_str); sprintf(temp_str, "Alarm:%s", alarm_flag ? "On" : "Off"); OLED_ShowString(16, 4, temp_str);这里的OLED_ShowString需要自己实现,核心是从字库里取出每一列的字节写入显存。注意在Proteus仿真里,OLED模型刷新速度比实物慢,如果调用I2C的延时太短,屏幕会出现花屏。一个简单的规避方法是每次写入命令后加一个100us的延时,或者在I2C初始化时把通信超时调到100ms。如果屏幕只亮不显示内容,优先检查OLED地址和复位引脚是否与原理图一致。
4.3 Proteus仿真里OLED与DS18B20联调的3个坑
第一个坑是Proteus的OLED模型需要正确选择型号,有的版本里只有“OLED 128x64 I2C”而驱动芯片不一定兼容SSD1306,软件显示乱码。第二个坑是I2C总线上拉电阻,Proteus默认不加上拉,STM32的I2C开漏输出会导致通信失败,必须在原理图里给PB8和PB9各加一个4.7k电阻。第三个坑是仿真中DS18B20的温度变化很慢,而OLED刷新又频繁,看起来温度像卡住了一样。其实可以在Proteus里双击DS18B20模型,修改温度步进值(Temperature Step),把默认的1度改成0.1度,这样温度变化会平滑很多,方便测试报警阈值是否生效。
这三个坑如果不在原理图阶段就规避,程序写得再好也跑不起来。我在Proteus仿真时习惯把DS18B20的4.7k上拉、I2C的4.7k上拉、OLED的电源去耦电容都画上,虽然仿真不需要这些电容,但对后面移植实物有参考价值。OLED的VCC和GND也要接到3.3V和地,不要在仿真里接到5V,SSD1306的耐压范围是3.3V,接5V会烧坏。
5. 报警阈值边界与仿真误差的验证技巧
5.1 用Proteus虚拟示波器抓取单总线时序
调试DS18B20最有效的手段是看单总线波形,Proteus的虚拟示波器可以接到PB0上,但1-Wire时序只有几十微秒,设置示波器时基必须调到50us/格以内。复位波形应该看到一长一短两个低电平脉冲,第一个是主机拉低500us,第二个是DS18B20回应的存在脉冲。如果只有主机脉冲而看不到存在脉冲,说明复位时序太短或上拉电阻没起作用。这个验证方法在实物调试时同样适用,只是需要把示波器探头换成逻辑分析仪。
5.2 阈值临界值测试矩阵
报警逻辑不能只测一个温度点,建议做一个测试矩阵,覆盖三种边界情况:温度等于阈值、温度低于阈值、温度高于阈值。在实际调试中,把报警阈值上限设为30度、下限设为10度,然后通过修改Proteus里DS18B20的温度值,观察OLED上Alarm状态和LED是否变化。测试时要注意DS18B20的分辨率是0.0625度,浮点比较不能直接用严格等于,改为(temp_value + 0.05f) >= high_th这样的容错比较。仿真通过后,建议把阈值上下限校验逻辑保留,防止实物上按键抖动导致阈值越界,这个校验代码在任何ST32项目中都能复用。
本文还有配套的精品资源,点击获取