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

0. 前言

依稀记得当初在刚学单片机时,写按键逻辑都是简单的 if(key == 0) delay(20ms)。但在 Linux 内核中,这种写法不仅会拉低系统性能,更是对 CPU 资源的极大浪费,大量的无效判断会将 CPU 资源都耗费在做无用功上面。

物理按键按下的一瞬间,电平并不是直接变低,而是会在 1 和 0 之间疯狂跳变,这就是按键抖动。如果你的驱动在检测到电平跳变时就直接触发中断,你会发现按一下按键,中断可能会触发几十次甚至更夸张。

今天,我拿我的板子鲁班猫 2 平台为例,带大家做一个符合 Linux 内核标准规范的按键驱动,并用它控制一个小小的 LED。虽然功能简单,但是它涉及到的知识点还是比较复杂的,它包含了 设备树配置中断下半部处理内核定时器消抖 、以及 字符设备驱动

最终达到的目标是直接使用驱动程序来控制 LED,并在按键按下或者松开时上报给用户程序。

1. 设备树

很多人写驱动喜欢直接在代码里硬编码 GPIO 编号,这在现代 Linux 内核里相对而言是不专业的做法。标准的做法要要通过设备树来描述硬件。在讲设备树之前,我先将写好的设备树节点放在下面,以便大家对照学习:

这段代码在根节点外面:

c 复制代码
&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>;
		};
	};
};

下面这段代码在根节点里面:

c 复制代码
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 逻辑电平和物理电平

看设备树中这一行:

c 复制代码
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驱动开发》这个专栏。

👉点击获取完整代码

相关推荐
A小辣椒6 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒10 小时前
TShark:基础知识
linux
AlfredZhao12 小时前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao1 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334661 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪1 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5202 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩2 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言