IMX6ULL Linux 驱动开发:GPIO 子系统 + misc 框架实现按键输入驱动开发

上篇博客讲解的驱动开发的标准具体开发流程,但是那个框架没用到设备树和子系统可移植性太低了,但掌握那个是基础的东西。今天介绍一种新的框架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 控制,推挽输出模式。
  • 功能需求
    1. 驱动通过 misc 框架注册,生成/dev/key_misc设备节点,LED 驱动同步生成/dev/led_misc设备节点;
    2. 应用层通过 read 接口读取按键状态,按下返回KEY_ON(1),松开返回KEY_OFF(0)
    3. 应用程序实现按键消抖,按下按键时翻转 LED 灯状态,实现按键控制 LED 的完整链路。

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 错误处理的内核规范写法,步骤拆解如下:

  1. 注册 misc 设备:调用misc_register一步完成字符设备的全流程注册,这是 misc 框架相比标准字符设备最大的优势;
  2. 查找设备树节点:通过of_find_node_by_path找到自定义的 putekey 节点,是驱动获取硬件信息的第一步;
  3. 解析 GPIO 编号:通过of_get_named_gpio从节点属性中解析出 GPIO 编号,后续所有 GPIO 操作都基于该编号;
  4. 申请 GPIO 资源:调用devm_gpio_request申请 GPIO,确保该引脚不会被其他驱动占用,同时利用内核资源管理机制自动释放;
  5. 配置 GPIO 模式:调用gpio_direction_input将引脚设置为输入模式,匹配按键的输入外设属性;
  6. 错误处理:每一步操作失败时,都通过 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;
}

核心逻辑解析:

  1. 设备文件打开 :通过open函数打开/dev/key_misc/dev/led_misc设备节点,获取文件描述符,这是 Linux 应用层访问字符设备的标准方式。
  2. 按键状态读取 :在循环中通过read函数读取按键状态,该函数会系统调用陷入内核,最终执行驱动中绑定的key_read接口,获取按键的实时状态;
  3. 按键消抖处理:通过对比当前状态与上一次状态,判断按键是否发生有效变化,配合 10ms 的延时,避免按键机械触点抖动导致的状态误判;
  4. 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 开发板加载与功能测试

  1. 文件传输 :将key_drv.kokey_app文件通过 nfs 共享目录(课程第 6 章配置)拷贝到开发板中;

  2. 驱动加载 :在开发板终端执行以下命令加载驱动模块:

    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
  3. LED 驱动准备 :确保 LED 的 misc 驱动已成功加载,/dev/led_misc设备节点正常生成;

  4. 应用程序运行 :给应用程序添加执行权限并启动:

    bash

    运行

    复制代码
    chmod 777 key_app
    ./key_app
  5. 功能测试:按下开发板上的按键,终端会打印状态信息,同时 LED 灯状态翻转;松开按键无操作,再次按下再次翻转,功能符合预期;

  6. 驱动卸载 :测试完成后,执行以下命令卸载驱动模块:

    bash

    运行

    复制代码
    rmmod key_drv.ko

    卸载成功后,终端会打印key drv exit success!/dev/key_misc设备节点会被内核自动删除。

四、常见问题排查与知识点深化

4.1 驱动加载常见问题与解决方案

  1. 问题 :insmod 驱动时提示No such device,终端打印can not find gpiokeynum

    • 原因:设备树节点未正确添加、语法错误,或设备树文件未更新到开发板,内核无法找到/putekey节点;
    • 解决方案:检查设备树节点语法,重新编译并更新开发板的设备树文件,通过ls /proc/device-tree/确认节点已被内核正确解析。
  2. 问题 :insmod 驱动时提示Device or resource busy,打印request gpio faikey

    • 原因:申请的 GPIO 引脚被设备树中其他节点占用,或被其他驱动申请;
    • 解决方案:检查设备树中是否有其他节点使用了该 GPIO,禁用冲突节点;或更换未被使用的 GPIO 引脚,通过cat /sys/kernel/debug/gpio查看 GPIO 占用情况。
  3. 问题 :应用层 open 设备节点失败,提示No such file or directory

    • 原因:驱动未成功加载,设备节点未生成;或设备节点名称拼写错误;
    • 解决方案:通过dmesg查看内核打印信息,确认驱动注册成功;通过ls /dev查看设备节点是否正常生成。
  4. 问题:应用层 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 等复杂外设驱动开发的基础。

相关推荐
小雨青年2 小时前
GitHub CLI 与脚本自动化
运维·自动化·github
捞的不谈~2 小时前
解决在Ubuntu系统下使用运行Lucid 相机(HTR003S-001)相应实例出现的依赖库缺失的问题
linux·运维·ubuntu
J超会运2 小时前
OpenEuler24.03 LVS+Keepalived实战指南
linux·服务器·前端
白毛大侠2 小时前
四表五链:Linux 防火墙的核心框架
linux·运维·网络
青桔柠薯片2 小时前
基于i.MX6ULL的字符设备驱动开发实践——以LED、蜂鸣器与按键为例
驱动开发·imx6ull
拾光Ծ2 小时前
吃透 Linux 静态库 / 动态库:ELF 文件、链接加载与进程地址空间详解
linux·动态库·静态库·elf·链接与加载·c/c++编程
铅笔小新z2 小时前
【Linux】进程(中)
linux·运维·服务器
云栖梦泽2 小时前
Linux内核与驱动:11.设备树
linux·c++
白毛大侠2 小时前
Linux 常用命令速查手册
linux·运维·服务器