1. 从零开始:认识你的硬件伙伴

如果你刚接触单片机,看到一堆芯片和传感器可能会有点懵。别担心,我刚开始玩51单片机的时候,连杜邦线都分不清正反。今天咱们要聊的,就是怎么用最经典、最便宜的51单片机,去“指挥”一个同样经典、便宜的DHT11温湿度传感器,让它乖乖地把温度和湿度数据报上来。这就像是给你的单片机装上一个“感觉器官”,让它能感知周围的环境,这是很多智能小项目的第一步,比如做个自动浇花系统、一个简易的室内环境监测仪,或者一个能显示温湿度的小闹钟。

先说说这两位主角。51单片机,你可以把它理解为一个非常听话、但有点“笨”的小电脑。它需要你一句一句地教它做事(写程序),但它执行力超强,价格也便宜,是学习嵌入式开发的绝佳起点。而DHT11呢,就是一个专门负责“感受”温度和湿度的模块。它内部集成了传感器和一个简单的处理芯片,能把物理世界的温湿度转换成数字信号,通过一根数据线告诉单片机。它的优点就是超级省心,单总线通信,接线简单,数据也算够用。我实测过,在一般的室内环境下,它的精度对于日常DIY项目完全没问题。

那么,这个组合能干嘛?想象一下,你可以做一个放在花盆里的土壤湿度监测器(虽然DHT11测的是空气湿度,但可以间接参考),当空气太干燥时自动提醒你浇水;或者做一个婴儿房温湿度监控,数据异常就给你手机发个提醒;甚至结合一个继电器,做一个温度过高自动开启小风扇的装置。这些想法实现起来都不难,核心就是今天要讲的:让51单片机读懂DHT11。整个过程就像搭积木,硬件连接是搭好底座,软件编程是赋予它灵魂。下面,我就带你一步步把这套积木搭起来,并把它玩出花来。

2. 硬件连接:别让线接错了

动手之前,咱们得先把硬件连起来。这一步千万不能马虎,我见过不少新手朋友代码写得没问题,最后发现是线接反了或者接松了,白白折腾半天。

2.1 准备你的“工具箱”

你需要准备以下几样东西:

  1. 一块51单片机开发板:比如常见的STC89C52RC核心板。这是我们的主控大脑。
  2. 一个DHT11模块:建议直接买模块,它已经把传感器和必要的上拉电阻集成好了,用起来方便。模块通常有3个或4个引脚。
  3. 几根杜邦线:公对公的,用于连接。
  4. 一台电脑:用于编写和下载程序。
  5. USB数据线:给开发板供电和下载程序。

这里重点看DHT11模块。以常见的3引脚模块为例,引脚通常是这样的:

  • VCC:电源正极,接5V。
  • GND:电源负极,接GND。
  • DATA:数据引脚,接单片机的任意一个I/O口(我们后面用P3^3)。

如果是4引脚模块,会多出一个NC(空脚)或者S(可能指串行数据,但通常也用不到),我们只接VCC、GND和DATA就行。

2.2 接线图与实战要点

接线其实就三根线,但顺序要对:

  1. DHT11的VCC -> 开发板的5V引脚
  2. DHT11的GND -> 开发板的GND引脚
  3. DHT11的DATA -> 开发板的P3.3引脚(或其他你定义的I/O口,程序中要对应修改)。

注意:很多开发板上,P3.3这个引脚可能也被用作其他功能(比如下载程序的引脚之一),但通常不影响我们作为普通I/O口使用。如果你发现下载程序后无法运行,可以尝试换到P1口或P2口的任意引脚,记得同步修改程序里的引脚定义。

接好线后,通上电,DHT11模块上的指示灯可能会亮一下。这时候硬件部分就完成了。是不是很简单?但这里有个隐藏的“坑”:上拉电阻。DHT11的数据线是“开漏输出”,简单说就是它自己只能把电平拉低(输出0),要输出高电平(1)时需要外部电路帮忙“拉上去”。幸运的是,我们买的模块通常已经集成了一个4.7K或10K的上拉电阻在VCC和DATA之间。如果你用的是单独的DHT11传感器元件(不是模块),就必须自己在VCC和DATA之间焊一个4.7K的电阻,否则数据线永远无法变成高电平,通信肯定会失败。这是我早期踩过的一个坑,折腾了一下午才发现问题。

3. 软件编程:教单片机“说话”

硬件搭好了,接下来就是重头戏——写程序。单片机是个“哑巴”,我们必须用C语言告诉它每一步该怎么做。DHT11用的是单总线协议,这是一种比较节省I/O口的通信方式,但时序要求很严格,就像两个人用摩斯密码对话,节奏不能乱。

3.1 理解单总线通信的“摩斯密码”

DHT11的通信过程分为三步:启动信号 -> 响应信号 -> 数据读取。数据一共40位(5个字节),包括湿度整数、湿度小数、温度整数、温度小数和一个校验和。

启动信号:单片机要主动发起聊天。它先把数据线拉低至少18毫秒(这里我们用30ms更稳妥),然后拉高20-40微秒,之后就把数据线控制权交给DHT11,并切换到输入模式,准备“听”DHT11说话。

响应信号:DHT11如果在线,会先把数据线拉低约80微秒作为回应,然后再拉高80微秒,表示“我准备好了,数据马上来”。

数据读取:紧接着,DHT11开始发送40位数据。每一位数据都以一个50微秒的低电平起始位开始,随后的高电平持续时间决定了这一位是0还是1。26-28微秒的高电平表示‘0’,约70微秒的高电平表示‘1’。我们的任务就是在这个高电平期间去“采样”数据线的状态。

听起来有点复杂?别怕,我们把它翻译成代码。关键就在于精确的延时。51单片机执行一条指令的时间是微秒级的,所以我们需要用循环来制造微秒和毫秒级的等待。这里必须使用单片机内部晶振的频率来计算延时循环的次数。我们的程序基于常见的11.0592MHz晶振,这个频率也方便串口通信。

3.2 从检测模块到读取数据

我们先写一个最简单的程序,目标不是读数据,而是检测DHT11模块到底在不在线上,有没有正确响应。这是调试的第一步,能帮你快速排除硬件连接问题。

#include "reg52.h"
#include "intrins.h"

sbit ledOne = P3^7; // 用一个LED来指示状态
sbit dht = P3^3;   // DHT11的数据线接在P3.3

// 精确延时函数,基于11.0592MHz晶振
void Delay30ms() {
    unsigned char i, j;
    i = 54;
    j = 199;
    do { while (--j); } while (--i);
}

void Delay60us() {
    unsigned char i;
    i = 25;
    while (--i);
}

void check_DHT() {
    dht = 1; // 先确保数据线为高
    dht = 0; // 单片机拉低数据线,发起启动信号
    Delay30ms(); // 保持低电平30ms
    dht = 1; // 释放总线,拉高

    // 等待60us后,读取DHT11的响应
    Delay60us();
    if(dht == 0) { // 如果DHT11把线拉低了,说明它存在并给出了响应
        ledOne = 0; // 点亮LED,表示模块检测成功
    }
}

void main() {
    ledOne = 1; // 初始化LED熄灭
    Delay30ms(); // 稍微等待系统稳定
    check_DHT(); // 执行检测
    while(1); // 程序停在这里,观察LED状态
}

把这段代码编译下载到单片机里,如果连接正确,DHT11模块上的LED(如果有的话)会闪一下,同时你定义的ledOne(比如接在P3.7的LED)应该会亮起。如果灯没亮,别急,按这个顺序排查:1. 检查VCC和GND是否接反或接错;2. 检查DATA线是否接触不良;3. 检查代码中延时函数是否针对你的晶振频率(如果是12MHz,延时时间会不同);4. 换个DHT11模块试试。

检测通过后,我们就可以升级到完整的数据读取函数了。完整的读取函数需要严格遵循时序,在响应信号后,连续读取40位数据。

char datas[5]; // 用于存储读取到的5个字节数据

void DHT11_Start() {
    dht = 1;
    dht = 0;
    Delay30ms();
    dht = 1;
    // 等待DHT11拉低总线(响应信号开始)
    while(dht);
    // 等待DHT11拉高总线(响应信号结束)
    while(!dht);
    // 等待DHT11拉低总线(数据传送开始)
    while(dht);
}

void Read_Data_From_DHT() {
    char i, j; // i用于循环5个字节,j用于循环每个字节的8位
    char tmp;  // 临时存储一个字节
    char flag; // 临时存储一位数据

    DHT11_Start(); // 发送启动信号,并等待响应结束

    for(i = 0; i < 5; i++) {
        tmp = 0; // 每个字节开始前清零
        for(j = 0; j < 8; j++) {
            // 等待每位数据开始的低电平过去
            while(!dht);
            // 延时40us,这个时间点在0和1的高电平持续时间中间,用于采样
            Delay40us();
            // 采样数据线状态
            if(dht == 1) {
                flag = 1; // 高电平持续时间长,是'1'
                // 等待这个高电平结束
                while(dht);
            } else {
                flag = 0; // 高电平持续时间短,是'0'
            }
            // 将读到的一位数据左移到tmp中
            tmp = tmp << 1;
            tmp |= flag;
        }
        datas[i] = tmp; // 存储一个完整的字节
    }
}

这个Read_Data_From_DHT()函数是整个程序的核心。它执行后,datas数组里就存好了数据。其中:

  • datas[0]:湿度的整数部分(单位:%RH)
  • datas[1]:湿度的小数部分(通常为0)
  • datas[2]:温度的整数部分(单位:摄氏度)
  • datas[3]:温度的小数部分(通常为0)
  • datas[4]:校验和,等于datas[0]+datas[1]+datas[2]+datas[3]结果的低8位

你可以通过校验和来判断数据是否传输正确。如果校验失败,可能是通信过程受到了干扰,需要重新读取。

4. 让数据“说话”:显示与上传

光把数据读出来存在变量里可不行,我们得想办法看到它。这里介绍两种最常用的方式:通过串口发送到电脑串口助手显示,以及在LCD1602液晶屏上显示。

4.1 通过串口与电脑对话

串口是单片机调试的“神器”,可以把数据发送到电脑上用软件(如串口助手、Putty)查看。我们需要先初始化单片机的串口功能。

sfr AUXR = 0x8E; // 一些增强型51单片机有辅助寄存器,用于加速

void UartInit(void) { // 波特率9600 @11.0592MHz
    AUXR = 0x01;    // 定时器1速度加倍
    SCON = 0x40;    // 串口模式1,只发送,不接收
    TMOD &= 0x0F;   // 清零定时器1模式位
    TMOD |= 0x20;   // 定时器1,模式2(8位自动重装)
    TH1 = 0xFD;     // 计算出的重装值,对应9600波特率
    TL1 = 0xFD;
    TR1 = 1;        // 启动定时器1
}

void sendByte(char data_msg) {
    SBUF = data_msg; // 将数据放入发送缓冲区
    while(!TI);      // 等待发送完成
    TI = 0;          // 清除发送完成标志
}

void sendString(char* str) {
    while(*str != '\0'){
        sendByte(*str); // 逐个字符发送
        str++;
    }
}

有了串口发送函数,我们就可以在读取DHT11数据后,把温湿度格式化成字符串发送出去。

void main() {
    UartInit(); // 初始化串口
    Delay1000ms(); // 等待系统稳定
    while(1) {
        Delay1000ms(); // 每隔1秒读取一次
        Read_Data_From_DHT();

        // 发送湿度
        sendString("Humidity: ");
        sendByte(datas[0]/10 + '0'); // 十位数转字符
        sendByte(datas[0]%10 + '0'); // 个位数转字符
        sendString(".");
        sendByte(datas[1] + '0');    // 小数位转字符
        sendString("%RH\r\n");       // \r\n是换行符

        // 发送温度
        sendString("Temperature: ");
        sendByte(datas[2]/10 + '0');
        sendByte(datas[2]%10 + '0');
        sendString(".");
        sendByte(datas[3] + '0');
        sendString("C\r\n\r\n"); // 多一个空行更清晰
    }
}

打开电脑上的串口助手,选择正确的COM口和9600波特率,就能看到每秒刷新一次的温湿度数据了。这一步成功,意味着你的单片机已经能完整地获取并输出环境信息。

4.2 在LCD1602屏幕上显示

如果想让你的项目脱离电脑独立显示,LCD1602液晶屏是个好选择。它能显示两行,每行16个字符。驱动LCD1602需要按照特定的时序操作,包括写命令和写数据。

首先,要定义好数据线和控制线。通常我们用P0口作为8位数据线,用P1口的几个引脚作为控制线(RS、RW、EN)。

#define databuffer P0 // P0口作为数据总线
sbit RS = P1^0; // 寄存器选择(高电平数据,低电平命令)
sbit RW = P1^1; // 读写选择(高电平读,低电平写)
sbit EN = P1^4; // 使能信号(下降沿触发执行)

// 检查LCD是否忙(内部操作是否完成)
void check_busy() {
    char tmp = 0x80;
    databuffer = 0x80;
    while(tmp & 0x80) {
        RS = 0; // 选择命令寄存器
        RW = 1; // 读操作
        EN = 0;
        _nop_();
        EN = 1; // 使能
        _nop_(); _nop_();
        tmp = databuffer; // 读取状态字,最高位BF为1表示忙
        EN = 0;
        _nop_();
    }
}

// 向LCD写入命令
void Write_Cmd_Func(char cmd) {
    check_busy();
    RS = 0; // 命令
    RW = 0; // 写
    EN = 0;
    _nop_();
    databuffer = cmd; // 放置命令
    _nop_();
    EN = 1; // 产生下降沿,执行命令
    _nop_(); _nop_();
    EN = 0;
    _nop_();
}

// 向LCD写入要显示的数据
void Write_Data_Func(char dataShow) {
    check_busy();
    RS = 1; // 数据
    RW = 0; // 写
    EN = 0;
    _nop_();
    databuffer = dataShow; // 放置数据
    _nop_();
    EN = 1;
    _nop_(); _nop_();
    EN = 0;
    _nop_();
}

// LCD初始化,必须按照特定步骤
void LCD1602_INIT() {
    Delay15ms();
    Write_Cmd_Func(0x38); // 设置16x2显示,5x7点阵,8位数据接口
    Delay5ms();
    Write_Cmd_Func(0x38);
    Write_Cmd_Func(0x08); // 关闭显示
    Write_Cmd_Func(0x01); // 清屏
    Write_Cmd_Func(0x06); // 读写后光标右移,屏幕不移动
    Write_Cmd_Func(0x0c); // 开显示,不显示光标
}

初始化完成后,我们可以写一个函数,方便地在指定位置显示字符串。

void LCD1602_showLine(char row, char col, char *string) {
    char address;
    if(row == 1) {
        address = 0x80 + col; // 第一行地址从0x80开始
    } else {
        address = 0x80 + 0x40 + col; // 第二行地址从0xC0开始
    }
    Write_Cmd_Func(address); // 先设置显示地址
    while(*string) {
        Write_Data_Func(*string); // 逐个写入字符
        string++;
    }
}

最后,在主循环中,我们把从DHT11读取到的数据,转换成格式化的字符串,显示在LCD上。

char huma_str[9]; // 存储湿度字符串,如"H:45.00%"
char temp_str[9]; // 存储温度字符串,如"T:23.50C"

void Build_Display_String() {
    // 构建湿度字符串 "H:xx.xx%"
    huma_str[0] = 'H'; huma_str[1] = ':';
    huma_str[2] = datas[0]/10 + '0';
    huma_str[3] = datas[0]%10 + '0';
    huma_str[4] = '.';
    huma_str[5] = datas[1]/10 + '0';
    huma_str[6] = datas[1]%10 + '0';
    huma_str[7] = '%';
    huma_str[8] = '\0'; // 字符串结束符

    // 构建温度字符串 "T:xx.xxC"
    temp_str[0] = 'T'; temp_str[1] = ':';
    temp_str[2] = datas[2]/10 + '0';
    temp_str[3] = datas[2]%10 + '0';
    temp_str[4] = '.';
    temp_str[5] = datas[3]/10 + '0';
    temp_str[6] = datas[3]%10 + '0';
    temp_str[7] = 'C';
    temp_str[8] = '\0';
}

void main() {
    // ... 初始化串口、LCD、DHT11引脚等 ...
    LCD1602_INIT();
    LCD1602_showLine(1, 0, "DHT11 Monitor"); // 显示一个标题
    Delay1000ms();

    while(1) {
        Delay1000ms();
        Read_Data_From_DHT();
        Build_Display_String();
        // 在LCD第一行显示湿度,第二行显示温度
        LCD1602_showLine(1, 0, huma_str);
        LCD1602_showLine(2, 0, temp_str);
        // 同时也可以通过串口发送到电脑
        sendString(huma_str); sendString("  ");
        sendString(temp_str); sendString("\r\n");
    }
}

现在,你的作品就有了一个本地显示界面,看起来更像一个完整的设备了。LCD1602的对比度可能需要调节板载的可调电阻(电位器)才能清晰显示。

5. 项目进阶:从监测到控制

数据显示出来了,但这只是感知。真正的乐趣在于“控制”,让单片机根据环境数据做出反应。这才是智能硬件的精髓。我们做一个简单的扩展:当温度超过一定阈值(比如24摄氏度)时,自动打开一个散热风扇。

这个功能实现起来非常简单,只需要增加一个输出引脚控制一个继电器模块或者直接驱动一个小型直流风扇(如果电流不大)。假设我们用P1^6这个引脚来控制风扇。

sbit fan = P1^6; // 定义风扇控制引脚

void main() {
    // ... 之前的初始化代码(串口、LCD、DHT11)...

    fan = 1; // 初始化风扇关闭(假设高电平关闭,低电平开启,具体看你的驱动电路)

    while(1) {
        Delay1000ms();
        Read_Data_From_DHT();
        Build_Display_String();
        LCD1602_showLine(1, 0, huma_str);
        LCD1602_showLine(2, 0, temp_str);

        // 简单的控制逻辑
        if(datas[2] > 24) { // 如果温度整数部分大于24度
            fan = 0; // 开启风扇
            // 可以在LCD上显示一个提示,比如在温度后面加个"*"
        } else {
            fan = 1; // 关闭风扇
        }

        // ... 串口发送代码 ...
    }
}

这就实现了一个最简单的温控风扇。你可以把阈值改成其他值,或者增加湿度控制逻辑(比如湿度过高开启除湿器)。继电器模块可以控制更大功率的电器,比如加湿器、空调扇等。这样一来,你的项目就从单纯的“环境监测站”升级成了“环境调节小管家”。

在实际做这个控制部分时,有几点经验分享:一是最好加上“回差”控制,避免在阈值附近频繁开关。比如设置开启温度是26度,关闭温度是24度,这样温度在25度波动时风扇不会频繁启停。二是驱动电机类负载时,要在单片机引脚和负载之间做好隔离,比如用三极管或者MOS管驱动,防止反向电动势损坏单片机。三是如果控制220V市电设备,务必使用隔离良好的继电器模块,注意用电安全。

6. 调试心得与常见问题排坑

玩硬件,调试是家常便饭。下面是我在玩DHT11和51单片机时遇到的一些典型问题及解决办法,希望能帮你少走弯路。

问题一:数据读出来全是0或者255。 这通常意味着通信完全失败。

  • 首先检查硬件:VCC、GND、DATA三根线是否接牢?可以用万用表量一下VCC和GND之间是不是5V左右。DATA引脚和VCC之间是否有上拉电阻(模块自带,如果是裸传感器必须外接)。
  • 检查启动时序:确保单片机拉低的时间足够长(至少18ms)。可以用逻辑分析仪或者示波器抓一下DATA引脚上的波形,看看启动信号和响应信号是否符合时序图。没有仪器的话,就仔细核对延时函数的晶振频率设置。
  • 检查引脚定义:程序里的sbit dht = P3^3;必须和你实际连接的引脚一致。

问题二:数据偶尔正确,经常出错,校验和不通过。 这通常是时序不够精确或受到干扰。

  • 优化延时函数:微秒级延时Delay40us()Delay60us()是核心。如果单片机正在执行中断服务程序,可能会打断这些延时,造成时序错乱。在读取DHT11的40位数据期间,最好暂时关闭全局中断。
  • 增加重试机制:不要只读一次就相信数据。可以在Read_Data_From_DHT()函数里加入校验和检查,如果校验失败,就延迟一段时间后重新发起通信,重复几次直到成功。但两次读取之间必须间隔至少1秒,因为DHT11两次测量之间需要时间。
  • 检查电源:DHT11对电源纹波比较敏感。如果开发板电源质量不好,或者连接了其他大电流设备(如电机),可能会干扰DHT11工作。尝试给DHT11的VCC和GND之间并联一个100uF的电解电容,可以起到滤波稳压的作用。
  • 缩短连接线:过长的杜邦线会引入干扰和信号反射,尽量让DHT11模块靠近单片机,使用短导线连接。

问题三:LCD1602显示乱码或者不显示。

  • 调节对比度:这是最常见的原因。找到LCD模块背面的蓝色可调电阻,用螺丝刀慢慢旋转,直到字符清晰显示。
  • 检查初始化序列:LCD1602的初始化必须严格按照数据手册的步骤和延时进行。我提供的LCD1602_INIT()函数是经过验证的,确保你的延时函数Delay15ms()Delay5ms()准确。
  • 检查忙信号:在每次写命令或数据前,必须检查LCD是否忙。check_busy()函数就是干这个的。如果省略这一步,在LCD内部处理上一条指令时写入新数据,会导致数据丢失和乱码。
  • 确认接线:仔细核对RS、RW、EN、D0-D7这11根线是否都接对了,有没有虚焊或接触不良。

问题四:串口助手收不到数据或收到乱码。

  • 核对波特率:确保单片机程序里设置的波特率(如9600)和串口助手软件选择的波特率完全一致。
  • 检查电平:大多数51单片机开发板的串口是TTL电平(0V和5V),需要直接连接电脑的USB转TTL模块,不能接RS232接口(那是±12V电平,会烧板子)。
  • 检查接线:USB转TTL模块的TX接单片机的RX(P3.0),RX接单片机的TX(P3.1),GND接GND。别接反了。
  • 检查代码:确认UartInit()函数被正确调用,并且sendByte函数中的while(!TI); TI=0;逻辑完整,没有遗漏清除标志位。

调试的过程就是学习和积累经验的过程。每解决一个问题,你对这套系统的理解就会加深一层。我最开始做的时候,也因为一个忙等待函数没写好,导致LCD显示异常,排查了大半天。后来养成了习惯,每写一个底层驱动函数,都先用它做最简单的事情测试一下,比如让LCD显示一个固定的字符,或者让串口循环发送一个字母,确认底层没问题后,再往上叠加功能。这种模块化调试的方法效率很高。

把DHT11的数据稳定读出来,再清晰地显示或发送出去,最后加上一点简单的逻辑控制,一个完整的温湿度监测乃至控制系统就成型了。这个项目虽然小,但涵盖了单片机开发的完整流程:硬件连接、底层驱动编写、数据处理、人机交互(显示/通信)以及简单的控制逻辑。吃透它,你再去做更复杂的传感器、通信协议(比如I2C的OLED屏、SPI的Flash存储)或者更复杂的控制任务,会发现思路都是相通的。硬件编程的乐趣就在于这种从无到有、让机器按你想法运行的控制感。多动手,多踩坑,解决问题的过程本身就是最大的收获。

Logo

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

更多推荐