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 & 字节3TH & TL用户可设定的温度报警上下限
字节4配置寄存器用于设置温度分辨率(9-12位)
字节5, 6, 7保留
字节8CRC校验前8个字节的循环冗余校验码

温度数据的格式需要特别注意。这是一个12位的补码形式整数(上电默认),存储在两个字节中。高字节的高5位(bit15-bit11)是符号位。如果温度为正,这5位全为0;如果温度为负,这5位全为1。

提示:理解补码格式对正确处理负温度至关重要。许多初学者读取负温度时显示乱码,问题往往出在数据格式转换上。

实际温度的计算公式为: 温度值 = 读取的16位有符号整数 × 0.0625 (°C)

例如,读取到的两个字节为 0x01 (低字节) 和 0x91 (高字节),那么16位整数就是 0x9101。这是一个负数(因为高5位是1),需要先转换为原码再计算。而如果读取到 0x500x00,那么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米),单总线的可靠性会下降。此时可以采取以下措施:

  1. 使用屏蔽线,并将屏蔽层单点接地。
  2. 适当减小上拉电阻的阻值(如改为2.2kΩ),以增强驱动能力。
  3. 降低通信速率(虽然DS18B20速率固定,但更稳定的电源和信号线有助于远距离通信)。

硬件连接好后,建议先用万用表测量一下VDD是否为稳定的5V,DQ引脚在空闲时是否为高电平(约5V),这是后续软件调试的基础。

3. 软件驱动开发:用代码“绘制”时序图

驱动DS18B20的本质,就是用单片机的IO口模拟出数据手册里那一张张严格的时序图。这个过程就像在时间轴上作画,每一笔(电平跳变)的起止时间都必须精确。很多教程只给出最终代码,但我想带你一起“读懂”时序图,这样无论遇到什么型号的单片机,你都能自己写出驱动。

3.1 初始化序列:检测传感器的“握手”信号

任何单总线通信都必须以初始化序列开始,目的是复位总线上的所有从设备,并检测是否有设备存在。

时序图解读与代码实现:

  1. 主机拉低总线至少480µs:这是复位脉冲,告诉所有DS18B20“注意,主机要发命令了”。
  2. 主机释放总线(拉高),并切换到输入模式,等待15-60µs。
  3. DS18B20回应:如果传感器存在,它会在主机释放总线后的15-60µs内,主动将总线拉低60-240µs,作为应答脉冲。
  4. 主机检测应答:主机在释放总线后,需要持续读取总线电平约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. 主机拉低总线至少1µs。
  2. 主机释放总线(拉高)。
  3. 主机在拉低总线后的15µs内采样总线状态。此时,如果DS18B20要发送“0”,它会保持总线低电平;如果发送“1”,它会释放总线(总线被上拉电阻拉高)。
  4. 整个读时隙至少需要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() 从固定的延时中解放出来,使得主循环可以更灵活地处理其他任务。调试时,如果发现显示数字有重影(鬼影),问题通常出在段选数据切换和位选切换的时机上,确保在切换位选前先关闭段选(消隐),就能解决大部分问题。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐