0. 前言
依稀记得当初在刚学单片机时,写按键逻辑都是简单的 if(key == 0) delay(20ms)。但在 Linux 内核中,这种写法不仅会拉低系统性能,更是对 CPU 资源的极大浪费,大量的无效判断会将 CPU 资源都耗费在做无用功上面。
物理按键按下的一瞬间,电平并不是直接变低,而是会在 1 和 0 之间疯狂跳变,这就是按键抖动。如果你的驱动在检测到电平跳变时就直接触发中断,你会发现按一下按键,中断可能会触发几十次甚至更夸张。
今天,我拿我的板子鲁班猫 2 平台为例,带大家做一个符合 Linux 内核标准规范的按键驱动,并用它控制一个小小的 LED。虽然功能简单,但是它涉及到的知识点还是比较复杂的,它包含了 设备树配置 、中断下半部处理 、内核定时器消抖 、以及 字符设备驱动。
最终达到的目标是直接使用驱动程序来控制 LED,并在按键按下或者松开时上报给用户程序。
1. 设备树
很多人写驱动喜欢直接在代码里硬编码 GPIO 编号,这在现代 Linux 内核里相对而言是不专业的做法。标准的做法要要通过设备树来描述硬件。在讲设备树之前,我先将写好的设备树节点放在下面,以便大家对照学习:
这段代码在根节点外面:
ini
&pinctrl{
//建立一个自定义引脚组
my_button_setup{
//定义引脚:bank为3,引脚为7 (RK_PA7),功能为GPIO,电气属性为上拉
my_button_pin:my-button-pin{
rockchip,pins = <3 RK_PA7 RK_FUNC_GPIO &pcfg_pull_up>;
};
};
};
下面这段代码在根节点里面:
ini
my_button{
compatible = "lubancat,mybutton";
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&my_button_pin>;
button-gpios = <&gpio3 RK_PA7 GPIO_ACTIVE_LOW>;//按键控制引脚,低电平代表按键按下
led-gpios = <&gpio3 RK_PA5 GPIO_ACTIVE_HIGH>;//LED控制引脚,高电平有效
interrupt-parent = <&gpio3>;
interrupts = <RK_PA7 IRQ_TYPE_EDGE_FALLING>;//下降沿
};
有基础的可以先尝试分析一下这两段设备树代码,再构思一下如果要实现我们最终的目标,驱动程序中大致逻辑应该怎么写,可以先把自己的思路打到评论区,再继续往后看,和我们最终的实现对照一下。
下面解析一下这两段设备树代码:
1.1 为什么要有pinctrl
在讲为什么要使用 pinctrl 之前,我们先来讲讲它到底有什么用。
我们一般看到的 pinctrl 的标准定义往往是这样的:pinctrl 子系统,顾名思义 Pin Control ,是 Linux 内核中用于统一管理 SoC 引脚的框架,主要处理 引脚复用 和 电气特性配置,比如上拉、下拉、驱动强度、速率、开漏等。它由芯片厂商提供 pinctrl driver,驱动开发者主要通过设备树来使用它。
来看看 pinctrl 的一些比较常见的具体用途:
- 引脚复用: 大多数的 SoC 引脚是 多功能复用 的,例如同一个物理引脚可以是 GPIO,UART TX/RX,I2C SDL/SDA,SPI MOSI/MISO/SCK/CS。 当你的设备需要占用某个引脚时,必须通过 pinctrl 把这些引脚复用到对应的功能上,否则引脚可能是默认功能或冲突状态,导致外设无法正常工作。
- 配置引脚的电气特性: 对于一个引脚,它还需要设置上拉、下拉、驱动强度、转换效率、电压电平、开漏等等。
如上面第一段代码,我定义了 my_button_setup,按键引脚如果不配置 内部上拉 ,在没有按下时它就是悬空的。悬空引脚会因为环境电磁干扰产生随机的高低电平波动,导致驱动频繁触发假中断。使用 &pcfg_pull_up 告诉内核把这个引脚接上内部电阻拉高,这样按键没按下时,它就是稳定的高电平。
这个坑我帮大家踩过了,如果不配置内部上拉,加载模块之后,可能出现中断风暴,严重时有好几次命令行直接卡住,无法输入命令,然后我的 SSH 就直接断开了。
第一个参数 3 代表 bank,我们使用的 gpio3,这里就填 3,RK_FUNC_GPIO 会将引脚复用为普通 GPIO 功能。
再来看第二段代码。
pinctrl-names 是引脚状态名称,这是一个字符串列表,从前到后每个字符串与后面的 pinctrl-0、pinctrl-1、pinctrl-2 等一一对应,最常见的默认值是 "default"。
pinctrl-0 对应 pinctrl-names 中的第一个名字,也就是我们定义的"default",再指向具体的配置节点 &my_button_pin,这样就把引脚名称和具体配置对应起来了。
1.2 逻辑电平和物理电平
看设备树中这一行:
ini
button-gpios = <&gpio3 RK_PA7 GPIO_ACTIVE_LOW>
很多开发者在这里会纠结,我的按键按下是低电平,那代码里 get_value 到底读到的是 0 还是 1?
实际上,如果你设置了 GPIO_ACTIVE_LOW,Linux 的 gpiod 子系统会自动帮你做逻辑转换:
- 按键按下,物理低电平,
get_value读到 1,代表有效。 - 按键抬起,物理高电平,
get_value读到 0,代表无效。
这样写出来的代码逻辑非常直观:1 就代表触发了,0 就代表没触发。
1.3 中断属性绑定
interrupt-parent 指定这个中断的 父中断控制器,GPIO 本身就是一个中断控制器,中断信号来自 gpio3 这个 bank。
interrupts 是中断描述符,后续的 interrupts 属性中的引脚编号,都是相对于 gpio3 这个控制器来解释的,IRQ_TYPE_EDGE_FALLING指定中断触发的类型,这里是下降沿触发,
配合内部上拉,当按键被按下,电平由高变低那一瞬间,中断就会发生。
到这里,我们设备树就讲完了,后面编译好再拷到板子的 /boot/dtb 目录下,之后重启板子就生效了。
一句话总结一下:搞底层驱动,第一步不是写 C 代码,而是调设备树。设备树没有写好,引脚悬空或者逻辑反向,后面写再多代码也是白搭,还有 pinctrl 里的上拉配置,是解决按键触发诡异中断的关键。
2.probe初始化函数
刚写驱动的时候,最头疼的就是 goto 标签。申请了内存要释放,申请了中断要注销,一旦逻辑乱了,就很容易造成资源泄露。
因此,要尽可能的使用 devm_ 系列 API。
2.1 私有结构体定义
在讲 probe 函数之前需要先了解一下我定义的私有结构体:
c
struct my_key_dev{
//字符设备相关
dev_t dev_num;
struct cdev cdev;
struct class* class;
struct device* device;
struct gpio_desc* key_gpio; //gpio描述符
struct gpio_desc* led_gpio;//led的gpio
int irq_num; //中断号
struct timer_list timer; //定时器
wait_queue_head_t wq;//等待队列头
int key_push;//按键产生的标志位
int key_state;//按键状态
};
为了驱动代码的健壮性和可移植性,以及未来可以支持多个设备,搞一大堆全局变量并不是一个明智的做法,我们要养成用一个私有结构体包装资源的习惯。
my_key_dev 结构体中最前面四个是字符设备相关的,以及后面的字符设备的注册都是有固定框架的,就不赘述了。
中间四个成员,前两个 gpio 描述符分别用于 按键 和 LED ,而中断号是由按键的 gpio 描述符转化而来的,timer_list 是我们用来消抖的。
wait_queue_head_t 等待队列头,用于实现进程的阻塞与唤醒,让 CPU 高效运行。
key_push 默认为零,当按键有动作时置位 1。key_state 表示此时按键的状态。
2.2 完整的probe函数
后面要结合代码来具体讲,所以我先把 probe 函数放在这,文末我会附上完整的驱动程序代码,读完之后大家可以看完整代码理一下逻辑。
c
static int my_key_probe(struct platform_device* pdev)
{
struct device* dev = &pdev->dev;
struct my_key_dev *key;
int ret;
key = devm_kzalloc(dev, sizeof(*key), GFP_KERNEL);
if(!key) return -ENOMEM;
//解析设备树
key->key_gpio = devm_gpiod_get(dev, "button", GPIOD_IN);
if(IS_ERR(key->key_gpio))
{
ret = PTR_ERR(key->key_gpio);
printk(KERN_INFO "获取GPIO失败!\n");
return ret;
}
key->led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW);
if(IS_ERR(key->led_gpio))
{
ret = PTR_ERR(key->led_gpio);
printk(KERN_INFO "获取GPIO失败!\n");
return ret;
}
//把GPIO转换成虚拟中断号
key->irq_num = gpiod_to_irq(key->key_gpio);
if(key->irq_num < 0)
{
printk(KERN_INFO "GPIO转中断号失败!\n");
return key->irq_num;
}
//初始化内核定时器
timer_setup(&key->timer, key_timer_callback, 0);
//初始化等待队列
init_waitqueue_head(&key->wq);
key->key_push = 0;
//申请并注册中断
ret = devm_request_irq(dev, key->irq_num, key_isr, IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING, "my_key_irq", key);
if(ret)
{
printk(KERN_INFO "申请中断失败!\n");
return ret;
}
//注册字符设备
ret = alloc_chrdev_region(&key->dev_num, 0, 1, "key_ctrl_led");
if(ret < 0)
{
printk(KERN_INFO "alloc failed!\n");
return ret;
}
cdev_init(&key->cdev, &key_ctrl_led_fops);
ret = cdev_add(&key->cdev, key->dev_num, 1);
if(ret < 0)
{
printk(KERN_INFO "adev add failed!\n");
goto err_add;
}
key->class = class_create(THIS_MODULE, "key_led_class");
if(IS_ERR(key->class))
{
printk(KERN_INFO "class create failed!\n");
ret = PTR_ERR(key->class);
goto err_class;
}
key->device = device_create(key->class, NULL, key->dev_num, NULL, "key_ctrl_led");
if(IS_ERR(key->device))
{
printk(KERN_INFO "device create failed!\n");
ret = PTR_ERR(key->device);
goto err_device;
}
platform_set_drvdata(pdev, key);
printk(KERN_INFO "按键驱动加载成功!\n");
return 0;
err_device:
class_destroy(key->class);
err_class:
cdev_del(&key->cdev);
err_add:
unregister_chrdev_region(key->dev_num, 1);
return ret;
}
2.3 内核的资源自动回收机制
上面的 probe 函数中,我们在申请内存,获取 GPIO 描述符和申请中断时分别采用了devm_kzalloc,devm_gpiod_get,devm_request_irq。
devm_ 前缀是内核提供的 Device Managed(设备管理) 机制。
传统写法中,如果你在 probe 中间出错,或者驱动卸载 remove时,你必须手动写一堆 kfree、gpio_free。漏写一个,就会造成资源泄漏。
而当前的而写法,只要 probe 成功,这些资源就挂载到了 device 结构体下。一旦驱动卸载,内核会自动回收所有资源。
2.4 从 gpio 到 gpiod
请注意 probe 函数中,我们获取按键和 LED 的 GPIO 描述符时的操作:
c
key->key_gpio = devm_gpiod_get(dev, "button", GPIOD_IN);
key->led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW);
这里传入的是 "button" 和 "led",而不是具体的引脚编号。
驱动程序不需要关心这个按键接在 GPIO3 还是 GPIO5,它只管找设备树里叫 button-gpios 和 led-gpios 的那个属性。
gpiod 是基于描述符的,它比旧的 gpio_ 编号更安全,能有效防止多个驱动误操作同一个引脚。
2.5 gpiod_to_irq
probe函数中中断号的获取方式如下:
c
key->irq_num = gpiod_to_irq(key->key_gpio);
在 ARM 架构上,GPIO 编号和中断号并不是线性对应的。以前我们要查硬件手册、算偏置。现在一行代码,内核直接把 GPIO 映射成虚拟中断号给你。
2.6 其他的初始化
在 probe 的结尾,还初始化了三个核心组件:
- Timer(定时器) :准备给按键消抖用。
- Wait Queue(等待队列) :准备给 read 阻塞读取用。
- Cdev(字符设备) :给用户空间提供接口。
写驱动最怕的就是初始化失败之后的擦屁股,以前写 probe ,每申请一个资源都要小心翼翼地写好对应的 free 。用了 devm_ 之后,代码清爽了,逻辑清晰了。建议所有还在死磕旧版内核 API 的,赶紧换 gpiod 和 devm,这才是真正的工业级标准。
3. 中断与定时器的核心逻辑
物理按键按下的一瞬间,电平会像疯了一样在 1 和 0 之间跳变很多次。如果我们每跳一次就触发一次中断,CPU 就会被这些无效的中断大量占用。
3.1 中断上半部
我们中断上半部的逻辑如下:
c
static irqreturn_t key_isr(int irq, void* dev_id)
{
struct my_key_dev* key = (struct my_key_dev*)dev_id;
disable_irq_nosync(irq);//关键
//jiffies是内核当前节拍数
mod_timer(&key->timer, jiffies + msecs_to_jiffies(20));//定个20ms闹钟
return IRQ_HANDLED;
}
当按键按下或抬起的那一刻,就回触发中断,然后进入中断处理函数。
请注意,我在关中断时用的是 disable_irq_nosync,而不是普通的 disable_irq,因为它会等待当前中断执行完才返回,你在中断里等中断完,这不是死锁了吗?
而 nosync 的妙处:它会让内核立刻关闭这个中断触发。这样,后续那几十次电平抖动产生的中断就被无视了,CPU 也不会进行无效的操作。
3.2 中断下半部:内核定时器
内核定时器的相关部分如下:
c
static void key_timer_callback(struct timer_list *t)
{
struct my_key_dev* key = from_timer(key, t, timer);
key->key_push = 1;
wake_up_interruptible(&key->wq);//唤醒
enable_irq(key->irq_num);//开中断,等待下一次按键动作
key->key_state = gpiod_get_value(key->key_gpio);
gpiod_set_value(key->led_gpio, key->key_state);//直接在内核实现控制LED,响应速度快
if(key->key_state == 1)
{
printk(KERN_INFO "[中断下半部]:按键按下有效!\n");
}
else
{
printk(KERN_INFO "【中断下半部】:按键抬起!\n");
}
}
当中断触发 20ms 后,定时器计时结束。这时物理电平已经稳定了,我们再来读取按键的电平。
3.3 状态同步
请注意下面两行代码:
c
key->key_push = 1;
wake_up_interruptible(&key->wq);//唤醒
这里把内核发生的硬件事件,通知给正在阻塞等待的用户进程。
4. 阻塞I/O
4.1 等待队列
probe 函数中可以看到这样一行代码:
c
init_waitqueue_head(&key->wq);
它初始化了一个等待队列头。
4.2 read函数
在字符设备操作结构体中绑定的 read 函数如下:
c
static ssize_t key_led_read(struct file* file, char __user* buf, size_t count, loff_t* offset)
{
struct my_key_dev* key = file->private_data;
//检测key_pushed是否为真,如果为假就睡眠
if(wait_event_interruptible(key->wq, key->key_push))
return -ERESTARTSYS;
if(copy_to_user(buf, &key->key_state, sizeof(int)))
{
return -EFAULT;
}
key->key_push = 0;//清除标志位。
return sizeof(int);//返回读取到的字节数
}
这里要注意:
c
if(wait_event_interruptible(key->wq, key->key_push))
return -ERESTARTSYS;
当用户程序中调用 read 时,内核会检查 key_push 这个标志位。如果它是 0,代表没按键,内核就会把这个用户进程的状态改为 睡眠,并把它踢出 CPU 的运行队列。
为什么用 interruptible? 这其实是一个好的习惯,万一你的用户程序卡死了,可以按下 Ctrl+C 发送信号,这个函数能感应到并立刻返回,而不是在那儿死等。
4.3 唤醒
在 3.2 小节的定时器回调函数中,我们唤醒了用户程序,他从刚才进入睡眠的地方开始执行,也就是 copy_to_user,内核空间的数据key_state 和用户空间是隔离的,不能直接赋值,必须通过 copy_to_user 跨越那道物理屏障,把按键状态安全地送到用户程序的手里。
从逻辑上看,我们按键点亮甚至比用户程序接收到信息还要快。
最后清除标志位,阻塞用户程序的下一次 read。
如果你发现自己的驱动 APP 跑起来后 CPU 占用100%,那一定是你的驱动没写好阻塞。一个好的驱动,应该在没活干的时候保持绝对的安静。通过等待队列,我们实现了内核与应用层之间的默契协同:不浪费一个 CPU 时钟周期,也不漏掉一次按键触发。
5. 运行测试
到这里,我们主要的逻辑,中断上下半部以及阻塞 I/O 已经全部讲完了,本章我们进行测试,看看我们的简单的用户程序能不能正确读到按键状态,看看 LED 能不能正确随着按键的按下点亮,松开熄灭。
如下图,先进入我们的代码目录:

my_button.ko 是我们等会要加载的模块,test_app 文件夹里面装的是我们的用户程序。
下面我们先加载内核驱动模块,再查看内核日志:

打印出 按键驱动加载成功! ,说明 probe 初始化成功了。
然后我们再运行用户程序:

现在用户程序已经跑起来了,正在等我们按下按键。
但是我们先不急,另开一个终端,看看进程的 CPU 占用:

top 命令默认是按照 CPU 占用排序的,这里并没有找到我们的应用程序,恰恰说明我们的程序已经进入休眠了,正在等待按键按下,因此它并不会空耗 CPU。
下面,我们准备按下按键,同时观察用户程序、内核日志与 LED 的变化:
应用程序如下:

LED 如下:

内核日志如下:

松开按键之后,应用程序如下:

松开按键,应用程序打印完毕之后,再次休眠,等待下一次案件动作。


到这里,按键驱动就全部拆解完成了。
从设备树的精准配置,到 devm 的资源管理,再到中断下半部的消抖逻辑,最后是阻塞 I/O 的协作。这套流程不仅仅是为了让一个 LED 灯亮起来,更是嵌入式 Linux 驱动开发中最通用、最标准的核心范式。
如果这篇文章帮助到你,可以关注一下我,我会持续更新《Linux驱动开发》这个专栏。