51单片机实战:DS18B20温度传感器从接线到数码管显示的完整指南
51单片机实战:从DS18B20传感器到数码管显示的完整温度监测系统构建
最近在整理工作室的旧项目,翻出了一个基于51单片机的温湿度记录仪。看着那块布满灰尘的开发板和略显简陋的数码管,突然想起当年为了搞定那个小小的DS18B20温度传感器,熬了好几个晚上研究时序图的日子。对于很多刚开始接触单片机传感器应用的开发者来说,DS18B20确实是个既让人爱又让人头疼的器件——它只需要一根数据线就能通信,节省了宝贵的IO口,但严格的单总线协议和时序要求,又让不少新手在调试时碰壁。如果你已经掌握了51单片机的基本编程,想真正把一个传感器“用起来”,特别是将采集到的数据直观地显示出来,那么从硬件连接到软件驱动,再到最终的数据可视化,这条完整的链路正是你需要打通的关键环节。这篇文章,我就结合自己踩过的坑和积累的经验,带你走一遍从DS18B20接线、时序理解、代码编写,到让温度值稳定显示在数码管上的全过程。我们不止步于让代码跑起来,更会深入探讨如何写出稳定、易维护的驱动,以及如何处理显示中的细节问题。
1. 项目核心:理解DS18B20与单总线通信的本质
在动手接线和写代码之前,我们必须先搞清楚DS18B20到底是如何工作的。很多人一上来就照着别人的代码抄,一旦遇到问题就束手无策,根本原因在于对底层通信机制不了解。DS18B20最大的特点就是采用了单总线(1-Wire)协议。顾名思义,单总线意味着只需要一根数据线(通常标记为DQ),加上电源和地线,共三根线,就能完成单片机与传感器之间的双向数据通信。这根数据线既要负责发送控制命令,又要负责读取温度数据,所有通信都通过严格的时间序列来区分。
这带来了两个核心挑战:时序的精确性和总线冲突的避免。51单片机作为主设备,必须严格按照DS18B20数据手册中规定的时间长度来拉高或拉低数据线,任何一个延时的不准确都可能导致通信失败。此外,总线上可能挂载多个DS18B20(通过唯一的64位ROM地址区分),因此读写操作都需要遵循一套复杂的命令序列。
DS18B20的温度转换结果存储在内部的**高速暂存器(Scratchpad)**中,这是一个9字节的存储器。我们最关心的是前两个字节,它们组成了16位的温度寄存器。
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 字节0 (LSB) | 温度值低8位 | 温度数据的低字节 |
| 字节1 (MSB) | 温度值高8位 | 温度数据的高字节,其中高5位为符号位 |
| 字节2 & 字节3 | TH & TL | 用户可设定的温度报警上下限 |
| 字节4 | 配置寄存器 | 用于设置温度分辨率(9-12位) |
| 字节5, 6, 7 | 保留 | |
| 字节8 | CRC校验 | 前8个字节的循环冗余校验码 |
温度数据的格式需要特别注意。这是一个12位的补码形式整数(上电默认),存储在两个字节中。高字节的高5位(bit15-bit11)是符号位。如果温度为正,这5位全为0;如果温度为负,这5位全为1。
提示:理解补码格式对正确处理负温度至关重要。许多初学者读取负温度时显示乱码,问题往往出在数据格式转换上。
实际温度的计算公式为: 温度值 = 读取的16位有符号整数 × 0.0625 (°C)
例如,读取到的两个字节为 0x01 (低字节) 和 0x91 (高字节),那么16位整数就是 0x9101。这是一个负数(因为高5位是1),需要先转换为原码再计算。而如果读取到 0x50 和 0x00,那么16位整数就是 0x0050,即十进制80,实际温度就是 80 × 0.0625 = 5.0°C。
2. 硬件连接与电路设计:为稳定性打下基础
理论清楚了,我们开始动手搭建硬件环境。一个可靠的硬件电路是软件稳定运行的前提,对于DS18B20这种对时序敏感的器件更是如此。常见的51单片机开发板(如STC89C52)和DS18B20模块为我们提供了便利,但自己设计电路时需要注意几个关键点。
首先是最基本的接线方式。DS18B20有三种供电模式:外部供电、寄生供电和强上拉供电。对于新手和大多数应用场景,我强烈推荐使用外部供电模式。这种模式稳定性最好,调试最简单。
接线非常简单:
- VDD (DS18B20引脚2): 接单片机系统的 +5V 电源。
- DQ (DS18B20引脚3): 接单片机的任意一个IO口(例如
P3^7),同时通过一个 4.7kΩ 的上拉电阻连接到 +5V。 - GND (DS18B20引脚1): 接系统的 GND。
这里重点说一下这个 4.7kΩ的上拉电阻。它是单总线通信正常工作的必要条件。因为DQ线在空闲时需要保持高电平,而单片机的IO口在设置为输入模式时呈高阻态,无法主动输出高电平。这个上拉电阻就是用来确保总线在空闲时处于确定的高电平状态。电阻值通常在4.7kΩ到10kΩ之间,4.7kΩ是经过验证最常用的值。
// 在代码中,我们这样定义连接的引脚
#include <reg52.h>
sbit DSPORT = P3^7; // 假设DS18B20的DQ线连接在P3.7引脚
其次是电源去耦。虽然DS18B20功耗很低,但在进行温度转换(尤其是高分辨率时)的瞬间电流会有所增大。为了确保电源稳定,建议在DS18B20的VDD和GND引脚之间,尽可能靠近传感器放置一个 0.1μF的瓷片电容,用于滤除高频噪声。
最后是关于长距离布线的考虑。如果你需要将传感器放置在离单片机较远的位置(超过1米),单总线的可靠性会下降。此时可以采取以下措施:
- 使用屏蔽线,并将屏蔽层单点接地。
- 适当减小上拉电阻的阻值(如改为2.2kΩ),以增强驱动能力。
- 降低通信速率(虽然DS18B20速率固定,但更稳定的电源和信号线有助于远距离通信)。
硬件连接好后,建议先用万用表测量一下VDD是否为稳定的5V,DQ引脚在空闲时是否为高电平(约5V),这是后续软件调试的基础。
3. 软件驱动开发:用代码“绘制”时序图
驱动DS18B20的本质,就是用单片机的IO口模拟出数据手册里那一张张严格的时序图。这个过程就像在时间轴上作画,每一笔(电平跳变)的起止时间都必须精确。很多教程只给出最终代码,但我想带你一起“读懂”时序图,这样无论遇到什么型号的单片机,你都能自己写出驱动。
3.1 初始化序列:检测传感器的“握手”信号
任何单总线通信都必须以初始化序列开始,目的是复位总线上的所有从设备,并检测是否有设备存在。
时序图解读与代码实现:
- 主机拉低总线至少480µs:这是复位脉冲,告诉所有DS18B20“注意,主机要发命令了”。
- 主机释放总线(拉高),并切换到输入模式,等待15-60µs。
- DS18B20回应:如果传感器存在,它会在主机释放总线后的15-60µs内,主动将总线拉低60-240µs,作为应答脉冲。
- 主机检测应答:主机在释放总线后,需要持续读取总线电平约480µs。如果在这期间读到了低电平,说明有设备应答。
/**
* @brief DS18B20初始化(复位与存在检测)
* @return uchar 1: 初始化成功,检测到器件;0: 初始化失败,未检测到器件
*/
uchar Ds18b20Init() {
uchar i = 0;
DSPORT = 0; // 主机拉低总线,开始复位脉冲
i = 70; while(i--); // 延时约642µs (满足480-960µs要求)
DSPORT = 1; // 主机释放总线,准备接收应答
// 延时一小段时间,等待传感器响应
i = 6; while(i--); // 延时约15µs
// 检测应答脉冲
while(DSPORT) { // 如果总线为高,说明无应答
DelayUs(60); // 每次检测等待约60µs
i++;
if(i > 80) { // 总等待时间超过约5ms,判定为超时
return 0; // 初始化失败
}
}
// 跳出循环,说明在60-240µs内检测到了低电平(应答)
// 等待应答脉冲结束(DS18B20释放总线)
i = 250; while(i--); // 延时约240µs
return 1; // 初始化成功
}
注意:这里的延时函数
DelayUs()和while(i--)循环的具体时长,取决于你使用的单片机主频。12MHz的51单片机,一个空循环大约几微秒。最好通过示波器或仿真来校准延时时间,这是调试成功的关键。
3.2 读写一位数据:时序的微观操作
读和写一位数据是单总线通信的基石。写一位时,主机通过控制低电平的持续时间来区分写“0”和写“1”。读一位时,主机先发起一个短暂的读时隙,然后采样总线状态。
写一位时序:
- 写“0”:主机拉低总线至少60µs,然后保持低电平直到整个时隙结束(通常>60µs)。
- 写“1”:主机拉低总线1-15µs,然后迅速释放总线(拉高),并在剩余时隙内保持高电平。
/**
* @brief 向DS18B20写入一个字节(低位在先)
* @param dat 要写入的字节数据
*/
void Ds18b20WriteByte(uchar dat) {
uchar j;
for(j=0; j<8; j++) {
DSPORT = 0; // 启动写时隙,拉低总线
_nop_(); _nop_(); // 极短延时,约2µs
// 根据要写的位(dat的最低位)决定总线电平
if(dat & 0x01) {
DSPORT = 1; // 写“1”,很快拉高
}
// 写“0”则保持低电平
// 保持低电平至少60µs
DelayUs(60);
DSPORT = 1; // 释放总线,结束写时隙
dat >>= 1; // 准备写下一个位
// 两个写时隙之间需要至少1µs的恢复时间
_nop_();
}
}
读一位时序:
- 主机拉低总线至少1µs。
- 主机释放总线(拉高)。
- 主机在拉低总线后的15µs内采样总线状态。此时,如果DS18B20要发送“0”,它会保持总线低电平;如果发送“1”,它会释放总线(总线被上拉电阻拉高)。
- 整个读时隙至少需要60µs。
/**
* @brief 从DS18B20读取一个字节(低位在先)
* @return uchar 读取到的字节数据
*/
uchar Ds18b20ReadByte() {
uchar i, j;
uchar byte = 0, bit = 0;
for(j=0; j<8; j++) {
DSPORT = 0; // 启动读时隙,拉低总线
_nop_(); // 短暂延时,约1µs
DSPORT = 1; // 主机释放总线
_nop_(); _nop_(); // 等待约10µs,让信号稳定
bit = DSPORT; // 采样总线电平,得到1位数据
// 将读到的位放到byte的正确位置上(本次循环读的是最低位)
byte >>= 1; // 先右移,为新的位腾出最低位
if(bit) {
byte |= 0x80; // 如果读到1,则设置byte的最高位
}
// 等待剩余的时隙时间(总时隙约60µs)
DelayUs(50);
}
return byte;
}
掌握了初始化、读一位、写一位这三个最底层的函数,你就拥有了与DS18B20对话的全部工具。后续的所有功能命令,都是通过组合调用这些函数来实现的。
4. 功能实现:温度转换、读取与数据处理
有了底层读写函数,我们就可以按照DS18B20的命令集来操作它了。一次完整的温度读取流程通常包括以下几个步骤,我建议将其封装成一个独立的函数 ReadTemperature()。
4.1 发送温度转换命令
读取温度的第一步是命令DS18B20开始一次AD转换。这个过程需要一定时间,分辨率越高,所需时间越长(12位分辨率最多需要750ms)。
void StartTemperatureConversion() {
Ds18b20Init(); // 1. 初始化
Ds18b20WriteByte(0xCC); // 2. 发送跳过ROM命令(适用于单传感器)
Ds18b20WriteByte(0x44); // 3. 发送温度转换命令
// 注意:发送0x44后,DS18B20开始转换,此时主机可以释放总线去做其他事情
// 也可以通过“读时隙”来查询转换是否完成(读总线,0为忙,1为完成)
}
4.2 读取温度寄存器值
转换完成后,我们需要发送命令读取存储在高速暂存器中的温度值。
int ReadTemperatureRaw() {
uchar tempL = 0, tempH = 0;
int temp_raw = 0;
Ds18b20Init(); // 1. 初始化
Ds18b20WriteByte(0xCC); // 2. 跳过ROM
Ds18b20WriteByte(0xBE); // 3. 发送读暂存器命令
tempL = Ds18b20ReadByte(); // 4. 先读低字节 (LSB)
tempH = Ds18b20ReadByte(); // 5. 再读高字节 (MSB)
// 将两个字节组合成一个16位整数
temp_raw = tempH;
temp_raw <<= 8;
temp_raw |= tempL;
return temp_raw; // 返回原始的16位温度数据
}
4.3 将原始数据转换为实际温度值
这是数据处理的核心。我们需要根据DS18B20的数据格式,将 temp_raw 这个16位有符号整数转换为实际的摄氏温度值,通常我们希望能得到带一位或两位小数的浮点数,或者将其放大为整数以便显示。
float ConvertRawToTemperature(int temp_raw) {
float temperature;
// 方法一:直接计算浮点数
temperature = temp_raw * 0.0625f; // 12位分辨率
return temperature;
// 方法二:转换为整数(放大100倍,保留两位小数)
// if(temp_raw & 0xF800) { // 判断高5位是否为1(负数)
// temp_raw = ~temp_raw + 1; // 取补码的补码得到原码(对于负数)
// temperature = -(temp_raw * 0.0625);
// } else {
// temperature = temp_raw * 0.0625;
// }
// return (int)(temperature * 100 + 0.5); // 四舍五入后转为整数
}
注意:处理负数时最容易出错。
temp_raw是补码形式。对于负数,其数值部分需要先取反加一(或按位取反后加1)得到原码的绝对值,再乘以0.0625并加上负号。上面的简化写法temp_raw * 0.0625f在支持有符号整数运算的编译器中是可行的,因为编译器会自动处理补码到实际值的转换。
4.4 封装一个完整的温度读取函数
将以上步骤整合,并加入简单的错误处理,形成一个健壮的读取函数。
/**
* @brief 读取一次DS18B20的温度值
* @param pTemp 指向存储温度值(浮点数)的指针
* @return uchar 1: 读取成功;0: 读取失败(传感器无响应)
*/
uchar GetTemperature(float *pTemp) {
int raw_temp;
if(!Ds18b20Init()) { // 初始化检测
return 0; // 传感器不存在或通信失败
}
StartTemperatureConversion(); // 开始转换
DelayMs(750); // 等待转换完成,12位分辨率最长时间
raw_temp = ReadTemperatureRaw(); // 读取原始值
*pTemp = ConvertRawToTemperature(raw_temp); // 转换
return 1; // 成功
}
5. 数据显示:在数码管上稳定呈现温度值
将得到的温度值显示在数码管上,是项目的“临门一脚”。这里涉及到数据拆分、数码管动态扫描以及如何优化显示效果。我们假设使用一个6位共阴数码管,通过74HC138或类似译码器进行位选。
5.1 数据拆分与格式化
假设我们得到的是放大100倍后的整数温度值 temp_int(例如23.45°C对应2345)。我们需要将其每一位分离出来,并处理小数点和符号。
// 假设 temp_int 是放大100倍后的温度整数(如2345代表23.45°C)
// DisplayBuffer[6] 用于存储6个数码管要显示的字形码
void PrepareDisplayData(int temp_int) {
uchar is_negative = 0;
uchar digits[6];
uchar i;
// 处理负数
if(temp_int < 0) {
is_negative = 1;
temp_int = -temp_int;
}
// 分离每一位数字(从个位开始分离)
digits[0] = temp_int % 10; // 百分位 (0.01°C)
digits[1] = (temp_int / 10) % 10; // 十分位 (0.1°C)
digits[2] = (temp_int / 100) % 10; // 个位
digits[3] = (temp_int / 1000) % 10; // 十位
// 更高位(百位、千位)通常为0,除非温度极端
// 将数字转换为数码管字形码,并处理小数点
// 字形码表,0-9对应0x3f, 0x06...
// 假设我们想在十位和个位之间显示小数点(显示XX.X°C)
DisplayBuffer[0] = SEG_CODE[digits[0]]; // 最低位,无小数点
DisplayBuffer[1] = SEG_CODE[digits[1]]; // 次低位,无小数点
DisplayBuffer[2] = SEG_CODE[digits[2]] | 0x80; // 个位,加上小数点 (0x80是小数点段)
DisplayBuffer[3] = SEG_CODE[digits[3]]; // 十位,无小数点
// 处理符号位
if(is_negative) {
DisplayBuffer[4] = 0x40; // “-”号的字形码
} else {
DisplayBuffer[4] = 0x00; // 不显示(或显示空格)
}
// 更高位可以显示其他信息或关闭
DisplayBuffer[5] = 0x00;
}
5.2 数码管动态扫描驱动
为了同时点亮多个数码管,必须采用动态扫描方式,即快速轮流点亮每一个数码管,利用人眼的视觉暂留效应形成“同时点亮”的错觉。
// 假设位选线由P2口的低三位控制(通过38译码器)
sbit LSA = P2^2;
sbit LSB = P2^3;
sbit LSC = P2^4;
void DigDisplay() {
static uchar pos = 0; // 当前扫描的位置
// 先关闭所有位选(消隐),防止鬼影
P0 = 0x00; // 关闭段选
// 根据pos设置位选信号,选择要点亮的数码管
switch(pos) {
case 0: LSA=0; LSB=0; LSC=0; break; // 第0位(最右边)
case 1: LSA=1; LSB=0; LSC=0; break; // 第1位
case 2: LSA=0; LSB=1; LSC=0; break; // 第2位
case 3: LSA=1; LSB=1; LSC=0; break; // 第3位
case 4: LSA=0; LSB=0; LSC=1; break; // 第4位
case 5: LSA=1; LSB=0; LSC=1; break; // 第5位(最左边)
}
// 送入该位置对应的段选数据
P0 = DisplayBuffer[pos];
// 移动到下一个位置,循环
pos++;
if(pos >= 6) {
pos = 0;
}
// 短暂延时,控制亮度。延时过长会闪烁,过短会变暗。
// 通常每个数码管点亮1-5ms,整个扫描周期控制在10-20ms以内(50-100Hz)
DelayUs(2000); // 延时2ms
}
5.3 主程序循环架构
将温度读取和显示结合起来,主程序的结构至关重要。我们需要平衡温度更新的频率和数码管扫描的流畅度。一个常见的策略是:在 main 函数的 while(1) 循环中,以最高优先级不间断地进行数码管扫描,而温度读取则每隔一段时间(如1秒)进行一次。
void main() {
float current_temp = 0.0;
u32 last_read_time = 0;
u32 system_tick = 0;
// 初始化定时器,用于产生1ms的系统节拍
Timer0_Init(); // 假设已配置好定时器0,每1ms产生一次中断,在中断里 system_tick++
while(1) {
// 任务1:数码管动态扫描(最高优先级,持续进行)
DigDisplay();
// 任务2:每隔1000ms读取一次温度
if(system_tick - last_read_time >= 1000) {
last_read_time = system_tick;
if(GetTemperature(¤t_temp)) {
// 读取成功,准备显示数据
PrepareDisplayData((int)(current_temp * 100)); // 放大100倍转为整数
} else {
// 读取失败,显示错误信息,例如“----”
DisplayError();
}
}
// 其他任务...
}
}
这种架构确保了显示不闪烁,同时温度数据也能定期更新。定时器中断的使用将 DigDisplay() 从固定的延时中解放出来,使得主循环可以更灵活地处理其他任务。调试时,如果发现显示数字有重影(鬼影),问题通常出在段选数据切换和位选切换的时机上,确保在切换位选前先关闭段选(消隐),就能解决大部分问题。
更多推荐
所有评论(0)