Linux 内核定时器与 DHT11:从内核机制到裸机驱动的完整解析
本文整理了中断处理、进程线程、文件描述符、内核定时器,以及 DHT11 单总线传感器驱动的核心知识点,适合嵌入式与 Linux 驱动方向的读者系统复习。
一、中断处理函数
1.1 什么是 ISR
中断处理函数是操作系统和嵌入式系统中响应硬件事件的核心机制。当外设产生中断请求时,CPU 暂停当前程序,保存现场,跳转到预先注册的中断处理函数执行,处理完成后再恢复现场并继续执行。
正常执行 ──► 中断发生 ──► 保存现场 ──► 执行 ISR ──► 恢复现场 ──► 继续执行
在 Linux 内核中,ISR 运行在中断上下文,不能睡眠、不能阻塞,也不能执行可能引发调度的操作。
1.2 编写要点
- 尽量短小:ISR 执行期间通常会屏蔽同级或更低优先级中断。
- 不可阻塞:不能睡眠,不能调用可能引起调度的函数。
- 注意并发:多核环境下同一中断可能在不同 CPU 上并发执行。
- 清除中断标志:否则硬件会反复触发中断。
- 返回值语义 :Linux 中通常返回
IRQ_HANDLED或IRQ_NONE。
1.3 通用模板
void XXX_IRQHandler(void)
{
uint32_t status = DEV->INT_STATUS; // 1. 取状态
if (!(status & DEV_INT_FLAG)) // 2. 判断是否本设备
return;
DEV->INT_CLR = status; // 3. 清标志
dev->counter++; // 4. 快速处理
schedule_bottom_half(dev); // 5. 触发下半部
}
核心原则:ISR 只做"确认中断 + 清标志 + 搬数据 + 发通知",其余交给下半部。
二、进程与线程
2.1 核心区别
| 维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立 | 共享所属进程 |
| 切换开销 | 大 | 小 |
| 通信方式 | IPC | 直接读写共享变量 |
| 隔离性 | 强 | 弱 |
2.2 内存布局
进程地址空间
├── 代码段 / 数据段 / 堆 ← 线程共享
├── 文件描述符表 ← 线程共享
├── 线程1:栈 + 寄存器 ← 线程独有
├── 线程2:栈 + 寄存器 ← 线程独有
2.3 选型建议
| 场景 | 推荐 |
|---|---|
| 强隔离、稳定性优先 | 进程 |
| 频繁通信、数据共享多 | 线程 |
| 崩溃不能互相影响 | 进程 |
一句话:进程 = 资源 + 隔离;线程 = 执行 + 共享。
三、文件描述符
3.1 本质
文件描述符是一个非负整数 ,是进程访问内核资源的句柄。它本质上是进程 fd 表中的索引,指向内核的打开文件表项,最终再指向 inode。
fd(整数)
└── 属于 → 进程的 fd 表
└── 指向 → 内核打开文件表项
└── 指向 → inode
3.2 三个默认 fd
| fd | 名称 | 宏 |
|---|---|---|
| 0 | 标准输入 | STDIN_FILENO |
| 1 | 标准输出 | STDOUT_FILENO |
| 2 | 标准错误 | STDERR_FILENO |
3.3 分配规则
内核总是分配当前最小的可用整数 。这个特性常被用来做重定向:先 close(1),再 open(...),新文件就会占用 fd 1。
3.4 "一切皆文件"
fd 可以代表普通文件、目录、设备、管道、socket、eventfd、epoll 实例等。Unix 的统一 I/O 接口就体现在这里。
四、内核定时器
4.1 本质
内核定时器建立在硬件定时器之上:硬件产生 tick,内核用 tick 驱动软件定时器子系统,管理大量"在某个时刻执行某个函数"的请求。
硬件定时器 ──► tick 中断 ──► 检查到期定时器 ──► 执行回调
关键点:timer_list 的回调运行在软中断上下文,不能睡眠。
4.2 时间基准
unsigned long timeout = jiffies + msecs_to_jiffies(500); // 500ms 后
unsigned long timeout2 = jiffies + HZ; // 1 秒后
HZ:每秒 tick 数。jiffies:自启动以来的 tick 计数。jiffies会回绕,比较必须使用time_after()等宏。
4.3 老代码的典型问题
下面这段代码很常见,但存在多处问题:
static void timeout(unsigned long data)
{
pr_info("timeout ok!\n");
}
static DEFINE_TIMER(mtimer, timeout, 0, 0);
static int __init kernel_timer_init(void)
{
init_timer(&mtimer); // 多余
add_timer(&mtimer); // 错误:expires=0 会立即触发
mod_timer(&mtimer, jiffies + msecs_to_jiffies(100));
return 0;
}
| 问题 | 说明 | 修正 |
|---|---|---|
init_timer 多余 |
DEFINE_TIMER 已初始化 |
删掉 |
add_timer 时机错误 |
expires=0 会立即触发 |
删掉,只用 mod_timer |
del_timer 不安全 |
不等待回调结束 | 改 del_timer_sync |
| 回调签名过时 | unsigned long data 是老接口 |
用 struct timer_list * |
4.4 修正版
适配 Linux 4.15 之后以及 5.x/6.x 内核的写法如下:
#include <linux/module.h>
#include <linux/init.h>
#include <linux/timer.h>
#include <linux/jiffies.h>
static struct timer_list mtimer;
static void timeout(struct timer_list *t)
{
pr_info("timeout ok!\n");
}
static int __init kernel_timer_init(void)
{
timer_setup(&mtimer, timeout, 0);
mod_timer(&mtimer, jiffies + msecs_to_jiffies(100));
pr_info("add timer success\n");
return 0;
}
static void __exit kernel_timer_exit(void)
{
del_timer_sync(&mtimer);
pr_info("del timer success\n");
}
module_init(kernel_timer_init);
module_exit(kernel_timer_exit);
MODULE_LICENSE("GPL");
4.5 老接口 vs 新接口
| 老接口 | 新接口 |
|---|---|
init_timer(&t) |
timer_setup(&t, fn, flags) |
t.function = fn |
在 timer_setup 里指定 |
t.data = x |
from_timer() 反推 |
void fn(unsigned long data) |
void fn(struct timer_list *t) |
4.6 周期性定时器
static void timeout(struct timer_list *t)
{
pr_info("timeout ok!\n");
mod_timer(&mtimer, jiffies + msecs_to_jiffies(100));
}
卸载时必须使用
del_timer_sync(),否则回调可能又把自己重新加入定时器队列。
五、static 的两种作用
代码里大量出现 static,初学者容易疑惑:为什么只在本文件可见的函数还能被内核调用?
| 作用 | 修饰对象 | 含义 |
|---|---|---|
| 链接属性 | 全局变量/函数 | 只在本文件可见 |
| 存储期 | 局部变量 | 静态存储,函数退出不销毁 |
**关键在于:static 限制的是名字能否被其他文件引用,不限制函数能否被调用。**内核通过函数指针、宏注册拿到地址后,照样可以调用。
module_init(kernel_timer_init); // 把地址注册给内核
DEFINE_TIMER(mtimer, timeout, 0, 0); // 把 timeout 指针存进结构体
一句话:static 挡的是"名字",不是"指针"。
六、DHT11 温湿度传感器
6.1 基本参数
| 项目 | 参数 |
|---|---|
| 测量范围 | 温度 050°C,湿度 2090%RH |
| 精度 | 温度 ±2°C,湿度 ±5%RH |
| 分辨率 | 整数 |
| 供电 | 3.3V ~ 5.5V |
| 通信 | 单总线 |
| 采样周期 | ≥1 秒 |
6.2 通信原理
DHT11 使用单根数据线进行双向通信,主机通过拉低总线发起通信,DHT11 通过响应脉冲确认,再发送 40 位数据。
主机:拉低 >18ms → 释放 → 等待响应
DHT :拉低 80us → 拉高 80us → 传 40 位数据
每位:50us 低 + 高电平长短决定 0/1
高 26~28us → 0
高 70us → 1
6.3 40 位数据格式
[湿度整数 8位][湿度小数 8位][温度整数 8位][温度小数 8位][校验和 8位]
校验和等于前 4 个字节相加后的低 8 位。
6.4 硬件连接
DHT11 MCU
VCC ────── 3.3V/5V
DATA ────── GPIO(需 4.7k~10k 上拉电阻到 VCC)
GND ────── GND
上拉电阻必须加。模块版通常自带,裸传感器需要自己加。
6.5 核心读取代码
uint8_t dht11_read(uint8_t *temp, uint8_t *humi)
{
uint8_t buf[5] = {0};
// 1. 主机发送起始信号
pin_output();
write_pin(0);
delay_us(18000); // 拉低 >18ms
write_pin(1);
delay_us(30);
// 2. 切换输入,等待响应
pin_input();
if (wait_level(0, 100)) return 1;
if (wait_level(1, 100)) return 2;
if (wait_level(0, 100)) return 3;
// 3. 读 40 位
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 8; j++) {
if (wait_level(1, 100)) return 4;
delay_us(40); // 延时采样
buf[i] <<= 1;
if (read_pin()) {
buf[i] |= 1;
wait_level(0, 100);
}
}
}
// 4. 校验
if ((uint8_t)(buf[0]+buf[1]+buf[2]+buf[3]) != buf[4]) return 5;
*humi = buf[0];
*temp = buf[2];
return 0;
}
6.6 常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 一直读失败 | 没加上拉电阻 | DATA 接 4.7k~10k 到 VCC |
| 偶发失败 | 采样太快 | 间隔 ≥1s |
| 数据全 0 | 时序不准 | 检查 delay_us |
| 校验错 | 干扰/线太长 | 缩短线、加滤波电容 |
| 第一次失败 | 上电不稳定 | 上电后延时 1s 再读 |
6.7 注意事项
- 必须加外部上拉电阻。
- 两次读取间隔至少 1 秒。
- 读期间对时序敏感,裸机实现时通常要关中断或保证延时准确。
delay_us必须准确,建议使用 SysTick、硬件定时器或内核提供的精确延时函数。- DHT11 只有整数输出,需要小数精度可选 DHT22。
七、总结
| 主题 | 核心要点 |
|---|---|
| 中断处理函数 | 短、不阻塞、清标志、下半部 |
| 进程 vs 线程 | 资源隔离 vs 执行共享 |
| 文件描述符 | 进程访问内核资源的整数句柄 |
| 内核定时器 | tick + 软件管理;回调在软中断上下文 |
| static | 限制名字可见性,不限制调用 |
| DHT11 | 单总线,靠高低电平时长编码 |
这些内容贯穿了从操作系统内核机制到裸机驱动开发的完整链路。理解中断、定时器、fd 这些抽象,再回头看 DHT11 这种时序敏感的传感器驱动,会发现底层逻辑是相通的:中断负责响应,定时器负责调度,fd 负责抽象资源,而驱动负责在时序约束下完成数据交互。
参考资料
- Linux 内核文档:
Documentation/timers/ - DHT11 数据手册
- 《Linux 设备驱动程序》
如果这篇文章对你有帮助,欢迎点赞、收藏、转发。有问题可以在评论区交流,后续也可以继续整理 Linux 驱动、设备树、GPIO 子系统、定时器与中断相关的实战笔记。