一、为什么需要中断底半部?
1.1 ISR 必须"快进快出"
中断服务程序(ISR)执行期间,CPU 不能响应同优先级或更低优先级的其他中断。如果 ISR 执行时间过长,会导致系统失去实时性。
举例:串口以 9600 波特率通信:
每秒传输字节数 = 9600 / 10 = 960 字节/s
接收 100 字节需要约 104ms
104ms 内 CPU 本可完成 5~6 次进程切换
若 ISR 阻塞 104ms → 键盘、定时器等中断全部无法响应 → 系统卡死
结论 :任何耗时操作(数据拷贝、协议解析、文件写入)都不能放在 ISR 里。
1.2 顶半部 + 底半部 分离模型
硬件中断触发
↓
顶半部(Top Half)------ ISR,只做最小必要操作
① 读寄存器、清中断标志
② 记录数据到缓冲区
③ 调度底半部任务
④ 立即返回(微秒级)
↓ (异步,不等待)
底半部(Bottom Half)------ 延后异步执行
① 数据解析、协议处理
② 文件 I/O、网络操作
③ 内存分配、复杂计算
(毫秒级,安全可阻塞)
核心思想 :顶半部保障中断响应速度,底半部保障任务完整性,二者解耦。
二、中断上下文 vs 进程上下文
这是理解底半部机制的基础,必须先弄清楚。
| 对比项 | 中断上下文 | 进程上下文 |
|---|---|---|
| 包含内容 | 硬中断 ISR、软中断、tasklet | 系统调用、内核线程、workqueue |
| 执行主体 | CPU 直接调用,无进程描述符 | 内核线程,受调度器管理 |
| 能否休眠 | 不能(致命错误) | 可以 |
| 能否阻塞 | 不能 | 可以 |
| 可用的锁 | 只能用自旋锁 | 自旋锁、互斥锁均可 |
为什么中断上下文不能休眠?
休眠本质是调用
schedule()让出 CPU。中断上下文没有进程描述符,内核调度器根本找不到它,无法将它挂起再恢复,系统会直接崩溃(内核 panic)。
类比:
- 中断上下文 = 消防员救火,必须一直冲,不能停下来休息
- 进程上下文 = 维修队,可以排队等待、休息、慢慢干
三、底半部三大机制
3.1 对比总览
| 机制 | 执行上下文 | 能否休眠 | 适用场景 |
|---|---|---|---|
| softirq | 中断上下文 | 不能 | 内核核心模块(网络/定时器),不推荐开发者直接使用 |
| tasklet | 中断上下文 | 不能 | 高频、轻量、不需要睡眠的任务 |
| workqueue | 进程上下文 | 可以 | 复杂、耗时、需要休眠/阻塞的任务 |
注意:"软中断"(softirq)≠ 系统调用时的 SWI 软中断。前者是内核内部机制,后者是用户态进入内核的陷阱指令,完全不同的概念。
3.2 Tasklet
原理
Tasklet 基于 softirq 封装,在中断返回后由内核在软中断上下文中执行。
c
#include <linux/interrupt.h>
/* ① 定义 tasklet 结构体 */
struct tasklet_struct my_tasklet;
/* ② 编写处理函数(在中断上下文执行,不能休眠!) */
void my_tasklet_func(unsigned long data)
{
// data 就是 tasklet_init 传入的第三个参数
printk("Bottom Half (tasklet): data=%lu\n", data);
// ❌ 绝对不能在这里 msleep() / mutex_lock() / kmalloc(GFP_KERNEL)
}
/* ③ 在 probe 里初始化 */
tasklet_init(&my_tasklet, my_tasklet_func, 123);
/* ④ 在顶半部 ISR 里调度(不是"调用",是"调度") */
static irqreturn_t key_irq_handler(int irq, void *dev)
{
printk("Top Half\n");
tasklet_schedule(&my_tasklet); // 调度,立即返回,不等待
return IRQ_HANDLED;
}
/* ⑤ 在 remove 里释放 */
tasklet_kill(&my_tasklet);
运行时序
按键按下 → 硬件中断 → ISR 执行:
printk("Top Half")
tasklet_schedule() ← 把 tasklet 加入软中断队列
return IRQ_HANDLED ← 顶半部立即返回
数毫秒后 → 软中断上下文执行:
my_tasklet_func()
printk("Bottom Half")
崩溃演示(绝对不要这样做)
c
void my_tasklet_func(unsigned long data)
{
msleep(1000); // ← 在中断上下文调用 sleep → 系统立即崩溃!
// 现象:dmesg 疯狂刷屏,内核 panic
}
3.3 Workqueue
原理
Workqueue 基于内核线程(kworker),在进程上下文中执行,因此可以安全地休眠、阻塞。
c
#include <linux/workqueue.h>
/* ① 定义 work 结构体 */
struct work_struct my_work;
/* ② 编写处理函数(进程上下文,可以休眠) */
void my_work_func(struct work_struct *work)
{
msleep(1000); // ✅ 完全合法
printk("Bottom Half (workqueue)\n"); // 1秒后才打印
}
/* ③ 初始化 */
INIT_WORK(&my_work, my_work_func);
/* ④ 在顶半部调度 */
static irqreturn_t key_irq_handler(int irq, void *dev)
{
printk("Top Half\n");
schedule_work(&my_work); // 调度,立即返回
return IRQ_HANDLED;
}
/* ⑤ 在 remove 里取消待执行的 work */
cancel_work_sync(&my_work);
选型建议
需要 msleep / mutex / kmalloc(GFP_KERNEL) / 文件 I/O?
→ 必须用 workqueue
轻量、高频、不需要任何阻塞?
→ 可以用 tasklet(性能更好)
四、原子操作与锁
4.1 为什么需要同步?
普通变量 int i = 0; i++; 在汇编层面被拆成三步:
读 → 加 → 写
↑ 如果在这里被中断或被另一个 CPU 打断 → 数据竞争 → 结果错误
4.2 原子操作(atomic_t)
适合单个整型变量的简单读写,比加锁开销更低。
c
#include <linux/atomic.h>
atomic_t count;
atomic_set(&count, 0); // 初始化为 0
atomic_inc(&count); // 原子自增
atomic_dec(&count); // 原子自减
atomic_add(5, &count); // 原子加 5
int val = atomic_read(&count); // 原子读
/* 原子加并返回新值 */
int new = atomic_add_return(1, &count);
原理 :底层由 ARM 的 LDREX/STREX 硬件指令保证"读-改-写"不可分割。
使用场景:中断处理程序、软中断、多核共享计数器。
4.3 自旋锁(spinlock)
忙等待,获取不到锁就一直循环检测(不睡眠)。
c
#include <linux/spinlock.h>
spinlock_t my_lock;
spin_lock_init(&my_lock);
/* 普通加锁/解锁(不关中断) */
spin_lock(&my_lock);
// 临界区(时间要极短,<100μs)
spin_unlock(&my_lock);
/* 中断安全版本(关闭本地中断 + 加锁) */
unsigned long flags;
spin_lock_irqsave(&my_lock, flags); // 保存中断状态 + 关中断 + 加锁
// 临界区
spin_unlock_irqrestore(&my_lock, flags); // 解锁 + 恢复中断状态
为什么中断里只能用自旋锁?
自旋锁不会导致睡眠,只是 CPU 忙等。互斥锁获取失败会调用
schedule()让出 CPU,在中断上下文这是致命操作。
irqsave 和普通 spin_lock 的区别 :如果中断处理程序和进程上下文都访问同一变量,必须用irqsave版本。否则进程持有锁时被中断打断,中断里也来申请同一个锁,会产生死锁。
4.4 互斥锁(mutex)
阻塞等待,获取不到锁就让进程睡眠,等待被唤醒。
c
#include <linux/mutex.h>
struct mutex my_mutex;
mutex_init(&my_mutex);
mutex_lock(&my_mutex); // 获取不到则睡眠等待
// 临界区(可以很长,可以阻塞)
mutex_unlock(&my_mutex);
/* 非阻塞版本,获取不到立即返回 */
if (mutex_trylock(&my_mutex)) {
// 获取成功
mutex_unlock(&my_mutex);
}
4.5 锁的选型总结
| 场景 | 推荐 |
|---|---|
| 中断上下文(ISR / tasklet / softirq) | 只能用自旋锁 |
| 进程上下文,临界区极短 | 自旋锁 |
| 进程上下文,临界区可能阻塞 | 互斥锁 |
| 多读少写 | 读写锁(rwlock) |
| 单个整型计数器 | 原子操作(最轻量) |
五、ioctl 接口
5.1 为什么需要 ioctl?
read/write 用于数据流,ioctl 用于命令控制,二者语义不同。
write("ledon")→ 字符串匹配方式,效率低,容易出错ioctl(fd, CMD_LED_ON, 0)→ 整数命令,内核直接 switch,高效清晰
5.2 ioctl CMD 的结构(32位)
31 30 29 16 15 8 7 0
┌────────┬──────────┬───────────┬──────────┐
│direction│ size │ type │ nr │
│ 2 bit │ 14 bit │ 8 bit │ 8 bit │
└────────┴──────────┴───────────┴──────────┘
| 字段 | 含义 |
|---|---|
type |
设备类型标识,用 ASCII 字符(如 'x'、'L'、'D') |
nr |
命令编号,从 0 开始 |
size |
传递数据的字节数 |
direction |
数据方向 |
四种构造宏:
c
_IO(type, nr) // 无数据传递(纯命令,如 LED_ON)
_IOR(type, nr, datatype) // 从驱动读数据到用户空间(如读温度)
_IOW(type, nr, datatype) // 从用户空间写数据到驱动
_IOWR(type, nr, datatype) // 双向
5.3 led_ioctl.c 代码解析
驱动侧定义命令
c
#include <asm/ioctl.h>
#define MAGIC_NUM 'x' // 魔数:选一个不常用的 ASCII 字符
#define LED_ON 0 // 命令编号
#define LED_OFF 1
#define LED_REV 2
// _IO:无数据传递,只发命令
#define CMD_LED_ON _IO(MAGIC_NUM, LED_ON)
#define CMD_LED_OFF _IO(MAGIC_NUM, LED_OFF)
#define CMD_LED_REV _IO(MAGIC_NUM, LED_REV)
驱动侧 ioctl 函数
c
// 注意:挂到 file_operations 的字段名是 unlocked_ioctl,不是 ioctl
static long ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
int ret = 0;
switch (cmd) {
case CMD_LED_ON:
led_on();
break;
case CMD_LED_OFF:
led_off();
break;
default:
ret = -EINVAL; // 未知命令返回 -EINVAL(无效参数)
break;
}
return ret;
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = open,
.read = read,
.write = write,
.unlocked_ioctl = ioctl, // ← 注意是 unlocked_ioctl
.release = close,
};
应用侧调用
c
#include <sys/ioctl.h>
#include <fcntl.h>
// 应用层必须有相同的宏定义
#define MAGIC_NUM 'x'
#define CMD_LED_ON _IO(MAGIC_NUM, 0)
#define CMD_LED_OFF _IO(MAGIC_NUM, 1)
int main(void)
{
int fd = open("/dev/led", O_RDWR);
if (fd < 0) { perror("open"); return -1; }
ioctl(fd, CMD_LED_ON, 0); // 开灯
sleep(1);
ioctl(fd, CMD_LED_OFF, 0); // 关灯
close(fd);
return 0;
}
5.4 读取传感器数据时用 _IOR
c
/* 驱动侧(如 dht11.c)*/
#define MAGIC_NUM 'd'
#define CMD_READ_HUMI _IOR(MAGIC_NUM, 0, unsigned char) // 驱动→用户
#define CMD_READ_TEMP _IOR(MAGIC_NUM, 1, unsigned char)
static long dht11_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
unsigned char val;
switch (cmd) {
case CMD_READ_TEMP:
val = get_temperature();
if (copy_to_user((void __user *)arg, &val, 1))
return -EFAULT;
break;
}
return 0;
}
/* 应用侧 */
unsigned char temp;
ioctl(fd, CMD_READ_TEMP, &temp); // arg 传指针
printf("温度: %d°C\n", temp);
5.5 常见错误
| 错误现象 | 原因 | 解决 |
|---|---|---|
返回 -EINVAL |
CMD 未定义或 magic 冲突 | 检查魔数是否与系统保留冲突 |
返回 -ENOTTY |
该设备不支持此 ioctl | 确认驱动注册了 unlocked_ioctl |
Permission denied |
设备节点权限不足 | chmod 666 /dev/led |
| 驱动没有反应 | 使用了 .ioctl 而非 .unlocked_ioctl |
改为 unlocked_ioctl |
系统保留的 magic 查询 :
cat /usr/include/asm-generic/ioctl.h或内核文档Documentation/userspace-api/ioctl/ioctl-number.rst
六、内核镜像补充知识
| 类型 | 说明 |
|---|---|
Image |
未压缩的内核二进制(5~10MB) |
zImage |
gzip 压缩后的镜像(2~4MB),实际烧写格式 |
bash
make zImage # 生成压缩镜像(推荐,节省空间和传输时间)
make Image # 生成原始镜像(仅调试)
make dtbs # 编译所有设备树
make pt.dtb # 只编译指定的设备树
启动流程:Bootloader 加载 zImage → 解压到内存 → 跳转到 Image 入口 → 内核启动
九、全课知识体系总结
Linux 驱动开发路径:
misc_led.c → 地址写死,最原始
↓
led_device.c → Platform 分离,地址在 led_device.c
+ led_driver.c
↓
led_platform.c → Platform + DTS,地址在 .dts,reg 属性 + ioremap
↓
led_subgpio.c → Platform + DTS + GPIO 子系统,无需 ioremap
↓
led_ioctl.c → 增加 ioctl 接口,命令控制更规范
↓
key_irq.c → 中断,gpio_to_irq,IRQ 基础
key_sub.c → 中断 + 等待队列,read 阻塞直到按键
↓
(本节) → 中断底半部(tasklet / workqueue)
原子操作与锁
DHT11 综合实战
核心开发原则:
- ISR 快进快出,耗时操作交给 workqueue
- 中断上下文只能用自旋锁,不能睡眠
- DTS 的 compatible 必须与驱动 of_match_table 完全一致
- 时序敏感的代码用
local_irq_save保护 - 所有申请的资源(GPIO、IRQ、misc)必须在 remove 里逆序释放