IMX6ULL按键驱动开发:Platform总线+中断顶底半部+阻塞式读取
在嵌入式Linux驱动开发中,按键驱动是基础且典型的外设驱动场景,核心需要解决中断触发 、耗时任务解耦 、用户层无轮询读取 三个问题。本次基于IMX6ULL开发板,结合Linux内核的GPIO子系统、中断子系统、Platform总线框架,分别实现tasklet 和workqueue 两种中断底半部的按键驱动,并采用阻塞式读取实现用户层的高效按键状态获取,同时理解中断顶/底半部、进程/中断上下文的核心差异。
一、核心知识点回顾
在解析代码前,先回顾本次驱动开发用到的Linux内核核心子系统和概念,也是嵌入式驱动开发的高频考点:
1. 中断顶/底半部
Linux中断处理采用顶半部(上半部)+底半部 的拆分设计,核心原则是顶半部快进快出,底半部处理非紧急耗时任务:
- 顶半部 :即中断服务程序(ISR),运行在中断上下文 ,处理最紧急的工作(如标记中断、调度底半部),不能阻塞、不能休眠、不能被打断;
- 底半部 :运行在软中断/tasklet/workqueue ,处理非紧急、可能耗时的工作,tasklet/软中断仍在中断上下文(不可阻塞),workqueue运行在进程上下文(可阻塞、可休眠)。
2. 关键内核子系统函数
| 子系统 | 核心函数 | 功能说明 |
|---|---|---|
| GPIO子系统 | of_get_named_gpio/gpio_free | 从设备树获取GPIO/释放GPIO |
| 中断子系统 | request_irq/free_irq/disable_irq | 申请/释放/关闭中断 |
| 中断底半部 | tasklet_init/tasklet_schedule | 初始化/调度tasklet |
| INIT_WORK/schedule_work | 初始化/调度workqueue | |
| 阻塞实现 | init_waitqueue_head | 初始化等待队列头 |
| wait_event_interruptible/wake_up_interruptible | 阻塞等待/唤醒队列 | |
| Platform总线 | of_device_id/ platform_driver_register | 设备树匹配/注册平台驱动 |
| 杂项设备 | misc_register/misc_deregister | 注册/注销杂项设备(主设备号10) |
3. 阻塞式读取
通过等待队列 实现,当无按键中断时,用户层的read操作会被阻塞,直到按键触发中断、底半部唤醒等待队列后,read才会返回按键状态,避免用户层轮询占用CPU资源,是嵌入式驱动中优化用户层调用的常用方式。
4. 杂项设备(miscdevice)
属于简化的字符设备,主设备号固定为10 ,无需手动申请设备号,通过MISC_DYNAMIC_MINOR动态分配次设备号,大幅简化字符设备的注册流程,适合按键、LED等简单外设。
二、驱动整体架构
本次提供的两段按键驱动代码,核心架构完全一致 ,仅中断底半部的实现分别采用tasklet和workqueue,整体架构如下:
Platform驱动注册 → 设备树匹配成功 → probe函数执行 → 注册misc设备 → 解析设备树GPIO/中断 → 申请中断(顶半部)→ 初始化等待队列/底半部
→ 按键触发中断 → 顶半部调度底半部 → 底半部修改唤醒条件 → 唤醒等待队列 → 用户层read解除阻塞并获取按键状态
两段代码的共性模块:
- 头文件引入:包含内核驱动、Platform、GPIO、中断、等待队列等核心头文件;
- 全局变量定义:GPIO号、中断号、等待队列头、唤醒条件(condition);
- 字符设备操作集:
file_operations实现open/read/close,write暂未实现; - 杂项设备定义:指定设备名、操作集,动态分配次设备号;
- Platform驱动:定义
probe/remove函数、设备树匹配表(compatible="pt-key"); - 模块入口/出口:注册/注销Platform驱动,遵循GPL协议。
以下先解析共性核心代码 ,再单独分析tasklet和workqueue的差异实现。
三、共性核心代码解析
1. 全局变量与设备定义
c
#define DEV_NAME "key"
static int key_gpio; // 按键GPIO号
static int key_irq; // 按键中断号
static wait_queue_head_t wq;// 等待队列头
static int condition; // 阻塞唤醒条件(0:阻塞,1:唤醒)
static struct miscdevice misc_dev = { // 杂项设备定义
.minor = MISC_DYNAMIC_MINOR, // 动态分配次设备号
.name = DEV_NAME, // 设备名,/dev/key
.fops = &fops // 字符设备操作集
};
// 设备树匹配表,与设备树的compatible匹配
static struct of_device_id key_table[] = {
{.compatible = "pt-key"},
{} // 空元素结束,必须加
};
// Platform驱动定义
static struct platform_driver pdrv = {
.probe = probe,
.remove = remove,
.driver = {
.name = DEV_NAME,
.of_match_table = key_table // 设备树匹配
}
};
关键说明 :condition是阻塞唤醒的核心标志,用户层read时会将其置0并阻塞,底半部处理时置1并唤醒等待队列。
2. 字符设备操作集
实现open/read/close,核心是read的阻塞式读取:
c
static struct file_operations fops = {
.owner = THIS_MODULE, // 归属本模块,防止模块卸载时被引用
.open = open,
.read = read,
.write = write,
.release = close // close对应release
};
// open函数:仅打印日志,无实际逻辑
static int open(struct inode * inode, struct file * file) {
printk("key open\n");
return 0;
}
// read函数:阻塞式读取核心
static ssize_t read(struct file * file, char __user * buf, size_t size, loff_t * loff) {
int ret = 0;
int status = 0;
printk("key read\n");
condition = 0; // 置0,准备阻塞
// 阻塞等待:当condition为1时解除阻塞,支持信号中断
wait_event_interruptible(wq, condition);
status = 1; // 按键触发,状态置1
// 将按键状态拷贝到用户层,__user表示用户层地址
ret = copy_to_user(buf, &status, sizeof(status));
return sizeof(status); // 返回读取的字节数
}
// close函数:仅打印日志
static int close(struct inode * inode, struct file * file) {
printk("key close\n");
return 0;
}
关键说明:
wait_event_interruptible(wq, condition):核心阻塞函数,第一个参数是等待队列头,第二个参数是唤醒条件,仅当condition为真时解除阻塞,且支持被信号(如Ctrl+C)中断;copy_to_user:内核空间向用户空间拷贝数据,是驱动与用户层交互的标准函数,禁止直接访问用户层地址。
3. probe函数(Platform驱动核心)
当Platform总线匹配设备树成功后,自动执行probe函数,完成驱动初始化的所有核心工作,是本次驱动的核心函数,流程如下:
注册misc设备 → 查找设备树节点 → 获取GPIO → 解析中断号 → 申请中断 → 初始化等待队列 → 初始化底半部
代码解析:
c
static int arg = 100; // 中断传递的参数,用于中断顶半部校验
static int probe(struct platform_device * pdev) {
struct device_node * pdts; // 设备树节点指针
int ret = misc_register(&misc_dev); // 注册杂项设备
if(ret) goto err_misc_register; // 错误处理,跳转到对应标签
// 1. 查找设备树节点:路径为/ptkey
pdts = of_find_node_by_path("/ptkey");
if(NULL == pdts) {
ret = PTR_ERR(pdts);
goto err_of_find;
}
// 2. 从设备树获取GPIO:属性名ptkey-gpio,索引0
key_gpio = of_get_named_gpio(pdts, "ptkey-gpio", 0);
if(key_gpio < 0) {
ret = key_gpio;
goto err_of_find;
}
// 3. 从设备树解析中断号并映射
key_irq = irq_of_parse_and_map(pdts, 0);
if(key_irq < 0) {
ret = key_irq;
goto err_irq_map;
}
// 4. 申请中断:中断号、顶半部处理函数、下降沿触发、中断名、传递参数
ret = request_irq(key_irq, key_irq_handler, IRQF_TRIGGER_FALLING, "key0_irq", &arg);
if(ret < 0) goto err_request_irq;
// 5. 初始化等待队列头
init_waitqueue_head(&wq);
// 6. 初始化底半部(tasklet/workqueue,两段代码差异点)
// ... 底半部初始化代码
printk("######################### key_driver probe\n");
return 0;
// 错误处理:从后往前释放资源,核心原则
err_request_irq:
printk("######################### request irq\n");
free_irq(key_irq, &arg);
err_irq_map:
printk("######################### key_driver irq map\n");
err_of_find:
printk("######################### key_driver find node failed\n");
err_misc_register:
misc_deregister(&misc_dev);
printk("######################### key_driver misc register ret = %d\n", ret);
return ret;
}
关键说明:
- 错误处理采用标签跳转,从后往前释放已申请的资源,避免内存泄漏/资源占用;
irq_of_parse_and_map:从设备树解析中断号并完成中断映射,是设备树时代的标准用法;request_irq参数:IRQF_TRIGGER_FALLING表示下降沿触发中断 (按键按下时电平从高到低,触发中断),最后一个参数&arg是传递给中断顶半部的参数;- 设备树节点路径为
/ptkey,需在开发板设备树中手动添加该节点。
4. 中断顶半部处理函数
c
static irqreturn_t key_irq_handler(int irq, void * dev) {
int arg = *(int *)dev;
// 参数校验,仅处理参数为100的中断
if(100 != arg)
return IRQ_NONE;
// 调度底半部(tasklet/workqueue,差异点)
// ... 底半部调度代码
printk("irq = %d dev = %d\n", irq, arg);
return IRQ_HANDLED; // 表示中断已正确处理
}
关键说明:
- 中断顶半部严格遵循快进快出 原则,仅做参数校验 和底半部调度,无其他耗时操作;
- 返回值:
IRQ_NONE表示不是本驱动的中断,IRQ_HANDLED表示正确处理中断; - 运行在中断上下文,禁止调用任何可能阻塞/休眠的函数。
5. remove函数与模块入口/出口
(1)remove函数
当Platform驱动卸载时执行,释放所有已申请的资源:
c
static int remove(struct platform_device * pdev) {
disable_irq(key_irq); // 关闭中断
free_irq(key_irq, &arg); // 释放中断
gpio_free(key_gpio); // 释放GPIO
misc_deregister(&misc_dev); // 注销杂项设备
printk("######################### key_driver remove\n");
return 0;
}
(2)模块入口/出口
遵循Linux内核模块的标准写法,注册/注销Platform驱动:
c
static int __init key_driver_init(void) {
int ret = platform_driver_register(&pdrv);
if(ret)
goto err_platform_driver_register;
printk("key platform_driver_register success\n");
return 0;
err_platform_driver_register:
printk("key platform_driver_register failed\n");
return ret;
}
static void __exit key_driver_exit(void) {
platform_driver_unregister(&pdrv);
printk("key platofrm_driver_unregister\n");
}
// 模块入口出口宏
module_init(key_driver_init);
module_exit(key_driver_exit);
MODULE_LICENSE("GPL"); // 必须声明GPL协议,否则模块加载失败
四、两种底半部实现差异解析
1. Tasklet版底半部实现
Tasklet是基于软中断实现的底半部机制 ,运行在中断上下文 ,不可阻塞、不可休眠 ,适合处理短时间、非阻塞的底半部任务,是中断底半部的常用选择。
(1)全局变量与底半部初始化
c
// 定义tasklet结构体
static struct tasklet_struct tsk;
// probe函数中初始化tasklet:参数为tasklet结构体、处理函数、传递的参数
tasklet_init(&tsk, key_tasklet_handler, 100);
(2)tasklet处理函数
c
static void key_tasklet_handler(unsigned long arg) {
condition = 1; // 修改唤醒条件为1
wake_up_interruptible(&wq); // 唤醒等待队列,解除用户层read阻塞
printk("key_tasklet_handler arg = %ld\n", arg);
}
(3)中断顶半部调度tasklet
c
// 在key_irq_handler中添加:调度tasklet
tasklet_schedule(&tsk);
关键说明:
tasklet_schedule:将tasklet加入内核的软中断调度队列,内核会在合适的时机执行其处理函数;- tasklet处理函数仍运行在中断上下文 ,禁止调用ssleep、kmalloc(GFP_KERNEL) 等可能阻塞的函数;
- 支持传递参数(unsigned long类型),灵活性高。
2. Workqueue版底半部实现
Workqueue是基于内核线程的底半部机制 ,运行在进程上下文 ,可阻塞、可休眠 ,适合处理耗时、需要休眠的底半部任务,是唯一支持休眠的底半部机制。
(1)全局变量与底半部初始化
c
// 定义work结构体
static struct work_struct work;
// probe函数中初始化work:参数为work结构体、处理函数
INIT_WORK(&work, key_work_func);
(2)work处理函数
c
static void key_work_func(struct work_struct *work) {
ssleep(1); // 休眠1秒,体现workqueue可阻塞的特性
condition = 1; // 修改唤醒条件为1
wake_up_interruptible(&wq); // 唤醒等待队列
printk("key_work_func ..\n");
}
(3)中断顶半部调度workqueue
c
// 在key_irq_handler中添加:调度workqueue
schedule_work(&work);
关键说明:
schedule_work:将work加入内核的工作队列,内核线程会执行其处理函数;- work处理函数运行在进程上下文 ,支持休眠、阻塞 ,代码中加入
ssleep(1)验证了这一特性,而tasklet中禁止使用该函数; - 是处理耗时中断底半部任务的唯一选择,如需要访问硬件、延时处理的场景。
3. Tasklet与Workqueue核心差异
| 特性 | Tasklet | Workqueue |
|---|---|---|
| 运行上下文 | 中断上下文 | 进程上下文 |
| 阻塞/休眠 | 禁止 | 支持 |
| 实现基础 | 基于软中断 | 基于内核线程 |
| 调度方式 | tasklet_schedule | schedule_work |
| 适用场景 | 短时间、非阻塞的底半部任务 | 耗时、需要休眠的底半部任务 |
| 中断屏蔽 | 屏蔽当前中断线 | 不屏蔽任何中断 |
五、设备树配置
本次驱动采用设备树描述硬件资源 ,需在IMX6ULL的设备树文件(如imx6ull-alientek-emmc.dts)中添加/ptkey节点,指定按键的GPIO、中断、兼容属性,代码如下:
dts
ptkey {
compatible = "pt-key"; // 与驱动的of_device_id匹配,必须一致
ptkey-gpio = <&gpio1 18 GPIO_ACTIVE_LOW>; // 按键GPIO,根据实际硬件修改
interrupt-parent = <&gpio1>; // 中断父节点为GPIO1
interrupts = <18 IRQ_TYPE_EDGE_FALLING>; // 18号GPIO,下降沿触发
status = "okay"; // 启用该节点
};
配置说明:
compatible:必须与驱动中的of_device_id一致(pt-key),是Platform总线匹配的核心;ptkey-gpio:根据实际硬件的按键GPIO引脚修改,格式为<&GPIO组 GPIO号 电平属性>;interrupts:中断配置,与request_irq的IRQF_TRIGGER_FALLING对应,为下降沿触发;- 设备树修改后,需重新编译设备树并烧录到开发板。
六、驱动编译与测试
1. 驱动编译
编写Makefile,指定交叉编译工具链和Linux内核源码路径(IMX6ULL适配的4.1.15内核),Makefile代码如下:
makefile
# 针对tasklet版,obj-m += key_tasklet.o
# 针对workqueue版,obj-m += key_workqueue.o
obj-m += key_tasklet.o
# 内核源码路径,根据自己的开发环境修改
KERNELDIR := /home/linux/imx6ull/linux-imx-rel_imx_4.1.15_2.1.0_ga
# 当前目录
PWD := $(shell pwd)
# 交叉编译工具链,IMX6ULL为arm-linux-gnueabihf-
CROSS_COMPILE := arm-linux-gnueabihf-
ARCH := arm
all:
make -C $(KERNELDIR) M=$(PWD) modules ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE)
clean:
make -C $(KERNELDIR) M=$(PWD) clean
编译命令:
bash
make -j4 # -j4根据CPU核心数调整,加速编译
编译成功后,生成key_tasklet.ko或key_workqueue.ko驱动模块。
2. 用户层测试代码
编写C语言测试程序,实现打开设备→阻塞式读取按键状态→打印结果 ,代码如下(key_test.c):
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
int fd;
int status = 0;
// 打开杂项设备,与驱动的DEV_NAME一致
fd = open("/dev/key", O_RDWR);
if(fd < 0) {
perror("open fail");
return -1;
}
printf("open /dev/key success\n");
while(1) {
// 阻塞式读取按键状态
read(fd, &status, sizeof(status));
if(status == 1) {
printf("key press !!!\n");
status = 0;
}
}
close(fd);
return 0;
}
交叉编译测试程序:
bash
arm-linux-gnueabihf-gcc key_test.c -o key_test
将编译后的key_test拷贝到开发板根文件系统。
3. 开发板测试步骤
-
加载驱动模块 :将
ko文件拷贝到开发板,执行加载命令:bashinsmod key_tasklet.ko # 加载tasklet版 # 或insmod key_workqueue.ko # 加载workqueue版 lsmod # 查看已加载模块 ls /dev/key # 查看杂项设备是否创建成功 -
查看内核打印 :通过
dmesg查看驱动初始化日志,确认probe函数执行成功:bashdmesg | grep key -
运行测试程序 :后台运行测试程序,按下按键触发中断:
bash./key_test &按下开发板的按键,终端会打印
key press !!!,表示驱动工作正常。 -
卸载驱动模块 :测试完成后,执行卸载命令:
bashrmmod key_tasklet # 无需加.ko后缀
七、开发总结与注意事项
1. 开发总结
本次按键驱动是嵌入式Linux驱动开发的经典案例,涵盖了多个核心知识点,重点掌握:
- Platform总线 :实现硬件与驱动分离,通过设备树描述硬件,驱动无需修改代码即可适配不同硬件,是现代Linux驱动的标准设计;
- 中断顶底半部 :拆分中断处理任务,顶半部快进快出,底半部处理非紧急任务,根据场景选择
tasklet(短时间)或workqueue(耗时/休眠); - 阻塞式读取:通过等待队列实现,避免用户层轮询,优化CPU资源占用,是嵌入式驱动的必备优化手段;
- 杂项设备:简化字符设备开发,适合简单外设,无需手动申请设备号;
- 资源管理 :驱动开发的核心原则,申请的资源必须释放,错误处理需从后往前释放资源,避免内存泄漏。
2. 关键注意事项
- 中断上下文禁止阻塞 :tasklet、软中断、中断顶半部均运行在中断上下文,禁止调用
ssleep、kmalloc(GFP_KERNEL)、copy_from_user等可能阻塞的函数; - 设备树匹配一致性 :
compatible属性必须驱动与设备树完全一致,否则Platform总线无法匹配,probe函数不会执行; - GPL协议声明 :内核模块必须添加
MODULE_LICENSE("GPL"),否则模块加载失败; - 用户层与内核层数据交互 :必须使用
copy_to_user/copy_from_user,禁止直接访问用户层地址,避免内存访问错误; - 中断触发方式 :根据硬件电路选择
IRQF_TRIGGER_FALLING(下降沿)、IRQF_TRIGGER_RISING(上升沿)或IRQF_TRIGGER_BOTH(双边沿)。
八、拓展延伸
本次驱动仅实现了按键的按下触发,可在此基础上进行拓展:
- 实现按键消抖:在workqueue中添加延时消抖,避免按键机械抖动导致的多次中断;
- 实现按键抬起触发:修改中断触发方式为双边沿,区分按键按下和抬起;
- 实现多个按键驱动:修改设备树和驱动,支持多个按键的中断处理;
- 实现ioctl接口:在驱动中添加ioctl函数,支持用户层配置按键的中断触发方式。