数据检测
187.1.10 数据检测
/* DHT11/dht11.h:课程移入工程的 DHT11 驱动接口。 */
#ifndef __DHT11_H /* 如果尚未定义头文件保护宏,就编译本文件内容。 */
#define __DHT11_H /* 定义保护宏,避免同一编译单元重复展开声明。 */
#include "stm32f1xx_hal.h" /* 引入 HAL 的 GPIO、定时器和基础类型声明。 */
#include "stm32f1xx.h" /* 引入 STM32F1 器件级定义;原驱动依照课程源码保留。 */
extern uint8_t Data[5]; /* 声明定义在 dht11.c 中的 5 字节测量数组,供 main.c 使用。 */
extern TIM_HandleTypeDef htim1; /* 声明 CubeMX 在 tim.c 中定义的 TIM1 句柄。 */
void Delay_us(uint16_t us); /* 声明基于 TIM1 的微秒级忙等待函数。 */
void DHT_GPIO_SET_OUTPUT(void); /* 声明把 PA5 配置为输出的函数。 */
void DHT_GPIO_SET_INPUT(void); /* 声明把 PA5 配置为输入的函数。 */
uint8_t DHT_Read_Byte(void); /* 声明按 DHT11 时序读取一个字节的函数。 */
uint8_t DHT_Read(void); /* 声明启动一次完整测量并校验 5 字节数据的函数。 */
#endif /* 结束 DHT11 头文件保护范围。 */
/* DHT11/dht11.c:课程复用的 DHT11 单总线驱动。 */
#include "stm32f1xx.h" /* 引入器件和 HAL 相关定义。 */
#include "dht11.h" /* 引入本驱动的公开声明。 */
uint8_t Data[5]={0x00,0x00,0x00,0x00,0x00}; /* 保存湿度整数、湿度小数、温度整数、温度小数和校验字节。 */
void Delay_us(uint16_t us) /* 使用 TIM1 计数器实现近似微秒延时。 */
{
uint16_t differ = 0xffff-us-25; /* 计算起始计数值,使起点到 0xFFFF-25 nominally 相差 us 个计数。 */
__HAL_TIM_SET_COUNTER(&htim1,differ); /* 把 TIM1 当前计数器装载为刚算出的起点。 */
HAL_TIM_Base_Start(&htim1); /* 启动 TIM1 向上计数。 */
while(differ < 0xffff-25) /* 反复读取计数器,直到累计经过约 us 个 1 MHz 计数。 */
{
differ = __HAL_TIM_GET_COUNTER(&htim1); /* 读取 TIM1 当前计数值。 */
}
HAL_TIM_Base_Stop(&htim1); /* 达到目标计数后停止 TIM1。 */
}
void DHT_GPIO_SET_OUTPUT(void) /* 把 DHT11 数据脚 PA5 切换为 MCU 输出。 */
{
GPIO_InitTypeDef GPIO_InitStructure; /* 定义 GPIO 配置结构体;源码没有整体清零。 */
GPIO_InitStructure.Pin=GPIO_PIN_5; /* 选择 GPIOA 的第 5 号引脚。 */
GPIO_InitStructure.Mode=GPIO_MODE_OUTPUT_PP; /* 设置为推挽输出,用于主机拉低/拉高数据线。 */
GPIO_InitStructure.Speed=GPIO_SPEED_FREQ_HIGH; /* 设置输出速度等级。 */
HAL_GPIO_Init(GPIOA,&GPIO_InitStructure); /* 把配置写入 GPIOA。 */
}
void DHT_GPIO_SET_INPUT(void) /* 把 PA5 切换为输入,以读取 DHT11 返回的脉冲。 */
{
GPIO_InitTypeDef GPIO_InitStructure; /* 定义 GPIO 配置结构体;源码同样没有整体清零。 */
GPIO_InitStructure.Pin=GPIO_PIN_5; /* 继续操作 GPIOA 的第 5 号引脚。 */
GPIO_InitStructure.Mode=GPIO_MODE_INPUT; /* 设置为普通输入模式。 */
GPIO_InitStructure.Speed=GPIO_SPEED_FREQ_HIGH; /* 原源码保留此赋值;输入模式并不依靠输出速度驱动信号。 */
HAL_GPIO_Init(GPIOA,&GPIO_InitStructure); /* 应用 PA5 输入配置。 */
}
uint8_t DHT_Read_Byte(void) /* 按高电平持续时间判断 0/1,连续读取 8 位。 */
{
uint8_t ReadData=0; /* 用移位累积方式组装最终字节。 */
uint8_t temp; /* 暂存当前读取到的一位。 */
uint8_t retry=0; /* 记录等待电平变化的次数,防止永久卡死。 */
uint8_t i; /* 作为 8 位读取循环的计数变量。 */
for(i=0; i<8; i++) /* 一次 DHT11 数据字节包含 8 位。 */
{
while(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_5)==0 && retry<100) /* 等待每一位开始前的低电平结束。 */
{
Delay_us(1); /* 每轮约等待 1 微秒。 */
retry++; /* 累加等待次数,最多尝试 100 次。 */
}
retry=0; /* 为后面的高电平等待重新清零计数。 */
Delay_us(40); /* 进入高电平约 40 微秒后采样:短高脉冲应已结束,长高脉冲仍保持高。 */
if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_5)==1) temp=1; /* 40 微秒处仍为高,判定当前位为 1。 */
else temp=0; /* 40 微秒处已为低,判定当前位为 0。 */
while(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_5)==1 && retry<100) /* 等待当前位的高电平结束。 */
{
Delay_us(1); /* 每轮约等待 1 微秒。 */
retry++; /* 累加等待次数,限制等待时间。 */
}
retry=0; /* 为下一位重新清零等待计数。 */
ReadData<<=1; /* 已读取的位整体左移一位,为新位腾出最低位。 */
ReadData |= temp; /* 把当前位合并到最低位。 */
}
return ReadData; /* 返回组装完成的一个字节。 */
}
uint8_t DHT_Read(void) /* 主机发起一次 DHT11 读取,并接收 40 位数据。 */
{
uint8_t retry=0; /* 用于等待传感器响应电平结束。 */
uint8_t i; /* 用于读取 5 个字节。 */
DHT_GPIO_SET_OUTPUT(); /* 先把数据脚切换为 MCU 输出。 */
HAL_GPIO_WritePin(GPIOA,GPIO_PIN_5,GPIO_PIN_RESET); /* 主机把数据线拉低,发出开始信号。 */
HAL_Delay(18); /* 低电平保持 18 毫秒。 */
HAL_GPIO_WritePin(GPIOA,GPIO_PIN_5,GPIO_PIN_SET); /* 主机释放前先把输出写为高电平。 */
Delay_us(20); /* 等待约 20 微秒后准备读取传感器响应。 */
DHT_GPIO_SET_INPUT(); /* 把数据脚切换为输入,让 DHT11 驱动总线。 */
Delay_us(20); /* 再等待约 20 微秒后检查响应低电平。 */
if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_5)==0) /* 检测到低电平时进入响应和数据接收流程。 */
{
while(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_5)==0 && retry<100) /* 等待 DHT11 响应低电平结束。 */
{
Delay_us(1); /* 每轮约延时 1 微秒。 */
retry++; /* 累加超时计数。 */
}
retry=0; /* 清零计数,准备等待响应高电平结束。 */
while(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_5)==1 && retry<100) /* 等待 DHT11 响应高电平结束。 */
{
Delay_us(1); /* 每轮约延时 1 微秒。 */
retry++; /* 累加超时计数。 */
}
retry=0; /* 清零计数,准备读取 40 位数据。 */
for(i=0; i<5; i++) /* 连续读取 5 个字节。 */
{
Data[i] = DHT_Read_Byte(); /* 把第 i 个字节保存到全局数组。 */
}
Delay_us(50); /* 完成本次读取后再等待约 50 微秒。 */
}
uint32_t sum=Data[0]+Data[1]+Data[2]+Data[3]; /* 计算前四个数据字节之和。 */
if((sum)==Data[4]) return 1; /* 与第五个校验字节相等时返回 1。 */
else return 0; /* 校验不相等时返回 0。 */
}
/* main.c:本课新增或改变的业务调用。 */
#include "dht11.h" /* 引入 DHT_Read() 和外部数组 Data 的声明。 */
#define SEND_LEN 5 /* 本课发送 DHT11 的完整 5 字节数据包。 */
MX_TIM1_Init(); /* CubeMX 生成的 TIM1 初始化调用,为 Delay_us() 提供 1 MHz 计数器。 */
DHT_Read(); /* 在 C8T6 发送分支发起一次 DHT11 读取;课堂代码没有检查返回值。 */
uint32_t ret = Can_Send_Msg(0x12, SEND_LEN, Data); /* 把 Data 的 5 个字节作为 ID 0x12 的 CAN 数据帧发出。 */
源码范围说明:本课直接把已有 DHT11 驱动移入工程,所以代码块保留了该驱动实际使用的业务代码,并补充逐行解释;CubeMX 自动生成的完整
tim.c、启动文件和 HAL 库实现不重复抄录。驱动本身存在未检查读取结果、可能发送旧数据等局限,依照课程源码保留并在正文中说明。
一、温湿度传感器的代码
本课把 C8T6 节点从“发送固定数组”改为“读取 DHT11,再通过 CAN 发送 5 字节测量数据”。课程接线为:
DHT11 / DHT11 模块 STM32F103C8T6
VCC ──────── 3.3 V
GND ──────── GND
DATA ──────── PA5
DATA 是双向单总线:开始阶段由 MCU 输出低电平,随后 MCU 把 PA5 改为输入,由传感器返回响应和 40 位数据。因此,同一根物理线需要在输出与输入模式之间切换。
若使用裸的四脚 DHT11,DATA 通常还需要按对应数据手册接上拉电阻;三脚模块往往已经把上拉电阻和去耦电容集成在小板上,但不能只凭外观假定,应该查看模块原理图或实测。课程使用 3.3 V 供电,换用其他模块版本时必须重新核对其允许电压及上拉电压,不能把 5 V 上拉信号直接假定为所有 STM32 引脚都安全。
二、写温湿度传感器的代码
2.1 找到之前的文件
课程从 02Can_Test_Ok 复制出 03Can_DHT11,再把公共 DHT11 目录完整复制到新工程内。这样工程移动、打包或交给别人时,.uvprojx 不会继续依赖原公共目录中的绝对/相对路径。
复用旧驱动不代表无需审查。移植至少要核对:芯片和 HAL 版本、GPIO 引脚、GPIO 时钟、定时器句柄与计数频率、供电和数据线电平,以及驱动返回值含义。
2.2 打开工程
2.2.1 添加温湿度传感器代码
在 Keil 中把 DHT11/dht11.c 添加到 Bsp 分组,并在 Options for Target → C/C++ → Include Paths 加入新工程自己的 DHT11 目录。路径选择器默认可能停在第二个工程,因此必须通过完整路径确认选中的是 03Can_DHT11/DHT11。
.c 文件需要加入工程构建;.h 文件不一定要作为编译单元加入分组,但其目录必须位于包含路径中,源码也必须用 #include "dht11.h" 引入声明。
2.2.2 编译
加入驱动后立即 Build 的作用是尽早暴露缺失依赖。本次红线和错误指向 htim1/定时器相关名称,说明驱动依赖一个尚未配置的 TIM1。
视频说“先编译,否则 CubeMX 可能把内容弄没”不能理解为 Build 本身能保护文件。CubeMX 重新生成工程时,真正的保护措施是:
- 用户修改放在
USER CODE BEGIN/END区域; - 自定义驱动保存在独立目录并纳入版本控制或备份;
- 生成后检查
.uvprojx中自定义文件和 Include Path 是否仍存在; - 不直接修改下一次生成会覆盖的自动代码区域。
2.2.3 定时器初始化
课程在 CubeMX 中启用 TIM1 内部时钟,把 Prescaler 设置为 71,Period 保持 65535。计数频率公式为:
f_CNT = f_TIM1 / (PSC + 1) = 72 MHz / (71 + 1) = 1 MHz
因此理想情况下一个计数周期为:
T_CNT = 1 / 1 MHz = 1 µs
这里写 71 才是 72 分频,因为硬件实际除数为寄存器值加 1。MX_TIM1_Init() 生成后被加入 main() 的外设初始化序列。
原笔记“STM32F103 所有定时器时钟永远都是 72 MHz”过于绝对。当前工程之所以得到 72 MHz,是因为系统和 APB 分频配置如下:
| 时钟域 | 当前配置 | 定时器时钟 |
|---|---|---|
| APB2 / TIM1 | PCLK2 = 72 MHz,APB2 预分频为 1 | TIM1 = 72 MHz |
| APB1 / 常见通用定时器 | PCLK1 = 36 MHz,APB1 预分频为 2 | 定时器时钟 = 2 × PCLK1 = 72 MHz |
规则是:APB 预分频为 1 时,定时器时钟等于对应 PCLK;预分频大于 1 时,STM32F1 的定时器时钟通常为对应 PCLK 的 2 倍。改变时钟树后必须重新计算,不能只记住“72 MHz”。
2.2.3.1 定时器初始化示例
Delay_us() 并不是硬件自动延时函数,而是:设置 TIM1 计数值、启动、循环读计数器、到达目标后停止。0xFFFF-us-25 与终点 0xFFFF-25 相差 us 个计数;常量 25 把计数窗口移离溢出点,本身没有把 C 函数调用和循环开销精确补偿掉。因此实际延时会包含 HAL 调用与轮询开销,只能视为此工程、此优化设置下的近似值。
2.3 看初始化函数
2.3.1 初始化函数分析
DHT_GPIO_SET_OUTPUT() 与 DHT_GPIO_SET_INPUT() 分别把 PA5 改成输出和输入。通信顺序可以概括为:
- MCU 输出低电平至少约 18 ms,通知 DHT11 开始一次通信;
- MCU 输出高后切为输入,等待传感器响应;
- DHT11 先给出响应低/高脉冲,再发送 40 位数据;
- 每一位都有起始低电平,随后的高电平持续时间区分 0 和 1;驱动在进入高电平约 40 µs 后采样;
- 连续 8 位组成一个字节,5 个字节组成完整数据包。
这是 DHT 系列的专用单总线时序,不是 Dallas/Maxim 1-Wire 协议,不能把两者的命令和时序混用。
2.3.2 温湿度传感器时序分析
驱动直接调用 HAL_GPIO_Init(GPIOA, ...),却没有在驱动内部执行 __HAL_RCC_GPIOA_CLK_ENABLE()。当前工程的 MX_GPIO_Init() 已启用 GPIOA 时钟,所以 PA5 能工作;这并不是“ST-Link 一定会替所有工程初始化 GPIOA”的通用保证,而是当前 CubeMX 工程生成结果。
如果改到 PB5,必须确保 GPIOB 时钟已经启用;改到其他端口同理。最稳妥的判断方法是查看生成的 gpio.c 或 RCC 寄存器配置,而不是根据调试接口所在端口推测。
驱动还有两个初始化细节:
GPIO_InitTypeDef GPIO_InitStructure;没有整体清零,也没有明确填写Pull;重新配置 GPIO 时应确认所有有效成员都有确定值。- 输出模式采用推挽输出。DHT 单总线通常依赖上拉保持高电平,常见做法是主机只主动拉低、随后释放线路;课程代码通过写高再切输入完成通信,必须确保模块与电平连接符合实际硬件。
2.4 DHT11.c 后面的使用
实际函数名是 DHT_Read(),不是 OCR 中的 dht11_Read()。把 #include "dht11.h" 放入 main.c 后,发送分支调用:
DHT_Read() → Data[0..4] → Can_Send_Msg(0x12, 5, Data)
本课源码忽略了 DHT_Read() 返回值。即使校验失败,程序仍把 Data 发到 CAN 总线。学习时应把返回值理解为“这次读取是否通过驱动的校验”,而不是可有可无的装饰。
2.5 编译问题及数据获取
Data 在 dht11.c 中定义,在 dht11.h 中以 extern uint8_t Data[5]; 声明。extern 不再分配一份数组,只告诉其他源文件“这个对象定义在别处”,链接器最终把 main.c 中的引用指向同一个全局数组。
五个字节的含义是:
| 下标 | 含义 |
|---|---|
Data[0] | 相对湿度整数部分 |
Data[1] | 相对湿度小数部分;常见 DHT11 读数中通常为 0,但不是应随意改写的“保留位” |
Data[2] | 温度整数部分 |
Data[3] | 温度小数部分 |
Data[4] | 前四个字节的校验字节 |
原固定测试数组长 8 字节,DHT11 数据包长 5 字节,所以 CAN DLC 改为 5。课程再用 #define SEND_LEN 5 代替散落的数字,后续长度变化时只修改一处。
当前 Rx_Data[8] 仍保留 8 字节是合理的:经典 CAN 数据区最大 8 字节,接收端按实际 Recv_Len 处理前 5 字节即可。
2.6 打开串口
C8T6 发送端显示 send ok;ZET6 接收端会交替出现正确帧和大量 recv eorro。这里的 eorro 是源码拼写错误,意思是当前一次轮询没有拿到非零长度报文。
错误提示并不来自 DHT11 数据内容,而来自接收端轮询速度远高于发送频率:两帧之间 FIFO0 大部分时间为空,Can_Recv_Msg() 返回 0,主循环就打印错误。它是程序状态设计不合理造成的日志噪声,不应描述成“正常的 CAN 错误”。
2.7 数据解析
课程观察到 ID 为 0x12、DLC 为 5,数据中湿度和温度随触摸传感器发生变化,说明“传感器读取—CAN 发送—CAN 接收—串口显示”链路基本连通。
但仅观察数值变化不能证明每一帧都有效。当前发送端:
- 不检查
DHT_Read()的 0/1 返回值; - 读取失败时
Data可能保留上一次值; - 如果传感器完全没有响应,函数跳过更新数组,旧数据甚至全零数组仍可能被继续发送。
因此可靠应用应只在读取和校验成功后发布新数据,并明确记录无响应、超时与校验失败状态。本课没有写这些逻辑,代码块不强行补全。
2.8 DHT11.c 分析
18 ms 开始低电平和后续微秒级忙等待会占用 CPU,但“出现 recv eorro”的直接原因不是延时损坏了 CAN,而是发送间隔存在、接收端又把“当前无帧”打印成错误。
DHT11 的采样更新速度有限,不适合在无延时的 while(1) 中连续读取。不同批次/模块资料对最低采样间隔可能有差异,应以手中器件的数据手册为准;不能因为代码能快速调用,就认为传感器每次都产生了新的测量值。制造商提供了 DHT11 产品页 和 单总线例程下载,移植时还应结合具体版本规格书核对供电、上拉与采样周期。
最后还要区分“阻塞时间”和“采样周期”:HAL_Delay(18) 只是一次通信开始信号的一部分,并不自动满足传感器两次测量之间所需的完整等待时间。后续课程加入的 100 ms 延时也只是课堂现象修正,不应直接当作所有 DHT11 的规格结论。