一、为什么需要平台总线驱动模型?

在传统的字符设备驱动开发中,我们经常会把硬件资源定义(如 GPIO 号、中断号)和驱动逻辑代码硬编码在一起,这会带来两个致命问题:

  1. 软硬件强耦合:更换硬件引脚时,必须修改整个驱动代码,重新编译,可维护性极差;
  2. 代码复用性低:同一款驱动适配多个不同硬件板卡时,需要维护多份高度重复的代码。

Linux 2.6 以上内核引入的平台总线(Platform Bus)驱动模型,就是为了解决这个问题。它是内核虚拟出来的一条通用总线,专门管理没有专用物理总线(如 I2C、SPI、USB)的外设(如 LED、按键、普通 GPIO 外设),核心设计思想是 **「设备与驱动完全解耦」**:

    设备端:只负责描述硬件资源(GPIO、中断、内存等),不包含任何驱动逻辑;

    驱动端:只负责实现通用的驱动逻辑,不硬编码任何硬件参数,通过总线获取设备的硬件资源;

   平台总线:统一维护设备链表和驱动链表,负责设备与驱动的匹配、生命周期管理。

二、平台总线核心原理(复习必懂核心)

2.1 三大核心实体

平台总线模型的所有逻辑都围绕三大实体展开,对应内核中的核心结构体:

实体 核心结构体 核心作用
平台总线 struct bus_type platform_bus_type 内核自带的全局虚拟总线,维护设备链表和驱动链表,实现匹配规则,管理设备与驱动的生命周期
平台设备 struct platform_device 描述硬件资源,向总线注册后加入设备链表,等待匹配驱动
平台驱动 struct platform_driver 实现驱动逻辑,向总线注册后加入驱动链表,等待匹配设备
核心结构体关键成员解析
  1. 平台设备 struct platform_device

    struct platform_device {
        const char    *name; // 【匹配核心】设备名称,和驱动name一致才能匹配
        int        id;       // 同名称设备的编号,单设备填-1
        struct device    dev;  // 设备通用属性
        void    *platform_data; // 【核心】自定义私有数据,向驱动传递硬件资源
        void    (*release)(struct device *dev); // 设备释放回调,内核强制要求非空
    };
    
  2. 平台驱动 struct platform_driver

    struct platform_driver {
        // 【匹配成功自动执行】驱动初始化入口,相当于传统驱动的init函数
        int (*probe)(struct platform_device *);
        // 【驱动注销自动执行】资源释放入口,相当于传统驱动的exit函数
        int (*remove)(struct platform_device *);
        struct device_driver driver; // 驱动通用属性
        const char *name; // 【匹配核心】驱动名称,和设备name一致才能匹配
    };
    

2.2 平台总线的匹配规则

平台总线的匹配逻辑在内核的platform_match函数中实现,优先级从高到低有 4 种匹配方式,我们入门开发最常用的是名称匹配

  1. 驱动强制匹配:通过设备的driver_override字段强制匹配指定驱动;
  2. 设备树匹配:通过驱动的of_match_table和设备树节点的compatible属性匹配;
  3. ID 表匹配:通过驱动的id_table数组匹配设备名称;
  4. 名称匹配(本文使用):直接对比设备的name和驱动的driver.name,字符串完全一致则匹配成功。
匹配触发时机
  • 注册新设备时:总线会遍历驱动链表,和每个驱动做匹配,匹配成功则执行驱动的probe函数;
  • 注册新驱动时:总线会遍历设备链表,和每个设备做匹配,匹配成功则执行驱动的probe函数。

核心特性:一个驱动可以匹配多个同类型设备,一个设备只能匹配一个驱动。

2.3 核心生命周期函数

  1. probe函数:平台总线匹配成功后自动执行,是驱动的实际初始化入口,负责 GPIO 申请、中断注册、设备节点创建等硬件初始化操作;
  2. remove函数:驱动从总线注销时自动执行,负责释放probe中申请的所有硬件资源,避免内存泄漏。

三、实战:按键驱动完整实现

我们基于平台总线模型,实现一个带中断上下半部 + 消抖的按键驱动,分为设备端和驱动端两个文件,所有代码都经过健壮性修复,可直接编译运行。

3.1 平台设备端:dev_platform.c

设备端只做一件事:向总线描述硬件资源(按键 GPIO 号),不包含任何驱动逻辑,后续更换按键引脚只需要修改这个文件,驱动代码完全不用动。

#include <linux/module.h>   		// 模块编程核心头文件
#include <linux/init.h>				// 模块初始化/卸载头文件
#include <linux/platform_device.h>	// 平台总线设备头文件

/*
* 平台设备的release回调函数(内核强制要求非空)
* 作用:设备从总线注销时,由内核自动调用,完成设备资源的最终释放
* 注意:空函数会触发内核警告,必须实现
*/
void xxx_release(struct device *dev)
{
    pr_info("【平台设备】设备已从总线安全释放\n");
}

/*
* 硬件资源描述:按键的GPIO编号数组
* 设计思想:设备端只负责定义硬件资源,驱动端通过总线获取,实现软硬件解耦
* key_gpio[0]:按键实际使用的GPIO号(这里是5,可根据硬件修改)
* key_gpio[1]:备用扩展引脚
*/
int key_gpio[2] = {5, 1};

/*
* 平台设备核心结构体(硬件资源的载体)
* 作用:向平台总线描述设备的名称、ID、私有硬件数据等信息
*/
struct platform_device xxx_pdev = {
    .name = "key_test",       // 【匹配关键】设备名称,必须和驱动端的driver.name完全一致
    .id = -1,                 // 设备ID,-1表示同名称的设备只有一个
    .dev = {
        .platform_data = key_gpio, // 私有硬件数据:传递给驱动的GPIO资源
        .release = xxx_release,    // 绑定设备释放回调函数
    },
};

/*
* 模块安装入口函数(insmod dev_platform.ko时自动执行)
* 平台总线流程:将设备注册到平台总线的设备链表,等待驱动匹配
*/
static int __init xxx_init(void)
{
    int err;
    
    /* 【平台总线核心步骤1】注册平台设备
    * 内核会将xxx_pdev添加到平台总线的设备链表中
    * 此时设备处于"等待驱动匹配"状态
    */
    err = platform_device_register(&xxx_pdev);
    if (err) {
        pr_err("【平台设备】注册失败,错误码:%d\n", err);
        return err;
    }
    
    pr_info("【平台设备】安装成功,传递的按键GPIO:%d\n", key_gpio[0]);
    return 0;
}

/*
* 模块卸载入口函数(rmmod dev_platform.ko时自动执行)
* 平台总线流程:将设备从平台总线的设备链表移除,触发release回调
*/
static void __exit xxx_exit(void)
{
    /* 【平台总线核心步骤】注销平台设备
    * 内核会将xxx_pdev从平台总线的设备链表中删除
    * 并自动调用xxx_release函数完成最终释放
    */
    platform_device_unregister(&xxx_pdev);
    
    pr_info("【平台设备】卸载成功\n");
}

/* 绑定模块的安装/卸载入口函数 */
module_init(xxx_init);
module_exit(xxx_exit);

/* 模块声明(内核强制要求) */
MODULE_LICENSE("GPL");                // 必须声明GPL协议,否则内核会报错
MODULE_AUTHOR("嵌入式驱动开发");       // 可选:作者信息
MODULE_DESCRIPTION("平台总线-按键设备端:描述硬件GPIO资源"); // 可选:模块功能描述
MODULE_VERSION("v1.0-稳定版");        // 可选:模块版本

3.2 平台驱动端:dri_platform.c

驱动端只做一件事:实现通用的按键驱动逻辑,不硬编码任何硬件参数,通过总线从设备端获取 GPIO 号,实现中断检测、消抖、状态打印。

中断设计:遵循 Linux 内核「上半部快进快出,下半部处理耗时操作」的规范,上半部只调度工作队列,下半部处理消抖和电平判断。

#include <linux/module.h>   		// 模块编程核心头文件
#include <linux/init.h>				// 模块初始化/卸载头文件
#include <linux/platform_device.h>	// 平台总线驱动头文件
#include <linux/fs.h>				// 文件操作头文件
#include <linux/miscdevice.h>		// 杂项设备头文件
#include <linux/gpio.h>				// GPIO操作库头文件
#include <linux/uaccess.h>			// 内核/应用层数据交换头文件
#include <linux/interrupt.h>	    // 中断处理头文件
#include <linux/workqueue.h>	    // 工作队列(中断下半部)头文件
#include <linux/delay.h>		    // 内核延时头文件
#include <linux/atomic.h>		    // 原子操作头文件(修复并发安全)

#define KEY_NAME "key_test"  // 设备名称,用于GPIO申请和中断注册

/* 全局变量优化:用原子变量替代普通int,解决中断下半部的并发安全问题 */
atomic_t flag = ATOMIC_INIT(0);  // 按键状态标记:0=松开,1=按下(原子初始化)

/* 硬件相关全局变量(仅在驱动匹配成功后赋值,避免未初始化访问) */
int key_gpio = 0;  // 按键GPIO号(从设备端的platform_data获取)
int irq = 0;       // 按键对应的中断号(通过GPIO号映射获取)

/* 工作队列结构体:用于中断下半部处理消抖、电平判断等耗时操作 */
struct work_struct key_work;

/* 
 * 中断服务函数(中断上半部:快进快出,禁止耗时操作)
 * 触发条件:按键按下/松开时,GPIO电平变化触发硬件中断
 * 平台总线流程:硬件中断触发后,CPU直接跳转到这里执行
 */
irqreturn_t key_irq_handler(int irq, void *arg)
{
	/* 【中断核心设计】上半部只做最紧急的事:调度工作队列到下半部
	 * 原因:中断上下文不能休眠、不能有延时,消抖/打印等耗时操作必须放下半部
	 */
	schedule_work(&key_work);
	
	return IRQ_HANDLED;  // 告诉内核:中断已成功处理
}

/* 
 * 工作队列处理函数(中断下半部:可安全执行延时、打印等耗时操作)
 * 触发条件:上半部调度后,内核在进程上下文中自动调用
 */
void key_work_func(struct work_struct *workp)
{
	u32 curr_level;
	
	/* 1. 临时关闭中断,防止按键抖动重复触发 */
	disable_irq(irq);
	
	/* 2. 读取当前GPIO电平,延时20ms消抖,再次读取确认状态稳定 */
	curr_level = gpio_get_value(key_gpio);
	msleep(20);  // 内核延时函数,仅在进程上下文中安全使用
	
	if (curr_level == gpio_get_value(key_gpio)) {
		/* 3. 用原子操作读写flag,防止并发竞态 */
		if (curr_level && atomic_read(&flag)) {
			atomic_set(&flag, 0);  // 原子设置:标记为松开
			pr_info("【按键事件】按键松开!!!\n");
		} else if (curr_level == 0 && !atomic_read(&flag)) {
			atomic_set(&flag, 1);  // 原子设置:标记为按下
			pr_info("【按键事件】按键按下!!!\n");
		}
	}
	
	/* 4. 重新打开中断,等待下一次按键触发 */
	enable_irq(irq);
}

/* 
 * 平台驱动的probe函数(【平台总线核心】匹配成功后自动执行)
 * 触发条件:平台总线发现设备和驱动的name完全一致
 * 作用:初始化硬件(GPIO、中断等),是驱动的"实际初始化入口"
 */
int xxx_probe(struct platform_device *ptr_dev)
{
	int *gpio_data;
	int err;
	
	pr_info("【平台驱动】总线匹配成功,开始执行probe初始化\n");
	
	/* 【安全检查1】获取并验证设备端传递的platform_data(硬件资源)
	 * 防止设备端未设置数据导致空指针崩溃
	 */
	gpio_data = (int *)ptr_dev->dev.platform_data;
	if (!gpio_data) {
		pr_err("【平台驱动】错误:设备端未传递platform_data\n");
		return -EINVAL;
	}
	key_gpio = gpio_data[0];  // 从设备端获取按键GPIO号
	pr_info("【平台驱动】从设备端获取到按键GPIO:%d\n", key_gpio);
	
	/* 【硬件初始化1】申请GPIO使用权
	 * 防止GPIO被其他驱动占用,KEY_NAME用于标识GPIO的使用者
	 */
	err = gpio_request(key_gpio, KEY_NAME);
	if (err) {
		pr_err("【平台驱动】GPIO申请失败,错误码:%d\n", err);
		return err;
	}
	
	/* 【硬件初始化2】设置GPIO为输入模式(按键需要读取电平) */
	err = gpio_direction_input(key_gpio);
	if (err) {
		pr_err("【平台驱动】GPIO输入模式设置失败,错误码:%d\n", err);
		goto err_free_gpio;  // 错误处理:跳转到已申请资源的逆序释放
	}
	
	/* 【硬件初始化3】将GPIO号映射为中断号
	 * 平台总线流程:硬件中断是通过GPIO触发的,需要先映射得到内核识别的中断号
	 */
	irq = gpio_to_irq(key_gpio);
	if (irq < 0) {
		err = irq;
		pr_err("【平台驱动】GPIO转中断号失败,错误码:%d\n", err);
		goto err_free_gpio;
	}
	pr_info("【平台驱动】GPIO映射到中断号:%d\n", irq);
	
	/* 【硬件初始化4】注册中断服务函数
	 * 参数说明:
	 * - irq:中断号
	 * - key_irq_handler:中断上半部服务函数
	 * - IRQF_TRIGGER_RISING|FALLING:上升沿+下降沿双触发(对应按下+松开)
	 * - KEY_NAME:中断名称
	 * - NULL:中断服务函数的参数(这里不需要)
	 */
	err = request_irq(irq, key_irq_handler, 
	                  IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING, 
	                  KEY_NAME, NULL);
	if (err) {
		pr_err("【平台驱动】中断注册失败,错误码:%d\n", err);
		goto err_free_gpio;
	}
	
	pr_info("【平台驱动】probe初始化全部完成,设备已就绪\n");
	return 0;  // 成功返回0,内核认为驱动匹配并初始化成功

/* 【错误处理核心规则】资源逆序释放:先申请的后释放
 * 这里只申请了GPIO,所以只需要释放GPIO
 */
err_free_gpio:
	gpio_free(key_gpio);
	return err;  // 始终返回错误码,让内核知道probe失败
}

/* 
 * 平台驱动的remove函数(平台总线驱动注销时自动执行)
 * 触发条件:驱动从平台总线注销时,由内核自动调用
 * 作用:释放probe中申请的硬件资源
 */
int xxx_remove(struct platform_device *ptr_dev)
{
	pr_info("【平台驱动】开始执行remove,释放硬件资源\n");
	
	/* 1. 释放中断服务函数 */
	free_irq(irq, NULL);
	/* 2. 释放GPIO使用权 */
	gpio_free(key_gpio);
	
	pr_info("【平台驱动】remove执行完成,资源已释放\n");
	return 0;
}

/* 
 * 平台驱动核心结构体(驱动逻辑的载体)
 * 作用:向平台总线描述驱动的名称、probe/remove回调函数
 */
struct platform_driver xxx_dri = {
	.probe = xxx_probe,    // 绑定匹配成功后的初始化回调
	.remove = xxx_remove,  // 绑定驱动注销后的资源释放回调
	.driver = {
		.name = "key_test",  // 【匹配关键】驱动名称,必须和设备端的name完全一致
	},
};

/* 
 * 模块安装入口函数(insmod dri_platform.ko时自动执行)
 * 平台总线流程:将驱动注册到平台总线的驱动链表,总线自动遍历设备链表匹配
 */
static int __init xxx_init(void)
{
	int err;
	
	pr_info("【平台驱动】开始安装驱动模块\n");
	
	/* 1. 初始化工作队列(绑定中断下半部处理函数)
	 * 必须在驱动注册前初始化,防止probe后立即触发中断时工作队列未就绪
	 */
	INIT_WORK(&key_work, key_work_func);
	
	/* 【平台总线核心步骤2】注册平台驱动
	 * 内核会将xxx_dri添加到平台总线的驱动链表中
	 * 并立即遍历设备链表,查找name匹配的设备
	 * 若找到匹配设备,自动触发xxx_probe函数
	 */
	err = platform_driver_register(&xxx_dri);
	if (err) {
		pr_err("【平台驱动】驱动注册失败,错误码:%d\n", err);
		return err;
	}
	
	pr_info("【平台驱动】驱动模块安装成功\n");
	return 0;
}

/* 
 * 模块卸载入口函数(rmmod dri_platform.ko时自动执行)
 * 平台总线流程:将驱动从平台总线的驱动链表移除,触发remove回调
 */
static void __exit xxx_exit(void)
{
	pr_info("【平台驱动】开始卸载驱动模块\n");
	
	/* 1. 注销平台驱动
	 * 内核会将xxx_dri从平台总线的驱动链表中删除
	 * 并自动触发xxx_remove函数释放硬件资源
	 */
	platform_driver_unregister(&xxx_dri);
	
	/* 2. 【关键修复】等待工作队列中的任务完全执行完毕
	 * 防止工作队列还在运行时就释放资源,导致Use-After-Free内核崩溃
	 */
	flush_work(&key_work);
	
	pr_info("【平台驱动】驱动模块卸载成功\n");
}

/* 绑定模块的安装/卸载入口函数 */
module_init(xxx_init);
module_exit(xxx_exit);

/* 模块声明(内核强制要求) */
MODULE_LICENSE("GPL");                // 必须声明GPL协议
MODULE_AUTHOR("嵌入式驱动开发");       // 可选:作者信息
MODULE_DESCRIPTION("平台总线-按键驱动端:实现中断+工作队列的按键检测"); // 可选:功能描述
MODULE_VERSION("v1.0-稳定版");        // 可选:模块版本

3.3 编译脚本 Makefile

针对 ARM 架构开发板(如 NanoPC-T4、RK3399)的 Makefile,需提前配置好交叉编译工具链和内核源码路径:

makefile

# 内核源码路径,根据自己的实际环境修改
KERNELDIR := /home/xxx/linux-sdk/kernel
# 当前路径
CURRENT_PATH := $(shell pwd)
# 交叉编译工具链,根据自己的环境修改
ARCH := arm64
CROSS_COMPILE := aarch64-linux-gnu-

# 要编译的模块
obj-m += dev_platform.o
obj-m += dri_platform.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) clean

执行make编译,会生成dev_platform.kodri_platform.ko两个内核模块文件。

四、测试验证与效果展示

4.1 模块加载与匹配测试

将编译好的 ko 文件传到开发板,按先设备、后驱动的顺序加载模块:

# 1. 加载平台设备模块
insmod dev_platform.ko
# 2. 加载平台驱动模块
insmod dri_platform.ko

加载后内核日志输出如下,说明设备与驱动匹配成功:

4.2 按键中断功能测试

按下 / 松开按键,内核会实时打印按键状态,说明中断和消抖功能正常:

4.4 模块卸载

先驱动、后设备的顺序卸载模块,避免资源泄漏:

# 1. 卸载驱动模块
rmmod dri_platform.ko
# 2. 卸载设备模块
rmmod dev_platform.ko

五、开发避坑指南

  1. 设备与驱动的 name 不匹配

    • 坑点:设备的name和驱动的driver.name字符串不一致,总线无法匹配,probe函数永远不会执行;
    • 修复:必须保证两个 name 完全一致,包括大小写、空格。
  2. 空的 release 回调函数

    • 坑点:设备端的release函数为空,卸载设备时会触发内核警告,甚至内存泄漏;
    • 修复:必须实现release函数,至少添加一条打印日志。
  3. probe 函数错误处理逻辑混乱

    • 坑点:错误分支返回 0,让内核误以为 probe 成功,导致资源泄漏;goto 标签顺序错误,重复释放 / 未释放资源;
    • 修复:错误分支始终返回错误码,按「先申请的后释放」的规则逆序释放资源。
  4. 中断上下文使用耗时操作

    • 坑点:在中断上半部服务函数中使用msleepprintk等耗时 / 休眠操作,导致内核崩溃;
    • 修复:上半部只做调度工作队列的操作,耗时操作全部放到中断下半部(工作队列、tasklet)。
  5. 全局变量无并发保护

    • 坑点:在中断和进程上下文同时读写普通全局变量,导致竞态条件,状态判断错误;
    • 修复:使用原子变量atomic_t、自旋锁等内核同步机制保护共享资源。
  6. 卸载驱动时未 flush 工作队列

    • 坑点:卸载驱动时直接释放资源,若工作队列还有未执行的任务,会导致访问已释放资源的内核崩溃;
    • 修复:释放资源前调用flush_work等待工作队列任务全部执行完毕。

六、总结与拓展

平台总线驱动模型的核心就是 「软硬件解耦,总线统一管理」整个工作流程可以简化为 4 步:

  1. 设备端注册:向平台总线的设备链表添加硬件资源描述;
  2. 驱动端注册:向平台总线的驱动链表添加驱动逻辑;
  3. 总线匹配:总线对比设备和驱动的 name,匹配成功自动执行probe函数;
  4. 生命周期管理:驱动注销时自动执行remove函数释放资源,设备注销时执行release函数。

拓展学习

本文使用的是最简单的名称匹配方式,实际企业级开发中更常用设备树匹配

  • 设备端:不再需要编写dev_platform.c,直接在设备树 dts 文件中添加按键节点,描述 GPIO 信息;
  • 驱动端:在platform_driver中添加of_match_table,通过compatible属性匹配设备树节点,从设备树中解析 GPIO 资源。

后续基于本文的驱动代码,修改为设备树匹配的方式,进一步理解平台总线的完整匹配逻辑。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐