☰
STM32F103标准库DHT11单总线驱动移植与时序避坑
2026/10/30 10:22:21 网站建设 项目流程

简介:这份STM32F103 DHT11驱动程序面向使用最小系统板学习环境监测的嵌入式初学者与单片机开发者,基于标准库实现单总线温湿度采集,重点解决GPIO时序控制、定时器或软件延时、中断接收与40位数据解析等移植难点,可直接用于课程实验、毕业设计和小型物联网终端原型。压缩包共211个文件,约5.75MB,包含50个h头文件与45个c源文件,便于查看驱动接口和模块实现;另有Keil工程文件(d、o、crf、uvprojx)、axf/hex下载文件及map、lst、sct等编译链接产物,并附少量bat、txt辅助文件,目录保留完整工程结构,方便对照编译与调试。已有955人学习,适合需要快速验证DHT11驱动、理解标准库配置并移植到其他STM32F103板卡的读者参考,也能从中梳理传感器时序排错与工程组织思路。

1. 为什么这块最小系统板配上 DHT11 驱动值得单独拆一遍

手头有一块 stm32f103 最小系统板,又买了个 DHT11 温湿度模块,很多人第一步就卡住:网上找的 dht11 驱动要么是 HAL 库命名法,要么绑死在某个开发板的引脚上,改个 GPIO 就编译一堆错。这份基于标准库的 dht11 驱动,恰恰是针对这个痛点写的:文件少、依赖少、引脚和延时全部靠宏导出,挪到自己的工程里主要就是改三四个定义。它适合刚上手嵌入式单片机、想先把「单总线器件怎么读」这件事跑通的人,也适合手里有老项目还在用标准库、不想为一个小传感器引入整套 HAL 的开发者。下面按工程骨架、时序实现、移植接口、踩坑排查、二次开发五段往下走。

2. 工程骨架:最小系统板接线与标准库目录怎么摆

先说清楚这套代码运行在什么环境里。目标是 STM32F103C8T6 这类最小系统板,72MHz 主频,标准外设库(StdPeriph Library)工程,编译工具链用 Keil 或 arm-none-eabi-gcc 都能过。整套驱动不依赖任何 RTOS,不依赖 printf 重定向,只要能提供一个微秒级延时和一个能被拉低拉高的 GPIO,它就能跑起来。这一点决定了它「简单易移植」不是口号,而是接口约束足够窄。

2.1 硬件连接与上拉电阻这件事

接线本身只有三根:VCC、GND、DATA。DHT11 的供电范围宽,3.3V 和 5V 都能工作,但在 STM32 的 GPIO 只耐受 3.3V 的前提下,直接给 5V 供电、数据线又直连 MCU 是有风险的。稳妥的做法是整体用 3.3V 供电,数据线加上拉。

信号DHT11 引脚最小系统板说明
VCC13.3V与 MCU 同域,避免电平不匹配
DATA2PA1(可改)单总线,需 4.7k~10k 上拉
NC3悬空部分模块此脚为空
GND4GND共地必接

上拉电阻是最容易被忽略的一环。DHT11 的数据线是开漏结构,总线释放后靠上拉拉高,如果没有外部上拉,只靠 MCU 内部弱上拉,边沿会变得很软,读出来的位宽判断就会飘。我一般直接在模块的 DATA 和 VCC 之间焊一个 4.7k 电阻,比在代码里反复调延时靠谱得多。

还有一点:DATA 线不要和电机、继电器、开关电源走同一束线。DHT11 的位宽是几十微秒量级,一根长线加一个干扰源,足够让高电平 26us 和 70us 的区分失效。

2.2 标准库工程的目录摆放

这套驱动拆成两个文件更利于移植:一个头文件放所有和硬件相关的宏与对外接口,一个源文件放时序实现。标准库工程里通常这样摆:

Project/ ├── Libraries/ │ ├── CMSIS/ # 内核头文件与启动文件 │ └── STM32F10x_StdPeriph_Driver/ # 标准外设库 ├── Hardware/ │ ├── dht11.c # 时序实现,不含板级细节 │ └── dht11.h # 引脚宏、对外接口、返回码 ├── User/ │ ├── main.c │ ├── stm32f10x_conf.h # 标准库头文件裁剪开关 │ ├── stm32f10x_it.c │ └── system_stm32f10x.c └── Output/

关键在于dht11.c里不出现GPIOA、GPIO_Pin_1这类具体名字,全部通过dht11.h里的宏间接引用。这样换板子的时候只动头文件,源文件一个字不改,代码评审时也容易看出改动范围。

stm32f10x_conf.h里至少要让stm32f10x_gpio.h、stm32f10x_rcc.h、misc.h处于启用状态。标准库工程最常见的编译错误就是某个外设头文件被注释掉了,然后报GPIO_InitTypeDef未定义,跟驱动本身没关系。

2.3 初始化顺序:时钟、GPIO、延时

标准库的初始化顺序有硬性依赖:先开时钟,再配 GPIO,最后才能用延时。顺序反了不会立刻崩,但会出现「第一次读失败、复位后又好了」这种玄学现象。

/* dht11.h 中与硬件绑定的部分,换板子只改这一段 */ #ifndef __DHT11_H #define __DHT11_H #include "stm32f10x.h" /* 引脚定义:改这三行即可迁到其他 GPIO */ #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_Pin_1 #define DHT11_GPIO_CLK RCC_APB2Periph_GPIOA /* 返回值约定,调用方据此决定是否重试 */ #define DHT11_OK 0 #define DHT11_ERR_NO_RESPONSE 1 /* 起始信号后没有等到响应 */ #define DHT11_ERR_CHECKSUM 2 /* 校验和不匹配 */ #define DHT11_ERR_TIMEOUT 3 /* 位读取超时,总线被拉死 */ void DHT11_Init(void); uint8_t DHT11_Read(uint8_t *temp, uint8_t *humi); #endif
/* dht11.c 中的初始化,注意时钟一定在最前面 */ void DHT11_Init(void) { GPIO_InitTypeDef gpio; /* 1. 使能 GPIO 时钟,标准库必须先开时钟再配寄存器 */ RCC_APB2PeriphClockCmd(DHT11_GPIO_CLK, ENABLE); /* 2. 默认配成开漏输出,总线空闲时由上拉拉高 */ gpio.GPIO_Pin = DHT11_GPIO_PIN; gpio.GPIO_Mode = GPIO_Mode_Out_OD; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &gpio); /* 3. 总线释放,交给上拉电阻维持高电平 */ GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); /* 4. 上电后器件需要时间稳定,这里留 1s,别省 */ delay_ms(1000); }

这段代码里有三个参数值得说清楚。GPIO_Mode_Out_OD选的是开漏输出而不是推挽,原因是单总线在同一根线上要双向传输,推挽输出在器件拉低总线时会形成对拉,长期这么干容易损坏 IO。GPIO_Speed_50MHz对 DHT11 这种几十微秒级的信号其实用不上,但开漏模式的上升沿靠上拉电阻,速度配置高一点不会更差,配低了反而边沿更软。最后的 1 秒延时是器件手册里要求的上电稳定时间,跳过它就会出现「第一批数据永远是 0」的经典现象,后面避坑章节还会展开。

3. 单总线时序:40 位数据是怎么一位一位抠出来的

DHT11 的通信是典型的主从单总线:主机拉低总线发起始信号,器件应答,然后器件主动输出 40 位数据。整个过程没有时钟线,全靠双方约定的时间窗口,所以这套驱动能不能用,八成取决于微秒延时准不准和采样点选得对不对。这一章把时序拆成延时、起始、逐位读取三段讲。

3.1 微秒级延时:SysTick 是最省事的那条路

标准库工程里通常没有现成的delay_us,很多人第一反应是写个空循环:

void delay_us(uint32_t n) { while (n--) __NOP(); }

这种写法在不同优化等级下循环次数完全不一样,-O0能跑、-O2就短了一半,属于典型的「我电脑上好好的」。要稳定,用 SysTick 做轮询式短延时,不占用中断。

/* * 基于 SysTick 的微秒延时,假定系统时钟 72MHz * SysTick 是 24 位递减计数器,LOAD 最大 0xFFFFFF * 因此单次可延时的上限约 0xFFFFFF / 72 ≈ 233015 us */ void delay_us(uint32_t us) { uint32_t reload = 72U * us; /* 72MHz 下 1us 计 72 个数 */ if (us == 0U) return; if (reload > 0xFFFFFFU) reload = 0xFFFFFFU; /* 超长延时截断,避免溢出 */ SysTick->LOAD = reload - 1U; SysTick->VAL = 0U; SysTick->CTRL = 0x00000005U; /* 时钟源 HCLK,使能计数,不开中断 */ while ((SysTick->CTRL & 0x00010000U) == 0U); /* 等 COUNTFLAG 置位 */ SysTick->CTRL = 0x00000000U; /* 关闭计数器,恢复现场 */ SysTick->VAL = 0U; } /* 毫秒延时直接叠加,误差在百微秒级,够用 */ void delay_ms(uint32_t ms) { while (ms--) delay_us(1000U); }

逻辑上,LOAD装的是重装载值,写reload - 1是因为计数器从该值递减到 0 产生一次计数周期。CTRL写成0x05表示选择内核时钟、使能计数、不产生中断,最后一位是ENABLE。轮询COUNTFLAG是第 16 位,置位说明计数完成。

参数说明:us传入的值建议不要超过 200000,超过会被截断成一次最大延时,行为不是「延时不生效」而是「延时时长变短」,这是最容易误判的地方。如果你的主频不是 72MHz,把72U换成实际主频的 MHz 数即可,这也是移植清单里的必改项。另外这个函数会独占 SysTick,工程里如果已经用 SysTick 做系统节拍,就得改成读 DWT 的 CYCCNT,或者干脆用一个定时器做延时基准,二者不要混用。

3.2 起始信号与响应检测

主机要发起一次读取,动作是:拉低总线至少 18ms,然后释放,等器件把总线拉低 80us 再拉高 80us 作为应答。这段代码里每一个delay_us的数值都直接对应手册里的时间窗。

/* 发起始信号并等待器件应答,返回 DHT11_OK 表示可以开始读数据 */ static uint8_t DHT11_StartAndWait(void) { /* 1. 主机拉低总线,至少 18ms,这里给 20ms 留余量 */ GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_ms(20); /* 2. 释放总线,20~40us 内器件会接管 */ GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(30); /* 3. 等器件把总线拉低(应答信号的第一段 80us 低电平) */ if (DHT11_WaitLevel(Bit_RESET, 100) != DHT11_OK) return DHT11_ERR_NO_RESPONSE; /* 4. 等器件释放总线(第二段 80us 高电平) */ if (DHT11_WaitLevel(Bit_SET, 100) != DHT11_OK) return DHT11_ERR_NO_RESPONSE; /* 5. 再等这一段高电平结束,之后就是 40 位数据 */ if (DHT11_WaitLevel(Bit_RESET, 100) != DHT11_OK) return DHT11_ERR_NO_RESPONSE; return DHT11_OK; }

逐段说明:第 1 段的 20ms 是下限余量,手册要求不小于 18ms,写 20 是给自己留安全边界;第 2 段释放后等 30us 再采样,是因为器件从检测到上升沿到拉低有自己的响应时间,太早采样会读到空闲高电平,误判成「无响应」。第 3、4、5 段都用同一个等待函数,把「等某个电平出现」抽象出来,避免到处写死循环。

DHT11_WaitLevel的实现要点是必须带超时:

/* 等待总线变成指定电平,timeout_us 为最长等待微秒数 */ static uint8_t DHT11_WaitLevel(BitAction level, uint32_t timeout_us) { while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) != level) { if (timeout_us-- == 0U) return DHT11_ERR_TIMEOUT; /* 器件没响应或总线被拉死 */ delay_us(1); } return DHT11_OK; }

参数说明:level用标准库的BitAction枚举,Bit_RESET是 0、Bit_SET是 1,比裸写 0/1 可读性好。timeout_us这里给 100,比任何一个 80us 的电平段都宽,既能覆盖正常时序,又能在器件损坏或线断时快速返回,不至于卡在 while 里出不来。血泪经验是:任何等待外部器件的循环都必须有超时,否则一个焊点虚焊就能让整个固件假死。

还有一处细节:GPIO_ReadInputDataBit读的是输入数据寄存器,它在开漏输出模式下依然反映引脚的真实电平,所以这里不需要来回切方向。如果你的工程把引脚配成了推挽输出,这个读法就会读到自己驱动的高电平,时序全错——这也是后面避坑章节的一条。

3.3 40 位逐位读取与校验

应答结束后,器件连续输出 40 位:8 位湿度整数、8 位湿度小数、8 位温度整数、8 位温度小数、8 位校验和,高位先出。每一位都以 50us 低电平开头,随后是高电平,高电平持续 26~28us 表示 0,持续 70us 表示 1。区分这两种情况的经典做法是「低电平结束后延时 40us 再采样」。

/* 读一个字节,高位在前 */ static uint8_t DHT11_ReadByte(void) { uint8_t i, data = 0U; for (i = 0U; i < 8U; i++) { /* 1. 等这一位的 50us 低电平结束 */ if (DHT11_WaitLevel(Bit_SET, 100) != DHT11_OK) return 0xFFU; /* 2. 延时 40us 后采样:0 的高电平已结束,1 的还没结束 */ delay_us(40); if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == Bit_SET) { data |= (uint8_t)(1U << (7U - i)); /* 高位先出,注意移位方向 */ /* 3. 等这一位的高电平结束,好让下一位从低电平开始 */ if (DHT11_WaitLevel(Bit_RESET, 100) != DHT11_OK) return 0xFFU; } } return data; }

采样点选 40us 是有讲究的。0 的高电平只有 26~28us,40us 时早已回落到低电平;1 的高电平约 70us,40us 时还在高电平上。采样点落在两者中间偏 1 的一侧,容错空间最大。如果把延时改成 30us,0 的尾部还残留一点高电平,就会读成 1;改成 60us,1 可能已经结束,又会读成 0。这个 40 是我反复试出来的甜点值,主频偏差较大时要重新标定。

移位方向7 - i对应高位先出。写反了不会报错,只会得到一串看似合理但完全对不上的数值,湿度永远是 0、温度永远是个位数,第一次遇到很容易怀疑传感器坏了。

return 0xFF在这里当错误标记用,因为正常数据里 0xFF 极少单独出现,而且外层还有校验和兜底。严格一点的做法是改成返回状态码加出参,但驱动保持简单也有它的价值。

3.4 一次完整读取的封装

把上面几段拼起来,对外只暴露一个函数。返回值分三类:成功、无响应、校验失败,调用方据此决定是重试还是丢弃。

/* * 读取一次温湿度 * temp、humi 为输出参数,单位分别是摄氏度和百分比 RH * 返回 DHT11_OK 表示数据有效 */ uint8_t DHT11_Read(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i, sum; if (temp == 0 || humi == 0) return DHT11_ERR_TIMEOUT; if (DHT11_StartAndWait() != DHT11_OK) return DHT11_ERR_NO_RESPONSE; for (i = 0U; i < 5U; i++) buf[i] = DHT11_ReadByte(); /* 校验和 = 前四个字节相加取低 8 位 */ sum = (uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]); if (sum != buf[4]) return DHT11_ERR_CHECKSUM; *humi = buf[0]; /* 湿度整数部分 */ *temp = buf[2]; /* 温度整数部分 */ return DHT11_OK; }

数据格式对照表放在这里更直观:

字节序号含义说明
buf[0]湿度整数单位 %RH
buf[1]湿度小数DHT11 恒为 0
buf[2]温度整数单位 ℃
buf[3]温度小数DHT11 恒为 0
buf[4]校验和前四字节之和的低 8 位

注意 DHT11 只给整数精度,想要小数得换 DHT22 或 SHT 系列,这不是驱动的问题。另外温度是 8 位无符号表示,0~50℃ 范围内没问题,如果以后换成分辨率更高、支持负温的型号,这里要改成有符号处理,别直接沿用。

4. 移植接口:换引脚、换主频、换工程只动三个地方

「简单易移植」要落到可执行的检查清单上,否则就是一句空话。这套驱动的移植面一共三处:引脚宏、主频常量、延时函数实现。其他地方都和硬件无关,这也是当初拆文件时刻意守住的边界。

4.1 把硬件相关的东西全塞进一个头文件

dht11.h里的宏分三类:引脚、时钟、返回码。前两类是必须改的,返回码一般不动。

/* 迁到 PB5 的写法:只改这三行 */ #define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_5 #define DHT11_GPIO_CLK RCC_APB2Periph_GPIOB

RCC_APB2Periph_GPIOB这个宏一定要和端口对应上,写错不会报编译错误,只会表现为「初始化成功但读不到数据」,因为时钟压根没开,写寄存器的操作全部无效。我在评审别人的移植代码时,第一眼就看这三个宏是否成套。

如果目标板用的是 GPIOE 这类挂在 APB2 上的端口,宏名仍然是RCC_APB2Periph_GPIOE,标准库已经封装好了。F103 上 GPIO 全在 APB2,不存在 APB1 的情况,这一点比 F4 系列省心。

4.2 GPIO 方向切换的两种写法与取舍

单总线需要双向,所以「什么时候是输出、什么时候是输入」有两种实现路线。

第一种是一直保持开漏输出,靠写 ODR 释放总线,读的时候读 IDR。好处是不用反复调GPIO_Init,时序连贯,中断打断的概率低;缺点是要求外部必须有上拉,否则释放后总线是浮空的。

第二种是发完起始信号后把引脚切成上拉输入,读完再切回输出:

/* 切换到上拉输入,准备接收器件数据 */ static void DHT11_PinAsInput(void) { GPIO_InitTypeDef gpio; gpio.GPIO_Pin = DHT11_GPIO_PIN; gpio.GPIO_Mode = GPIO_Mode_IPU; /* 上拉输入 */ gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &gpio); } /* 切回开漏输出,准备下一次拉低总线 */ static void DHT11_PinAsOutput(void) { GPIO_InitTypeDef gpio; gpio.GPIO_Pin = DHT11_GPIO_PIN; gpio.GPIO_Mode = GPIO_Mode_Out_OD; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &gpio); }

两种写法都能跑,差别在容错。板上没有外部上拉时,第二种靠内部上拉能勉强工作,但内部上拉是几十千欧量级,边沿很软,读出来偶发校验错;第一种没有外部上拉则直接读不到数据。我的选择是:硬件上必焊 4.7k 上拉,软件上用第一种,代码更短,时序抖动更小。只有在改不了硬件的场合才退回第二种。

GPIO_Init每次调用都会重写 CRL/CRH 寄存器,本身不慢,但它会把整个端口的配置重算一遍,如果同一端口上还有别的引脚在用,参数必须写全,不能只写GPIO_Pin就完事——标准库的行为是只改你指定的引脚位,这一点倒是安全的。

4.3 换主频、换编译器时的检查清单

移植翻车的第二大来源是主频。72MHz 的工程挪到 8MHz 内部时钟的板子上,delay_us里的 72 没改,所有延时会被拉长 9 倍,时序彻底崩掉,现象是「一直返回无响应」。

检查清单按顺序走:

  1. 确认SystemCoreClock的实际值,别信注释。在main开头打印或打断点看一眼。
  2. 把delay_us里的72U换成SystemCoreClock / 1000000U,让延时自适应主频。
  3. 确认SysTick没有被 RTOS 或别的模块占用。占用了就换 DWT 或独立定时器。
  4. 确认优化等级。-O2下如果延时函数被内联或优化掉,时序会偏,给延时函数加__attribute__((noinline))或volatile修饰关键变量。
  5. 确认stm32f10x_conf.h里 GPIO、RCC、misc 三个头文件是打开的。

第 4 条尤其值得说。有些工程为了让延时更准,会把delay_us写成static inline,结果编译器把整个循环优化成了常量计算,实际延时变成 0。判断方法很简单:把逻辑分析仪挂在 DATA 线上,看起始信号的低电平宽度是不是 20ms 左右,差一个数量级就说明延时没生效。

换成 GCC 工具链时还有个小坑:BitAction这个枚举在标准库里定义在stm32f10x_gpio.h,某些裁剪过的库版本里会被条件编译掉,报未定义。这时把DHT11_WaitLevel的参数类型改成uint8_t即可,逻辑不变。

5. 避坑与排查:五个真实踩过的坑

这一章按「现象 → 原因 → 解决」写,都是这套驱动在实际项目里出过的问题。看完能省掉不少对着示波器发呆的时间。

5.1 上电第一次读永远失败,之后恢复正常

现象:冷启动后第一次调用DHT11_Read返回校验错或无响应,复位一次之后又好了,或者连续读几次就正常了。

原因:DHT11 上电后内部需要一段时间稳定,手册要求不小于 1s,而且第一次转换的数据本身不可靠。很多例程把DHT11_Init里的延时省掉,或者只给 100ms,就正好踩在这个窗口里。

解决:初始化里保留 1s 延时;应用层再加一层「首帧丢弃」逻辑,上电后第一次读取的结果直接扔掉,从第二次开始采用。这个习惯我后来在所有单总线器件上都保留了,包括 DS18B20。

5.2 连续读取时好时坏,间隔越短失败率越高

现象:单次读取正常,放进 100ms 周期的定时任务里就频繁校验失败,把周期改成 2s 又正常了。

原因:DHT11 的采样周期不能低于 1s,实测低于 1.5s 就开始不稳定。它不是「读完就能立刻再读」的器件,内部有一次完整的温湿度转换过程,转换期间总线上的响应会异常。

解决:把读取周期固定在 2s 以上。如果业务上需要更快的数据刷新率,别在这颗器件上死磕,换 DHT22 或者 I2C 接口的传感器。用定时器节拍驱动读取,不要放在主循环里裸跑,容易因为别的地方耗时变化导致间隔抖动。

5.3 校验和总是差一点点,或者数据全 0

现象:能收到响应,40 位也读完了,但校验和对不上;或者读出来湿度温度都是 0。

原因:最常见的是采样点偏了。delay_us(40)里的 40 是基于 72MHz 标定的,如果主频改了没同步改延时,采样点整体偏移,0 和 1 判断错误,校验和自然不对。其次是上拉电阻缺失或阻值过大,边沿太软导致高电平宽度测量偏差。

解决:先用逻辑分析仪看一位数据的高电平宽度,0 应该在 26~28us,1 应该在 70us 左右,偏差超过 20% 就调延时系数。没有分析仪就用串口把 5 个字节原样打印出来,看是不是规律性地全 0 或全 0xFF,前者多半是采样点太晚,后者多半是太早。

5.4 换了引脚就完全读不到,代码看着没问题

现象:从 PA1 改到 PB5,编译通过,运行后一直返回无响应。

原因:三个宏只改了两个,DHT11_GPIO_CLK还留着RCC_APB2Periph_GPIOA,GPIOB 的时钟没开,所有配置写不进去。标准库对未开时钟的 GPIO 写操作不报错,属于静默失败。

解决:把三个宏写在一起,改的时候整块替换。更稳妥的做法是把端口和时钟绑成一个宏,用#define DHT11_GPIO_CLK RCC_APB2Periph_GPIO##PORT这种拼接,虽然可读性差一点,但不会漏。我个人还是倾向手写三行加一句注释「三个一起改」。

5.5 加了串口打印之后就再也不成功

现象:单独跑驱动正常,一旦在读取过程中调用printf打印调试信息,读取就失败。

原因:printf重定向到串口是阻塞式的,一个字符串打出去可能几百微秒到几毫秒,直接打断了 40us 级别的位采样。同理,任何中断服务程序如果在读取期间执行时间偏长,也会造成同样的后果。

解决:读取函数执行期间不要打印,把结果存到全局变量,读完再统一输出。如果系统里有高优先级中断(比如串口接收、定时器),在DHT11_StartAndWait到读完 40 位这段临界区里关中断,读完立刻开。这段临界区总时长约 5ms,对多数应用可以接受;如果中断实时性要求极高,就改用状态机加定时器捕获的方式解析,别用阻塞式延时。

6. 二次开发:把驱动接进自己的采集框架

驱动跑通只是起点,真正要用起来,一般会做三件事:统一接口、加滤波、加调度。我习惯先定义一个传感器抽象层,把 DHT11 挂上去,这样以后换型号只改一个文件。

/* sensor.h:统一接口,返回值 0 表示成功 */ typedef struct { const char *name; uint8_t (*init)(void); uint8_t (*read)(float *temp, float *humi); } sensor_t; /* dht11_adapter.c:把整型驱动包成浮点接口 */ static uint8_t dht11_init_adapter(void) { DHT11_Init(); return 0; } static uint8_t dht11_read_adapter(float *temp, float *humi) { uint8_t t, h; if (DHT11_Read(&t, &h) != DHT11_OK) return 1; *temp = (float)t; *humi = (float)h; return 0; } const sensor_t sensor_dht11 = { "DHT11", dht11_init_adapter, dht11_read_adapter };

这层适配看起来是多写了一层,但收益在后面:应用层只认sensor_t,采集任务不用关心底层是单总线还是 I2C。

第二件事是滤波。DHT11 的整数精度本身跳变就大,直接显示会让人觉得「这传感器不准」。我一般做 5 点滑动平均,再对温度做一阶低通:

/* 5 点滑动平均,buf 由调用方维护,idx 指向最旧数据 */ float moving_average(float *buf, uint8_t *idx, float new_val) { float sum = 0.0f; uint8_t i; buf[*idx] = new_val; *idx = (uint8_t)((*idx + 1U) % 5U); for (i = 0U; i < 5U; i++) sum += buf[i]; return sum / 5.0f; }

滑动平均的窗口别开太大,DHT11 两秒才出一个数,开 10 点意味着 20 秒的滞后,温度变化就跟不上了。5 点是经验值。

第三件事是调度。用 SysTick 或者一个基本定时器产生 1ms 节拍,在节拍里做计数,每到 2000ms 触发一次读取,读到的数据放进全局结构体,主循环只管消费。这样读取的时序不受主循环里其他逻辑的影响,稳定性会明显好一截。

验证方法我通常用两步:先把原始 5 字节通过串口按十六进制打出来,和校验和一起看,确认帧结构没问题;再拿一个独立的温湿度计放在旁边做对比,室温下两者差值在 2℃、5%RH 以内就算正常。如果差值大,先别怀疑驱动,检查传感器是不是被板子上的发热元件烤到了,DHT11 贴着稳压芯片装,温度读数能高出五六度。

这套代码我前后在不同板子上移植过几次,最省事的做法始终是:新建工程时先把dht11.c和dht11.h原样拷进去,改三个宏,把延时函数的主频常量跟工程对齐,然后拿逻辑分析仪看一眼起始信号的低电平宽度,对了再往下走。从那以后我每次移植外设驱动都强制先量一个时序,不再靠「编译过了应该没问题」这种侥幸,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询