上篇博客讲解的驱动开发的标准具体开发流程,但是那个框架没用到设备树和子系统可移植性太低了,但掌握那个是基础的东西。今天介绍一种新的框架GPIO 子系统 + misc 框架,本文基于正点原子 IMX6ULL 开发板,讲解一个基于 misc 框架的按键输入驱动开发全流程,同时配套对应的应用层测试程序。
一、理论基础
1.1 Linux 驱动分层思想与两大核心子系统
Linux 驱动设计的核心思想是设备与驱动分离 和驱动分层,这一思想在 GPIO 相关驱动中,落地为 pinctrl 子系统和 GPIO 子系统两大核心组件,彻底替代了裸机开发中直接操作寄存器的开发模式。
- pinctrl 子系统:负责芯片引脚的复用配置与电气特性设置。IMX6ULL 的绝大多数引脚都具备多种功能,既可以配置为通用 GPIO,也可以复用为 I2C、PWM、UART 等外设功能,这部分引脚级的配置工作全部由 pinctrl 子系统统一管理,避免了驱动代码中直接操作寄存器带来的移植性问题。
- GPIO 子系统:在 pinctrl 子系统将引脚配置为 GPIO 功能后,GPIO 子系统负责统一管理 GPIO 的输入 / 输出方向配置、电平读写等操作。内核封装了一套跨平台的标准 API,开发者无需关注底层寄存器细节,只需调用通用 API 即可完成 GPIO 的所有操作,极大提升了驱动的跨平台性和可维护性。
1.2 misc 杂项设备驱动框架
标准的字符设备驱动开发,需要手动分配设备号、初始化 cdev 结构体、添加字符设备、创建设备类与设备节点,流程繁琐且模板代码冗余。而 misc 设备(杂项设备)是内核对字符设备驱动的一层轻量化封装,其主设备号固定为 10,内核会自动分配次设备号,大幅简化了简单字符设备的开发流程。
- 核心数据结构 :
struct miscdevice,核心成员包括:minor:次设备号,通常设置为MISC_DYNAMIC_MINOR,让内核自动分配;name:设备名,对应 /dev 目录下生成的设备节点名称;fops:文件操作集,绑定驱动的 open/read/write 等业务接口。
- 核心 API :
misc_register():注册 misc 设备,一步完成字符设备注册、设备类与设备节点的创建,替代了标准字符设备的多步操作;misc_deregister():注销 misc 设备,自动释放所有相关内核资源。
- 核心优势:对于按键、LED、蜂鸣器这类简单的字符设备,使用 misc 框架可以大幅减少模板代码,让开发者聚焦于硬件操作的核心业务逻辑。
1.3 设备树与驱动的适配
Linux 3.x 版本之后引入设备树(Device Tree),核心目的是实现硬件信息与驱动代码的分离。硬件平台的寄存器地址、引脚信息、中断配置等硬件相关内容,全部写在设备树.dts 文件中,驱动代码通过内核提供的 API 读取设备树中的硬件信息,从而实现一套驱动适配多个硬件平台。
- 设备树 GPIO 节点核心属性:
compatible:驱动与设备节点匹配的唯一标识符,驱动中通过该字符串匹配对应的设备树节点;gpio-key = <&gpio1 9 GPIO_ACTIVE_LOW>:自定义 GPIO 属性,指定了 GPIO 所属组、引脚编号、有效电平;status = "okay":启用该节点,内核会解析该节点;若为disabled则内核会忽略该节点。
- 驱动中设备树常用 API:
of_find_node_by_path():通过设备树节点路径查找对应的节点结构体;of_get_named_gpio():从设备树节点中解析出 GPIO 编号,供 GPIO 子系统 API 使用。
1.4 sysfs 虚拟文件系统
sysfs 是 Linux 内核提供的一种虚拟文件系统,默认挂载在 /sys 目录下,用于向用户空间暴露内核中设备、驱动、总线的相关信息与属性,是内核与用户空间交互的重要通道。
- 核心目录结构:
/sys/devices:存放系统中所有物理设备的层级信息,是 sysfs 的核心目录;/sys/bus:存放各类总线(platform、i2c、spi 等)的设备与驱动信息,实现设备与驱动的匹配管理;/sys/class:按设备类型分类存放设备信息,misc 设备会统一存放在/sys/class/misc/目录下;/sys/dev/:按字符设备 / 块设备的主次设备号分类,提供设备节点的快速访问。
- 实际作用:misc 按键驱动注册成功后,会自动在
/sys/class/misc/下生成 key_misc 目录,里面包含了设备的主次设备号、驱动归属等信息,既可以用于驱动调试,也可以通过 sysfs 属性文件实现用户空间与内核空间的直接交互。
1.5 驱动逻辑与 GPIO 标准操作
- GPIO 子系统核心 API:
gpio_request()/devm_gpio_request():申请 GPIO 资源,防止多驱动同时占用同一个 GPIO;其中devm_前缀的函数是设备资源管理版,驱动卸载时会自动释放资源,无需手动调用gpio_free();gpio_direction_input():将 GPIO 配置为输入模式,适用于按键、传感器等输入外设;gpio_direction_output():将 GPIO 配置为输出模式,适用于 LED、继电器等输出外设;gpio_get_value():读取 GPIO 引脚的电平值,返回 0 为低电平,非 0 为高电平;gpio_set_value():设置 GPIO 引脚的输出电平。
二、按键驱动开发(基于 misc 框架 + GPIO 子系统)
基于上述理论基础,我们完整实现一个按键输入驱动,配套代码分为驱动层key_drv.c和应用层key_app.c,同时完成设备树节点的适配,最终实现按键按下翻转 LED 灯状态的完整功能。
2.1 硬件与功能需求说明
- 硬件平台:正点原子 IMX6ULL 开发板,按键引脚连接到 SOC 的 GPIO1_IO09,按键采用上拉输入设计,按下时引脚为低电平,松开时为高电平;LED 灯通过 GPIO1_IO03 控制,推挽输出模式。
- 功能需求 :
- 驱动通过 misc 框架注册,生成
/dev/key_misc设备节点,LED 驱动同步生成/dev/led_misc设备节点; - 应用层通过 read 接口读取按键状态,按下返回
KEY_ON(1),松开返回KEY_OFF(0); - 应用程序实现按键消抖,按下按键时翻转 LED 灯状态,实现按键控制 LED 的完整链路。
- 驱动通过 misc 框架注册,生成
2.2 设备树节点修改
首先需要在 IMX6ULL 的设备树文件imx6ull-alientek-emmc.dts中添加按键的设备树节点,将硬件信息传递给内核与驱动。
步骤 1:添加 pinctrl 引脚配置节点
在 iomuxc 根节点中添加按键引脚的 pinctrl 配置,将引脚复用为 GPIO 功能,并设置上拉电气特性:
dts
pinctrl_key: keygrp {
fsl,pins = <
MX6UL_PAD_GPIO1_IO09__GPIO1_IO09 0x10B0 /* 按键引脚,上拉模式 */
>;
};
步骤 2:添加按键设备根节点
在设备树根节点下添加 putekey 自定义节点,描述按键的硬件信息:
dts
putekey {
#address-cells = <1>;
#size-cells = <1>;
compatible = "pute-key";
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_key>;
gpio-key = <&gpio1 9 GPIO_ACTIVE_LOW>;
status = "okay";
};
节点关键说明:
pinctrl-0 = <&pinctrl_key>:引用上面定义的引脚复用配置,确保内核启动时将该引脚初始化为 GPIO 功能;gpio-key = <&gpio1 9 GPIO_ACTIVE_LOW>:指定按键使用 GPIO1 组的第 9 号引脚,低电平为有效状态(按键按下);status = "okay":启用该节点,确保内核启动时解析该节点。
步骤 3:编译与更新设备树
修改完成后,在内核源码目录执行make dtbs重新编译设备树,生成的imx6ull-alientek-emmc.dtb文件通过 tftp 下载到开发板,替换原有的设备树文件,重启开发板使配置生效。
2.3 驱动层代码完整解析(key_drv.c)
1. 头文件引入
c
运行
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/cdev.h>
#include <linux/module.h>
#include <linux/miscdevice.h>
#include <asm/io.h>
#include <asm/uaccess.h>
#include <linux/of.h>
#include <linux/of_address.h>
#include <linux/gpio.h>
#include <linux/of_gpio.h>
关键头文件说明:
linux/miscdevice.h:misc 设备驱动核心头文件,包含核心结构体与注册 / 注销 API;linux/gpio.h/linux/of_gpio.h:GPIO 子系统与设备树 GPIO 解析 API 的核心头文件;asm/uaccess.h:包含copy_to_user/copy_from_user,是内核空间与用户空间数据交互的唯一合规方式;linux/of.h:设备树节点操作 API 的核心头文件。
2. 宏定义与全局变量
c
运行
#define KEY_ON 1
#define KEY_OFF 0
static int gpiokeynum; // 保存从设备树解析出的GPIO编号,全局供read接口使用
gpiokeynum为全局静态变量,在驱动入口函数中完成 GPIO 编号的解析与赋值,在 read 接口中用于读取引脚电平。
3. 文件操作集 read 接口实现
c
运行
static ssize_t key_read(struct file *fp, char __user *puser, size_t n, loff_t *off)
{
int value = 0;
unsigned long nret = 0;
// 调用GPIO子系统API读取引脚电平
value = gpio_get_value(gpiokeynum);
if (value < 0) {
pr_info("get gpiokeynum faikey\n");
return -EINVAL;
}
// 电平逻辑转换:低电平(按键按下)→ KEY_ON,高电平(按键松开)→ KEY_OFF
value = (value == KEY_ON) ? KEY_OFF : KEY_ON;
// 将按键状态从内核空间拷贝到用户空间
nret = copy_to_user(puser, &value, sizeof(value));
if (nret != 0) {
pr_info("copy_to_user faikey\n");
return -EFAULT;
}
return sizeof(value);
}
核心逻辑解析:
gpio_get_value(gpiokeynum):GPIO 子系统标准 API,替代了手动映射寄存器读取电平的裸机操作;- 电平逻辑转换:匹配设备树中
GPIO_ACTIVE_LOW的配置,将硬件电平转换为应用层可识别的按键状态; copy_to_user:内核空间无法直接访问用户空间的内存地址,必须通过该函数完成数据拷贝,返回 0 表示拷贝成功,非 0 表示失败,这是 Linux 驱动中用户与内核空间交互的强制规范;- 接口返回值:返回成功拷贝的字节数。
4. 文件操作集结构体绑定
c
运行
static struct file_operations fops = {
.owner = THIS_MODULE,
.read = key_read,
};
struct file_operations是字符设备驱动的核心,里面的函数指针绑定了驱动对设备文件的操作接口。这里我们仅实现了 read 接口用于读取按键状态,若需要实现写操作,只需添加.write成员并绑定对应的处理函数即可(代码注释部分为 write 接口的完整实现)。
.owner = THIS_MODULE:固定写法,将该操作集归属到当前内核模块,防止模块被使用时被意外卸载。
5. misc 设备结构体定义
c
运行
static struct miscdevice key_misc = {
.minor = MISC_DYNAMIC_MINOR, // 内核自动分配次设备号
.name = "key_misc", // 设备节点名,注册成功后生成/dev/key_misc
.fops = &fops, // 绑定文件操作集
};
这是 misc 驱动的结构体。通过动态分配次设备号,避免了手动管理设备号的冲突问题,设备名直接对应 /dev 目录下的设备节点名称。
6. 驱动入口函数实现
c
运行
static int __init key_drv_init(void)
{
int ret = 0;
struct device_node *pkeynode = NULL;
// 步骤1:注册misc设备,一步完成字符设备全流程注册
ret = misc_register(&key_misc);
if (ret != 0) {
pr_info("misc register faikey\n");
return ret;
}
// 步骤2:通过路径查找设备树中的putekey节点
pkeynode = of_find_node_by_path("/putekey");
if (pkeynode == NULL) {
pr_info("can not find gpiokeynum\n");
ret = -ENODEV;
goto err_deregister;
}
// 步骤3:从设备树节点中解析GPIO编号
gpiokeynum = of_get_named_gpio(pkeynode, "gpio-key", 0);
if (gpiokeynum < 0) {
pr_info("get gpiokeynum faikey\n");
ret = -EINVAL;
goto err_deregister;
}
// 步骤4:申请GPIO资源,使用devm_自动管理版本,避免资源泄漏
ret = devm_gpio_request(key_misc.this_device, gpiokeynum, "key_drv");
if (ret != 0) {
pr_info("request gpio faikey\n");
ret = -EBUSY;
goto err_deregister;
}
// 步骤5:将GPIO配置为输入模式
gpio_direction_input(gpiokeynum);
pr_info("key drv init success!\n");
return 0;
// 错误处理:注册失败时注销已申请的misc设备,避免内核资源泄漏
err_deregister:
misc_deregister(&key_misc);
return ret;
}
入口函数是驱动加载时执行的核心逻辑,采用 goto 错误处理的内核规范写法,步骤拆解如下:
- 注册 misc 设备:调用
misc_register一步完成字符设备的全流程注册,这是 misc 框架相比标准字符设备最大的优势; - 查找设备树节点:通过
of_find_node_by_path找到自定义的 putekey 节点,是驱动获取硬件信息的第一步; - 解析 GPIO 编号:通过
of_get_named_gpio从节点属性中解析出 GPIO 编号,后续所有 GPIO 操作都基于该编号; - 申请 GPIO 资源:调用
devm_gpio_request申请 GPIO,确保该引脚不会被其他驱动占用,同时利用内核资源管理机制自动释放; - 配置 GPIO 模式:调用
gpio_direction_input将引脚设置为输入模式,匹配按键的输入外设属性; - 错误处理:每一步操作失败时,都通过 goto 语句注销已注册的 misc 设备,避免内核资源泄漏。
c
运行
static void __exit key_drv_exit(void)
{
// 注销misc设备,释放所有相关内核资源
misc_deregister(&key_misc);
pr_info("key drv exit success!\n");
}
驱动卸载时执行该函数,核心操作是调用misc_deregister注销 misc 设备,自动删除 /dev 目录下的设备节点。由于我们使用了devm_gpio_request申请 GPIO,这里无需手动释放 GPIO,内核会在设备注销时自动处理相关资源。
8. 模块声明与许可证
c
运行
module_init(key_drv_init); // 绑定驱动入口函数,insmod时执行
module_exit(key_drv_exit); // 绑定驱动出口函数,rmmod时执行
MODULE_LICENSE("GPL"); // 声明许可证,必须遵循GPL协议,否则无法使用内核核心API
MODULE_AUTHOR("wang"); // 声明驱动作者
这是 Linux 内核模块的固定格式,MODULE_LICENSE("GPL")是强制要求,否则内核会报模块污染警告,且无法使用内核的大部分核心 API。
2.4 应用层测试代码解析(key_app.c)
驱动实现完成后,编写应用层程序,遵循 Linux "一切皆是文件" 的设计思想,通过设备文件访问驱动,实现按键状态读取与 LED 控制。
c
运行
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#define KEY_ON 1
#define KEY_OFF 0
#define LED_ON 1
#define LED_OFF 0
// 毫秒级延时函数,用于按键软件消抖
void delay_ms(int ms)
{
usleep(ms * 1000);
}
int main(void)
{
int fd_key = 0;
int fd_led = 0;
int readstat_pre = 0; // 上一次的按键状态
int readstat_cur = 0; // 当前的按键状态
int flag = 0; // LED灯状态标志,0为灭,1为亮
// 1. 打开按键设备节点,获取文件描述符
fd_key = open("/dev/key_misc", O_RDWR);
if (-1 == fd_key)
{
perror("fail to open key device");
return -1;
}
// 2. 打开LED设备节点(需提前实现LED的misc驱动)
fd_led = open("/dev/led_misc", O_RDWR);
if (-1 == fd_led)
{
perror("fail to open led device");
close(fd_key);
return -1;
}
// 3. 循环扫描按键状态
while (1)
{
// 读取当前按键状态,触发驱动中的key_read接口
read(fd_key, &readstat_cur, sizeof(readstat_cur));
// 按键状态发生变化,执行消抖处理
if (readstat_cur != readstat_pre)
{
// 仅在按键按下时,翻转LED状态
if(readstat_cur == KEY_ON)
{
flag = !flag;
write(fd_led, &flag, sizeof(flag));
printf("key pressed, led state: %s\n", flag ? "on" : "off");
}
}
// 10ms延时消抖,更新上一次的按键状态
delay_ms(10);
readstat_pre = readstat_cur;
}
// 关闭设备文件,释放资源
close(fd_key);
close(fd_led);
return 0;
}
核心逻辑解析:
- 设备文件打开 :通过
open函数打开/dev/key_misc和/dev/led_misc设备节点,获取文件描述符,这是 Linux 应用层访问字符设备的标准方式。 - 按键状态读取 :在循环中通过
read函数读取按键状态,该函数会系统调用陷入内核,最终执行驱动中绑定的key_read接口,获取按键的实时状态; - 按键消抖处理:通过对比当前状态与上一次状态,判断按键是否发生有效变化,配合 10ms 的延时,避免按键机械触点抖动导致的状态误判;
- LED 状态控制 :仅当按键按下时,翻转 LED 状态标志,并通过
write函数将状态写入 LED 设备节点,触发 LED 驱动的 write 接口,完成 LED 亮灭控制。
三、驱动编译与加载测试
3.1 驱动 Makefile 编写
编写 Makefile,通过交叉编译工具链将驱动代码编译为可在 IMX6ULL 开发板上运行的.ko 内核模块文件:
makefile
# 内核源码路径,需替换为自己的实际路径
KERNELDIR := /home/linux/imx6ull/kernel/linux-imx-rel_imx_4.1.15_2.1.0_ga_alientek
# 当前工程路径
CURRENT_PATH := $(shell pwd)
# 交叉编译工具链,对应课程第7章配置的工具链
CROSS_COMPILE := arm-linux-gnueabihf-
# 目标硬件架构
ARCH := arm
# 指定编译的目标模块
obj-m := key_drv.o
# 编译目标
build: kernel_modules
# 内核模块编译命令
kernel_modules:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules
# 清理编译产物
clean:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) clean
在 Ubuntu 终端执行make命令,即可生成key_drv.ko驱动模块文件。
3.2 应用程序交叉编译
使用交叉编译工具链编译应用层代码,生成可在开发板上运行的可执行文件:
bash
运行
arm-linux-gnueabihf-gcc key_app.c -o key_app
编译完成后,生成key_app可执行文件。
3.3 开发板加载与功能测试
-
文件传输 :将
key_drv.ko和key_app文件通过 nfs 共享目录(课程第 6 章配置)拷贝到开发板中; -
驱动加载 :在开发板终端执行以下命令加载驱动模块:
bash
运行
insmod key_drv.ko加载成功后,终端会打印
key drv init success!,同时查看 /dev 目录,会自动生成key_misc设备节点:bash
运行
ls /dev/key_misc也可通过 sysfs 查看设备信息,验证 misc 设备注册成功:
bash
运行
ls /sys/class/misc/key_misc -
LED 驱动准备 :确保 LED 的 misc 驱动已成功加载,
/dev/led_misc设备节点正常生成; -
应用程序运行 :给应用程序添加执行权限并启动:
bash
运行
chmod 777 key_app ./key_app -
功能测试:按下开发板上的按键,终端会打印状态信息,同时 LED 灯状态翻转;松开按键无操作,再次按下再次翻转,功能符合预期;
-
驱动卸载 :测试完成后,执行以下命令卸载驱动模块:
bash
运行
rmmod key_drv.ko卸载成功后,终端会打印
key drv exit success!,/dev/key_misc设备节点会被内核自动删除。
四、常见问题排查与知识点深化
4.1 驱动加载常见问题与解决方案
-
问题 :insmod 驱动时提示
No such device,终端打印can not find gpiokeynum- 原因:设备树节点未正确添加、语法错误,或设备树文件未更新到开发板,内核无法找到
/putekey节点; - 解决方案:检查设备树节点语法,重新编译并更新开发板的设备树文件,通过
ls /proc/device-tree/确认节点已被内核正确解析。
- 原因:设备树节点未正确添加、语法错误,或设备树文件未更新到开发板,内核无法找到
-
问题 :insmod 驱动时提示
Device or resource busy,打印request gpio faikey- 原因:申请的 GPIO 引脚被设备树中其他节点占用,或被其他驱动申请;
- 解决方案:检查设备树中是否有其他节点使用了该 GPIO,禁用冲突节点;或更换未被使用的 GPIO 引脚,通过
cat /sys/kernel/debug/gpio查看 GPIO 占用情况。
-
问题 :应用层 open 设备节点失败,提示
No such file or directory- 原因:驱动未成功加载,设备节点未生成;或设备节点名称拼写错误;
- 解决方案:通过
dmesg查看内核打印信息,确认驱动注册成功;通过ls /dev查看设备节点是否正常生成。
-
问题:应用层 read 按键状态无变化,始终为固定值
-
原因:硬件接线错误、GPIO 引脚配置错误,或设备树中有效电平设置与实际硬件不符;
-
解决方案:通过万用表测量按键引脚的实际电平,确认硬件接线正确;检查设备树中
GPIO_ACTIVE_LOW/GPIO_ACTIVE_HIGH是否与硬件匹配;通过 sysfs 手动操作 GPIO,验证引脚功能正常:bash
运行
# 导出GPIO,替换为实际的GPIO编号 echo 9 > /sys/class/gpio/export # 设置为输入模式 echo in > /sys/class/gpio/gpio9/direction # 读取电平值 cat /sys/class/gpio/gpio9/value
-
五、总结
整个开发过程中,我们需要遵循 Linux 驱动的设计思想:通过设备树实现硬件信息与驱动代码的分离,通过 GPIO 子系统屏蔽底层寄存器操作,通过 misc 框架简化字符设备的注册流程,最终实现了一套可移植、易维护的外设驱动。这一开发框架,不仅适用于按键、LED 这类简单外设,也是 I2C、SPI、LCD 等复杂外设驱动开发的基础。