【Linux驱动实战】:标准的按键控制LED驱动写法

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 的一些比较常见的具体用途:

  1. 引脚复用: 大多数的 SoC 引脚是 多功能复用 的,例如同一个物理引脚可以是 GPIO,UART TX/RX,I2C SDL/SDA,SPI MOSI/MISO/SCK/CS。 当你的设备需要占用某个引脚时,必须通过 pinctrl 把这些引脚复用到对应的功能上,否则引脚可能是默认功能或冲突状态,导致外设无法正常工作。
  2. 配置引脚的电气特性: 对于一个引脚,它还需要设置上拉、下拉、驱动强度、转换效率、电压电平、开漏等等。

如上面第一段代码,我定义了 my_button_setup,按键引脚如果不配置 内部上拉 ,在没有按下时它就是悬空的。悬空引脚会因为环境电磁干扰产生随机的高低电平波动,导致驱动频繁触发假中断。使用 &pcfg_pull_up 告诉内核把这个引脚接上内部电阻拉高,这样按键没按下时,它就是稳定的高电平。

这个坑我帮大家踩过了,如果不配置内部上拉,加载模块之后,可能出现中断风暴,严重时有好几次命令行直接卡住,无法输入命令,然后我的 SSH 就直接断开了。

第一个参数 3 代表 bank,我们使用的 gpio3,这里就填 3,RK_FUNC_GPIO 会将引脚复用为普通 GPIO 功能。

再来看第二段代码。

pinctrl-names 是引脚状态名称,这是一个字符串列表,从前到后每个字符串与后面的 pinctrl-0pinctrl-1pinctrl-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_kzallocdevm_gpiod_getdevm_request_irq

devm_ 前缀是内核提供的 Device Managed(设备管理) 机制。

传统写法中,如果你在 probe 中间出错,或者驱动卸载 remove时,你必须手动写一堆 kfreegpio_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-gpiosled-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 的结尾,还初始化了三个核心组件:

  1. Timer(定时器) :准备给按键消抖用。
  2. Wait Queue(等待队列) :准备给 read 阻塞读取用。
  3. Cdev(字符设备) :给用户空间提供接口。

写驱动最怕的就是初始化失败之后的擦屁股,以前写 probe ,每申请一个资源都要小心翼翼地写好对应的 free 。用了 devm_ 之后,代码清爽了,逻辑清晰了。建议所有还在死磕旧版内核 API 的,赶紧换 gpioddevm,这才是真正的工业级标准。

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驱动开发》这个专栏。

👉点击获取完整代码

相关推荐
重庆小透明3 小时前
【搞定面试之mysql】第三篇 mysql的锁
java·后端·mysql·面试·职场和发展
NAGNIP3 小时前
一文搞懂卷积神经网络经典架构-LeNet
算法·面试
NAGNIP3 小时前
一文搞懂深度学习中的池化!
算法·面试
kyriewen5 小时前
Generator 函数:那个能“暂停”的函数,到底有什么用?
前端·javascript·面试
Moe4885 小时前
Redis 缓存三大经典问题:穿透、击穿与雪崩
java·后端·面试
Mr.wangh8 小时前
redis面试题总结
java·redis·面试
重学一遍9 小时前
模拟面试-微服务专题
微服务·面试·职场和发展
梦里花开知多少9 小时前
浅谈ThreadPool
android·面试
kyriewen10 小时前
手写 Promise:从“我会用”到“我会造”
前端·javascript·面试