数据检测

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

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 / TIM1PCLK2 = 72 MHz,APB2 预分频为 1TIM1 = 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 改成输出和输入。通信顺序可以概括为:

  1. MCU 输出低电平至少约 18 ms,通知 DHT11 开始一次通信;
  2. MCU 输出高后切为输入,等待传感器响应;
  3. DHT11 先给出响应低/高脉冲,再发送 40 位数据;
  4. 每一位都有起始低电平,随后的高电平持续时间区分 0 和 1;驱动在进入高电平约 40 µs 后采样;
  5. 连续 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 的规格结论。