简介:一份基于STM32单片机的DS18B20温度采集与OLED显示系统完整工程包,涵盖按键设置报警阀值、继电器加热控制等核心逻辑,面向单片机初学者、电子爱好者及课程设计人群,可直接用于Proteus仿真验证与实物调试。整个压缩包共191个文件,大小3.89MB,以C源码(.c/.h)、Proteus仿真工程(.pdsprj)、可烧录hex文件、Keil工程配置及相关说明文档为主,同时包含编译中间文件与备份信息,目录结构清晰、便于按模块对照学习。系统采用IIC通信驱动OLED实时显示温度,DS18B20完成单总线温度采集,用户可通过按键动态设定报警阀值;当温度低于阀值时加热继电器启动,高于阀值时自动停止加热,逻辑完整、可直接下载运行,适合作为STM32入门综合练习。目前已有783人浏览学习,读者可获得完整工程,既能掌握传感器驱动、IIC显示、按键交互与继电器控制等知识点,又可在现有基础上继续扩展风扇控制、物联网上报等进阶功能。
1. 用STM32做可设阈值的DS18B20温度报警,先明白这套链路解决什么问题
温度报警这个需求,放在课程设计、毕业设计和实际的小型温控场景里出现频率极高,而这个标题把整套链路浓缩成了一个能跑起来的Proteus仿真工程:STM32做主控和逻辑判断,DS18B20负责温度采集,三个按键用来设置报警阈值,OLED实时显示温度和阈值,最后在Proteus里完成仿真验证。相比DHT11那类温湿度一体传感器,DS18B20的精度在12位分辨率下可以做到0.0625℃,而且单总线只占一个GPIO,对引脚紧张的STM32最小系统很友好。把单总线时序、按键消抖、I2C驱动OLED这三块基本功吃透,这套流程可以直接平移到恒温箱、发酵罐、机房测温等类似项目里。需要先说明的是,正文按照工程习惯使用“阈值”一词,标题里的“阀值”为同一概念,变量命名时统一为 threshold,避免代码里出现两种写法。
2. DS18B20时序与单总线驱动:先让温度数据真正从引脚读出
2.1 单总线协议和复位时序
DS18B20用的是单总线(1-Wire)协议,主机和从机共用一根数据线 DQ,所有通信由主机发起。单总线上可以挂多个 DS18B20,每个芯片出厂时带一个64位ROM地址,依靠地址可以精确寻址。大多数温度报警场景只挂一个传感器,因此代码里一般直接用跳过ROM命令(0xCC),不逐个匹配地址。
单总线没有独立时钟线,时序完全靠主机在精确的时间窗口内拉低或释放总线来表达逻辑0和逻辑1。这也是 DS18B20 驱动里最容易出问题的地方:初始化顺序不对、延时不达标,读回来的数据往往是 0xFF 或者 85℃(上电复位后的默认温度值)。所以第一步是把复位脉冲吃透:主机将 DQ 拉低 480~960 微秒,然后释放总线,上拉电阻把电平拉高;DS18B20 在释放后的 15~60 微秒窗口内主动将总线拉低 60~240 微秒,作为存在脉冲(Presence Pulse)。主机检测到这个低电平,就说明从机在线,可以进行后续读写。
单总线时序的关键参数可以按下面这张表核对,代码里的延时参数都从这里来:
| 参数 | 最小值 | 典型值 | 最大值 | 说明 |
|---|---|---|---|---|
| 主机复位低电平 | 480us | - | 960us | 拉低时间不足则无法触发从机复位检测 |
| 存在脉冲 | 60us | - | 240us | 主机应在释放后 15~60us 内开始采样 |
| 写 1 时隙 | 1us | - | 15us | 低电平保持时间必须小于 15us |
| 写 0 时隙 | 60us | - | 120us | 要保证从机采样到低电平 |
| 读时隙采样点 | - | 15us | - | 从机释放总线后在此附近采样 |
2.2 读写时隙的 HAL 库实现
先解决延时问题。HAL 库自带的HAL_Delay只能精确到毫秒级,而单总线操作需要微秒级延时,我常用的方案是启用 Cortex-M3 内核的 DWT 周期计数器,不占用定时器,也不受系统节拍中断影响:
static void delay_us(uint32_t us) { DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t ticks = us * (HAL_RCC_GetHCLKFreq() / 1000000); while (DWT->CYCCNT < ticks); }DWT->CYCCNT是内核自带的 32 位周期计数器,在 72MHz 主频下每个计数约 13.9ns。使用前要通过DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk使能。这个方案不会与HAL_Delay抢占任何外设资源,适合对时序敏感的驱动代码。
GPIO 配置选用开漏输出加内部上拉,这样切换输入输出时不需要改变外部电路:
#define DQ_GPIO_PORT GPIOB #define DQ_PIN GPIO_PIN_1 void ds18b20_set_output(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DQ_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull = GPIO_PULLUP; // 内部上拉 gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DQ_GPIO_PORT, &gpio); } void ds18b20_set_input(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DQ_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DQ_GPIO_PORT, &gpio); }开漏输出加内部上拉,在实验板上即使不接外部 4.7k 电阻也能工作。但真实硬件中建议在 DQ 引脚对 VCC 接一颗 4.7k 上拉电阻,内部上拉阻值偏大,长线缆或者传感器远离主板时波形边沿会变差。
写一个字节的代码:
void ds18b20_write_byte(uint8_t data) { ds18b20_set_output(); for (int i = 0; i < 8; i++) { if (data & (1 << i)) { HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_RESET); delay_us(4); // 写1:低电平保持 4us HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_SET); delay_us(64); // 释放后等待时隙结束 } else { HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_RESET); delay_us(64); // 写0:持续拉低 64us HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_SET); delay_us(1); } } }写 1 时隙的关键是主机拉低时间必须小于 15us,因为 DS18B20 在总线释放后的 15~60us 窗口内采样。代码里取 4us,留有足够余量。写 0 时隙则可以一直拉低到 60us 以上,从机在 60us 内采样到低电平即判定为 0。
读字节函数:
uint8_t ds18b20_read_byte(void) { uint8_t value = 0; for (int i = 0; i < 8; i++) { ds18b20_set_output(); HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_RESET); delay_us(1); // 读时隙起始低电平 HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_SET); ds18b20_set_input(); // 释放总线,准备接收数据 delay_us(10); // 释放后 10us 处采样 if (HAL_GPIO_ReadPin(DQ_GPIO_PORT, DQ_PIN) == GPIO_PIN_SET) { value |= (1 << i); } delay_us(55); // 等待整个时隙结束 } return value; }读时序最容易踩的坑是切换输入模式的耗时。HAL_GPIO_Init在运行时需要配置寄存器,耗时可能到微秒级,如果切换完成后再延时 10us 采样,会超过 DS18B20 的 15us 数据输出窗口。如果读出来的数据总是 0xFF,优先检查这一段的延时裕量,或者把采样点提前到释放后的 4~6us。
复位函数,返回从机是否存在:
uint8_t ds18b20_reset(void) { uint8_t presence = 0; ds18b20_set_output(); HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_RESET); delay_us(500); // 复位低电平 HAL_GPIO_WritePin(DQ_GPIO_PORT, DQ_PIN, GPIO_PIN_SET); ds18b20_set_input(); delay_us(70); // 存在脉冲窗口内采样 if (HAL_GPIO_ReadPin(DQ_GPIO_PORT, DQ_PIN) == GPIO_PIN_RESET) { presence = 1; // 检测到存在脉冲 } delay_us(200); // 等待复位时序完全结束 return presence; }参数说明:复位低电平取 500us,处于 480~960us 区间中部;释放后等待 70us 再读取总线电平,此时已经过了存在脉冲的起始时间,读到低电平即表示从机应答。最后再等 200us 是为了让整个复位周期完全结束,避免立刻发起写命令时从机状态未恢复。
2.3 温度换算与负温处理
读取一次完整温度需要两轮通信:第一轮启动温度转换,第二轮读取暂存器中的9字节数据。代码实现:
float ds18b20_read_temperature(void) { uint8_t data[9]; if (!ds18b20_reset()) return -127.0f; ds18b20_write_byte(0xCC); // 跳过 ROM,单设备场景不需要地址匹配 ds18b20_write_byte(0x44); // 启动温度转换 HAL_Delay(750); // 12 位分辨率,转换时间最长 750ms if (!ds18b20_reset()) return -127.0f; ds18b20_write_byte(0xCC); ds18b20_write_byte(0xBE); // 读暂存器,空调连续读取 9 字节 for (int i = 0; i < 9; i++) { data[i] = ds18b20_read_byte(); } int16_t raw = (int16_t)(data[1] << 8 | data[0]); // 必须用有符号类型接收 float temp = raw * 0.0625f; // 12位分辨率 LSB = 0.0625℃ return temp; }raw必须定义为int16_t。DS18B20 在 12 位分辨率下输出 16 位有符号数,负数时高位字节为 0xFC、0xF8 等符号扩展值,用int16_t接收后再乘 0.0625 才能得到正确的负温度。比如 -0.5℃ 对应的原始值为 0xFFF8,乘 0.0625 后结果是 -0.5。
读取失败时返回 -127 作为哨兵值。显示层检测到这个值就显示“Err”,并且不参与报警判断,避免传感器掉线时误触发报警。
3. 按键设置报警阈值:消抖、状态机与掉电存储
3.1 按键电路与消抖参数
三个按键 K1、K2、K3 分别接 PB12、PB13、PB14,按键另一端接地,GPIO 配置为内部上拉输入。按下时引脚读到低电平,释放时恢复到高电平。Proteus 仿真里的按键模型没有机械抖动,但代码保留消抖逻辑,因为同一套程序烧到真实板子上后,机械触点抖动是必然存在的。
常用的软件消抖参数是 10~20ms。我通常把按键扫描放到定时器中断里,每 10ms 扫一次,连续两次扫描读到相同电平才确认按键状态变化。低于 5ms 的消抖时间在大部分国产按键上不够可靠,过高则会影响按键响应手感。
| 按键 | 引脚 | 正常模式功能 | 设置模式功能 |
|---|---|---|---|
| K1 | PB12 | 进入设置模式 | 切换上限/下限/退出 |
| K2 | PB13 | 无 | 阈值加 1 |
| K3 | PB14 | 无 | 阈值减 1 |
设置模式的进入逻辑:在正常显示状态下短按 K1,进入设置模式,OLED 上的阈值数字开始闪烁。再短按 K1,光标在上限和下限之间切换。在最后一个设置项上长按 K1 2 秒退出设置模式并保存。这个交互逻辑简单,且不需要额外增加确认键。
3.2 按键扫描状态机实现
按键状态机用四个状态管理一次完整的按下和释放过程,避免一次按键被重复触发:
typedef enum { KEY_STATE_IDLE, // 空闲,等待按下 KEY_STATE_PRESSED, // 检测到按下,正在消抖 KEY_STATE_CONFIRMED, // 消抖确认,等待释放 } key_state_t; typedef struct { GPIO_TypeDef *port; uint16_t pin; key_state_t state; uint32_t last_tick; uint8_t event; } key_t; void key_scan(key_t *key, uint32_t now) { uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin); key->event = 0; switch (key->state) { case KEY_STATE_IDLE: if (level == GPIO_PIN_RESET) { key->state = KEY_STATE_PRESSED; key->last_tick = now; } break; case KEY_STATE_PRESSED: if (level == GPIO_PIN_SET) { key->state = KEY_STATE_IDLE; // 抖动,复位 } else if (now - key->last_tick >= 20) { key->state = KEY_STATE_CONFIRMED; // 20ms 消抖完成 key->last_tick = now; } break; case KEY_STATE_CONFIRMED: if (level == GPIO_PIN_SET) { key->state = KEY_STATE_IDLE; // 释放完成 } break; default: key->state = KEY_STATE_IDLE; break; } }关键参数:消抖时间 20ms,也就是两次连续的按键扫描。now来自HAL_GetTick()。业务逻辑里判断key->event,只有从KEY_STATE_PRESSED进入KEY_STATE_CONFIRMED的那一次扫描才把event置 1,这样一次按下只会产生一次有效事件。
在主循环里,三个按键分别调用扫描函数,然后根据当前模式处理事件:
key_scan(&key1, HAL_GetTick()); key_scan(&key2, HAL_GetTick()); key_scan(&key3, HAL_GetTick()); if (key1.event) { if (setting_mode) { setting_index++; if (setting_index > 2) { setting_mode = 0; threshold_save(&cfg); // 退出设置,保存阈值 } } else { setting_mode = 1; setting_index = 0; } } if (key2.event && setting_mode) { if (setting_index == 0) cfg.upper++; if (setting_index == 1) cfg.lower++; if (setting_index == 2) cfg.upper++; // 第三项为退出确认 }阈值调整时要做边界限制,比如上限不允许超过 100,下限不允许低于 -50,且上限必须大于下限。这些限制条件放在threshold_save之前统一校验,避免非法值写入 Flash。
3.3 阈值掉电存储:内部 Flash 模拟 EEPROM
用户设好的阈值,断电后不能丢。STM32F103 没有真正的 EEPROM,但内部 Flash 可以按页擦除、按半字编程。F103C8 的 Flash 大小为 64KB,最后一页地址是 0x0800FC00,页大小 1KB,恰好可以用来存两个阈值变量。
#define THRESHOLD_FLASH_BASE 0x0800FC00 typedef struct { uint16_t upper; uint16_t lower; } threshold_t; void threshold_save(const threshold_t *cfg) { HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPERR); FLASH_ErasePage(THRESHOLD_FLASH_BASE); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, THRESHOLD_FLASH_BASE, cfg->upper); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, THRESHOLD_FLASH_BASE + 2, cfg->lower); HAL_FLASH_Lock(); } threshold_t threshold_load(void) { threshold_t cfg; uint16_t *p = (uint16_t *)THRESHOLD_FLASH_BASE; cfg.upper = p[0]; cfg.lower = p[1]; return cfg; }三个注意事项。第一,写入前必须先擦除整页,Flash 不允许对非 0xFF 地址直接编程。第二,每次按键不要触发保存,只有退出设置模式时才调用一次threshold_save。第三,Flash 擦写寿命约 1 万次,如果用户的阈值调整频繁,保存前可以加一个判断,比较内存值和 Flash 值是否一致,不一致才写。
如果工程里启用了 IAP 或者把中断向量表重映射到了 Flash 末尾,0x0800FC00这个地址要重新评估,避免和程序代码冲突。简单做法是在链接脚本里显式预留最后 1KB,或者直接选用更高容量型号的最后几页。
4. OLED 显示与 Proteus 仿真联调:HAL 库驱动 SSD1306
4.1 硬件 I2C 还是软件 I2C
OLED 模块选用 0.96 寸 SSD1306,分辨率 128×64,I2C 接口,7 位地址 0x3C,写地址为 0x78。STM32F103 的硬件 I2C1 在 PB6(SCL)和 PB7(SDA)上,HAL 库写法理论上可以直接用HAL_I2C_Mem_Write发送命令和数据。但实际工程里硬件 I2C 的时序受中断影响更大,Proteus 仿真的硬件 I2C 模型和真实芯片又存在差异,因此我更推荐软件 I2C:不挑引脚、时序可控、出问题容易调试。这套 OLED 驱动函数换成软件模拟后,在任何 STM32 型号上都能直接复用。
软件 I2C 只需要两个 GPIO 做开漏输出,SCL 和 SDA 分别接 PB6、PB7。如果 PB6/PB7 被其他功能占用,换任意两个 IO 都可以,只改宏定义即可。
4.2 SSD1306 初始化序列和显示函数
SSD1306 上电后需要发送一组初始化命令,其中最重要的是开启电荷泵,否则屏幕不会亮:
#define OLED_ADDR_WRITE 0x78 /* 0x3C 左移一位 */ static void oled_write_byte(uint8_t data, uint8_t control) { i2c_start(); // SDA 拉低,SCL 高电平时产生起始信号 i2c_send_byte(OLED_ADDR_WRITE); // 从机地址 + 写标志 i2c_send_byte(control); // 0x00 表示命令,0x40 表示数据 i2c_send_byte(data); i2c_stop(); } static void oled_cmd(uint8_t cmd) { oled_write_byte(cmd, 0x00); } static void oled_data(uint8_t data) { oled_write_byte(data, 0x40); }初始化命令中比较关键的部分:
oled_cmd(0xAE); // 关闭显示 oled_cmd(0xD5); oled_cmd(0x80); // 显示时钟分频/振荡频率 oled_cmd(0xA8); oled_cmd(0x3F); // 复用率 1/64 oled_cmd(0x8D); oled_cmd(0x14); // 开启电荷泵,必须配置 oled_cmd(0xAF); // 打开显示参数说明:0x8D后面的0x14是开关内部电荷泵的使能位,写成 0x10 则电荷泵关闭,模块内部升压电路不工作,屏幕无显示。0xD5后面的0x80决定振荡频率和分频系数,频率太低时刷新率不够,点亮的测试画面会明显闪烁。
显示字符串时把字模数据放到常量数组,按列扫描的方式填充到 SSD1306 的 GDDRAM:
void oled_show_char(uint8_t x, uint8_t y, char ch) { uint8_t *font = &font6x8[(ch - ' ') * 6]; for (uint8_t i = 0; i < 6; i++) { oled_data(font[i]); // 一列一列写入 } }6×8 字体每个字符占 6 列,每列 8 像素恰好对应一页。128×64 的屏幕被分成 8 页,每页 8 行像素。设置页地址和列地址后,连续写入 6 字节就是完整字符。实际工程中我一般用 8×16 字体做主界面,数字够大,一眼就能看清阈值。
主循环中不要整屏清屏再重绘,否则温度刷新时屏幕会闪烁。做法是维护一个界面结构体,只有温度变化超过 0.1℃ 或者阈值被修改时,才更新对应的显示区域。显示一行的函数:
void oled_show_string(uint8_t page, uint8_t col, const char *str) { oled_set_page(page); // 设置页地址 0~7 oled_set_col(col); // 设置列地址 0~127 while (*str) { oled_show_char6x8(*str++); } }oled_set_page和oled_set_col分别发送0xB0 + page和0x00 + (col & 0x0F)、0x10 + (col >> 4)两组命令。列地址超出 127 后需要重新设置地址,否则数据会写进下一行,界面上出现错位。
4.3 Proteus 仿真搭建与常见坑
Proteus 工程里按这张表连接元件:
| STM32 引脚 | 外设器件 |
|---|---|
| PB1 | DS18B20 DQ |
| PB6 | OLED SCL |
| PB7 | OLED SDA |
| PB12 | 按键 K1 |
| PB13 | 按键 K2 |
| PB14 | 按键 K3 |
| PA1 | 报警 LED |
DS18B20 的 DQ 引脚需要接一颗 4.7k 电阻到 VCC,仿真里用 RES 组件,阻值设置 4700。STM32 的 VDDA、VDD 接 5V,VSSA、VSS 接地。
双击 STM32 芯片,在 Program File 项选择 Keil 编译输出的 HEX 文件。时钟配置如果使用外部 HSE,CubeMX 里把 RCC 设为 HSE Crystal,Proteus 芯片属性里的 Clock Frequency 设为 8MHz。这里最容易出的问题:程序内部配置外部晶振,但 Proteus 仿真默认时钟没设对,程序卡死在HAL_RCC_ClockConfig,现象是 OLED 不亮、LED 不闪、按键无响应。解决方式是把芯片属性 Clock 设置成和 CubeMX 一致的晶振频率,或者在 CubeMX 里改用 HSI 内部时钟,两个工作必须匹配。
OLED 在 Proteus 里的模型:较新版本的 Proteus 元件库可以直接搜索 SSD1306 或 OLED 128x64,选 I2C 版本,不要选成 SPI 接口的型号。如果库列表里没有,可以先用 LCD12864 液晶替代验证按键和报警逻辑,但单总线温度显示的画面内容和 SSD1306 有区别,不建议作为最终交付仿真。
Proteus 仿真时如果 DS18B20 温度一直显示 85℃,说明复位和读暂存器的主机时序有问题。先在仿真器里让程序跑在 debug 模式,在ds18b20_reset后加断点,确认 presence 标志为 1。如果仿真里存在脉冲一直检测不到,检查 DS18B20 的 DQ 是否与 PB1 正确连接,上拉电阻是否误接到了 GND。
5. 验证技巧:逻辑分析仪量时序、边界测试与长按连发
5.1 用逻辑分析仪抓单总线波形
手头有 24MHz 采样率的逻辑分析仪时,把通道夹在 PB1 上,采样率开到 2MHz 以上,触发方式选下降沿。复位时能看到 480us 的低电平,上升沿后存在约 200us 的低电平总线占用;读时序里每个时隙约 60us,低电平宽度和采样点位置都能直接量出来。实测最常见的问题是读字节采样点太晚,波形上每个读时隙的低电平只有 1us,但代码在释放总线后等了 10us 才采样,接近 DS18B20 的 15us 输出窗口上限。把delay_us(10)改成delay_us(4),采样点前移到释放后 4~5us,通常能解决。
5.2 报警阈值边界测试
把上限设为当前温度上方 0.5℃,然后改变传感器温度(真实硬件可以用手捏住传感器升温,仿真里可以直接双击 DS18B20 修改温度值),观察报警 LED 和 OLED 上的报警标志是否在越过阈值的瞬间翻转。下限同理。这个测试同时验证了温度换算的小数部分是否正确——如果显示 25.3℃ 但报警在 25℃ 就触发,说明浮点运算或者阈值比较用错了量纲,比如把阈值当整数、把温度当浮点直接比较,两者精度不同导致临界点错位。
5.3 长按连发调整阈值
按键调阈值时,从 20 调到 80 需要按 60 次,效率太低。在KEY_STATE_CONFIRMED状态里增加连发逻辑:按下超过 1000ms 后,每 200ms 自动执行一次阈值加或减。实现方式是在按键扫描结构体里增加一个repeat_tick,在确认状态的非阻塞分支检查:
if (now - key->last_tick >= 1000 && now - key->repeat_tick >= 200) { key->event = 1; // 连发事件 key->repeat_tick = now; }连发期间判断的是同一状态下的重复事件,不会改变状态机本身。长按 2 秒可以完成约 6 次调整,比逐次按明显省时间。
5.4 阈值保存的断电验证
在退出设置模式后,让程序延时 50ms 再允许下次写操作,然后直接切断仿真或板卡电源,重新上电后观察 OLED 显示的阈值。如果恢复的是默认值 30/20,而不是用户设置的 26/18,说明threshold_save没有在退出设置时被调用,或者 Flash 写入后立即断电导致编程未完成。Flash 编程操作需要时间,退出设置后至少延时 50ms 再断电,这段延时也同时保证了 OLED 刷新完最后一帧画面,避免下次上电显示残留。
本文还有配套的精品资源,点击获取