嵌入式 Linux 字符设备驱动编写:从原理到实战

在嵌入式 Linux 开发中,字符设备驱动是最基础也最常用的驱动类型(如串口、按键、LED 等均属于字符设备)。本文将结合一个完整的hello字符设备驱动案例,拆解嵌入式 Linux 字符设备驱动的通用编写流程,并分析核心知识点与实战要点。

一、案例背景

本文案例包含两个文件:

  1. 驱动层(hello_drv.c):实现字符设备驱动的核心逻辑,完成设备注册、文件操作接口实现、自动创建设备节点等功能;
  2. 应用层(hello_drv_test.c):通过标准 Linux 文件 IO 接口(open/read/write/close)访问驱动设备,验证驱动功能。

二、嵌入式 Linux 字符设备驱动编写通用流程

复制代码
#include <linux/module.h>

#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/miscdevice.h>
#include <linux/kernel.h>
#include <linux/major.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/init.h>
#include <linux/device.h>
#include <linux/tty.h>
#include <linux/kmod.h>
#include <linux/gfp.h>

/* 1. 确定主设备号                                                                 */
static int major = 0;
static char kernel_buf[1024];
static struct class *hello_class;


#define MIN(a, b) (a < b ? a : b)

/* 3. 实现对应的open/read/write等函数,填入file_operations结构体                   */
static ssize_t hello_drv_read (struct file *file, char __user *buf, size_t size, loff_t *offset)
{
	int err;
	printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
	err = copy_to_user(buf, kernel_buf, MIN(1024, size));
	return MIN(1024, size);
}

static ssize_t hello_drv_write (struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
	int err;
	printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
	err = copy_from_user(kernel_buf, buf, MIN(1024, size));
	return MIN(1024, size);
}

static int hello_drv_open (struct inode *node, struct file *file)
{
	printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
	return 0;
}

static int hello_drv_close (struct inode *node, struct file *file)
{
	printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
	return 0;
}

/* 2. 定义自己的file_operations结构体                                              */
static struct file_operations hello_drv = {
	.owner	 = THIS_MODULE,
	.open    = hello_drv_open,
	.read    = hello_drv_read,
	.write   = hello_drv_write,
	.release = hello_drv_close,
};

/* 4. 把file_operations结构体告诉内核:注册驱动程序                                */
/* 5. 谁来注册驱动程序啊?得有一个入口函数:安装驱动程序时,就会去调用这个入口函数 */
static int __init hello_init(void)
{
	int err;
	
	printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
	major = register_chrdev(0, "hello", &hello_drv);  /* /dev/hello */


	hello_class = class_create(THIS_MODULE, "hello_class");
	err = PTR_ERR(hello_class);
	if (IS_ERR(hello_class)) {
		printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
		unregister_chrdev(major, "hello");
		return -1;
	}
	
	device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello"); /* /dev/hello */
	
	return 0;
}

/* 6. 有入口函数就应该有出口函数:卸载驱动程序时,就会去调用这个出口函数           */
static void __exit hello_exit(void)
{
	printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
	device_destroy(hello_class, MKDEV(major, 0));
	class_destroy(hello_class);
	unregister_chrdev(major, "hello");
}


/* 7. 其他完善:提供设备信息,自动创建设备节点                                     */

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");

步骤 1:确定主设备号(或让内核分配)

作用

主设备号是字符设备的唯一标识,Linux 内核通过 "主设备号 + 次设备号" 区分不同设备(主设备号对应驱动类型,次设备号对应同一驱动下的多个设备)。

实现方式

  • 手动指定 :直接定义static int major = 240;(需确保未被其他驱动占用);
  • 内核自动分配 :定义static int major = 0;(后续通过register_chrdev让内核分配可用主设备号)。

核心逻辑

主设备号是驱动与内核的 "身份凭证",后续注册驱动、创建设备节点都依赖它。

步骤 2:定义自己的file_operations结构体

作用

struct file_operations内核与应用层的交互桥梁,它封装了驱动对外提供的所有操作接口(如open/read/write),应用层调用标准文件 IO 函数时,内核会转发到该结构体对应的驱动函数。

代码示例

复制代码
// 定义file_operations结构体(模板)
static struct file_operations hello_drv = {
    .owner   = THIS_MODULE,  // 固定赋值:表示驱动所属的内核模块
    .open    = hello_drv_open,  // 后续实现的open接口
    .read    = hello_drv_read,  // 后续实现的read接口
    .write   = hello_drv_write, // 后续实现的write接口
    .release = hello_drv_close, // 后续实现的close接口
};

核心逻辑

file_operations是驱动的 "功能清单",内核通过它知道驱动支持哪些操作;其中.owner = THIS_MODULE是内核要求的必填项,用于模块引用计数管理。

步骤 3:实现drv_open/read/write等函数并填入结构体

作用

实现file_operations中声明的接口函数,完成内核与用户空间的数据交互、硬件操作等核心逻辑。

关键函数解析

(1)open函数(设备打开)
复制代码
static int hello_drv_open(struct inode *node, struct file *file)
{
    printk("设备已打开\n");
    // 可在此处初始化硬件(如配置GPIO、申请资源)
    return 0; // 返回0表示成功
}
  • 入参:inode是设备文件的索引节点(包含设备号),file是文件描述符对应的内核对象;
  • 作用:驱动初始化(如硬件配置、资源申请),应用层调用open("/dev/hello")时触发。
  • 调用的 printk 是内核态的日志打印函数,其作用贯穿驱动开发的调试、运行监控、问题定位全流程,是嵌入式 Linux 驱动开发中最基础也最核心的调试手段之一。

疑问:我有一点不太明白,我们自己封装的驱动的这个hello_drv_open函数,不应该是调用相关的open函数来实现功能吗,这里面就一个打印函数,那这个函数怎么实现打开文件的功能的呢?

你这个疑问核心是没分清 "驱动层open函数的本质""普通文件open的功能" ------ 驱动里的hello_drv_open并不是 "打开文件",而是 **"响应应用层的open调用,完成设备的初始化 / 准备工作"**,它的 "打开功能" 是由内核框架兜底,而非函数内的代码直接实现。

一、先纠正核心认知:驱动open函数≠"打开文件"

我们先明确两个层面的open的区别:

层面 函数 / 操作 核心作用
应用层 open("/dev/hello", O_RDWR) 向内核发起 "访问设备" 的请求,内核会帮你创建文件描述符(fd),并关联到驱动
驱动层 hello_drv_open 响应内核的调用,完成设备 "打开前的初始化"(而非创建 fd / 打开文件)

简单说:

  • 应用层调用open后,内核先完成所有 "文件层面" 的工作(比如分配 fd、检查权限、找到对应的驱动);
  • 内核确认一切合法后,才会调用驱动的hello_drv_open,让你有机会做 "设备层面" 的准备。

你案例中hello_drv_open只有打印函数,依然能 "打开设备",本质是:"打开设备" 的核心逻辑由 Linux 内核框架实现,驱动的open函数只是 "可选的初始化钩子"

二、内核是如何 "兜底实现打开功能" 的?

当你在应用层执行fd = open("/dev/hello", O_RDWR)时,内核会执行一套完整的流程,驱动的hello_drv_open只是其中一个 "可选步骤":

步骤 1:内核解析设备文件/dev/hello

内核从/dev/hello的 inode 节点中,提取出主设备号(比如 240)和次设备号(0)。

步骤 2:内核查找驱动映射表

内核查自己维护的 "字符设备驱动表",找到主设备号 240 对应的file_operations结构体(就是你注册的hello_drv)。

步骤 3:内核创建file结构体

内核为这次打开操作创建一个struct file对象(记录打开模式、当前偏移、驱动指针等),并分配一个文件描述符(fd)返回给应用层 ------ 这一步已经完成了 "打开" 的核心(拿到 fd)

步骤 4:内核调用驱动的hello_drv_open

如果你的file_operations中指定了.open = hello_drv_open,内核就会调用这个函数;如果没指定(设为 NULL),内核也不会报错,直接认为 "打开成功"。

步骤 5:返回文件描述符

驱动的hello_drv_open返回 0(成功)后,内核把 fd 返回给应用层,应用层拿到 fd 就可以执行read/write了。

总结:"打开设备并拿到 fd" 的核心逻辑,是内核在调用hello_drv_open之前就完成的 ------ 驱动的open函数只是给你一个 "初始化设备" 的机会,哪怕你只写了打印,内核依然会完成 "打开" 的核心工作。

三、驱动open函数的真正价值:设备初始化(案例简化才只写打印)

你案例中hello_drv_open只有打印,是因为这是一个 "无硬件依赖的 demo";如果是真实硬件驱动(比如 LED / 按键 / 串口),hello_drv_open里会写满 "设备初始化代码",这才是它的核心作用。

(2)read函数(内核→用户空间读数据)
复制代码
static ssize_t hello_drv_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
    int err;
    // 核心:将内核缓冲区数据拷贝到用户空间(必须用copy_to_user,不能直接memcpy)
    err = copy_to_user(buf, kernel_buf, MIN(1024, size));
    return MIN(1024, size); // 返回实际读取的字节数
}
  • 入参:__user *buf是用户空间缓冲区(内核不能直接访问),size是用户请求的读取长度;
  • 核心函数:copy_to_user(dst, src, len)
    • 功能:安全地将内核空间数据拷贝到用户空间;
    • 返回值:成功返回 0,失败返回未拷贝的字节数;
    • 为什么不用memcpy?用户空间地址在内核态是 "非法地址",直接访问会导致内核崩溃。
(3)write函数(用户→内核空间写数据)
复制代码
static ssize_t hello_drv_write(struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
    int err;
    // 核心:将用户空间数据拷贝到内核缓冲区
    err = copy_from_user(kernel_buf, buf, MIN(1024, size));
    return MIN(1024, size); // 返回实际写入的字节数
}
  • 核心函数:copy_from_user(dst, src, len)
    • 功能:安全地将用户空间数据拷贝到内核空间;
    • 返回值:成功返回 0,失败返回未拷贝的字节数。
(4)release函数(设备关闭)
复制代码
static int hello_drv_close(struct inode *node, struct file *file)
{
    printk("设备已关闭\n");
    // 可在此处释放硬件资源
    return 0;
}
  • 作用:应用层调用close(fd)时触发,用于释放资源(如关闭硬件、释放内存)。

步骤 4:通过register_chrdev向内核注册驱动

作用

file_operations结构体 "注册" 到内核,让内核知道 "某个主设备号对应的驱动提供哪些操作接口"。

核心函数:register_chrdev

复制代码
major = register_chrdev(major, "hello", &hello_drv);
  • 入参:
    • major:主设备号(填 0 表示让内核自动分配);
    • "hello":驱动名称(会显示在/proc/devices中);
    • &hello_drv:驱动的file_operations结构体指针;
  • 返回值:
    • 成功:返回分配的主设备号(手动指定时返回传入的major);
    • 失败:返回负数(如-EBUSY表示主设备号已被占用)。

核心逻辑

注册完成后,内核会在字符设备列表中添加一条记录:"主设备号 X 对应的驱动是 hello_drv,支持 open/read/write 等操作"。

步骤 5:实现驱动入口函数(module_init

作用

驱动的 "启动入口",当执行insmod hello_drv.ko安装驱动时,内核会自动调用该函数,完成驱动的初始化(如注册设备、创建设备类)。

代码示例

复制代码
static int __init hello_init(void)
{
    int err;
    // 步骤4:注册字符设备
    major = register_chrdev(0, "hello", &hello_drv);

    // 后续步骤7会用到:创建设备类
    hello_class = class_create(THIS_MODULE, "hello_class");
    if (IS_ERR(hello_class)) { // 错误检查
        unregister_chrdev(major, "hello"); // 注册失败则回滚
        return -1;
    }

    return 0;
}
// 告诉内核:驱动的入口函数是hello_init
module_init(hello_init);

关键说明

  • __init:内核宏,标记该函数是初始化函数,驱动加载完成后会被释放(节省内存);
  • 错误处理:驱动开发必须严谨,某一步失败后要回滚已完成的操作(如注册设备后创建设备类失败,需先注销设备)。

步骤 6:实现驱动出口函数(module_exit

作用

驱动的 "停止出口",当执行rmmod hello_drv卸载驱动时,内核会自动调用该函数,释放驱动占用的所有资源(避免内存泄漏)。

核心函数:unregister_chrdev

复制代码
static void __exit hello_exit(void)
{
    // 后续步骤7会用到:销毁设备节点、设备类
    device_destroy(hello_class, MKDEV(major, 0));
    class_destroy(hello_class);

    // 注销字符设备
    unregister_chrdev(major, "hello");
}
// 告诉内核:驱动的出口函数是hello_exit
module_exit(hello_exit);

关键说明

  • __exit:内核宏,标记该函数是退出函数,仅在驱动卸载时调用;
  • 资源释放顺序:与入口函数的操作顺序相反(先创建的资源后销毁)。

步骤 7:自动创建设备节点(class_create/device_create

作用

传统驱动需要手动执行mknod /dev/hello c 主设备号 次设备号创建设备节点,通过class_createdevice_create可让内核自动生成/dev/xxx设备文件(依赖 udev 机制)。

核心函数解析

(1)class_create:创建设备类
复制代码
hello_class = class_create(THIS_MODULE, "hello_class");
  • 功能:在/sys/class/目录下创建一个类(如/sys/class/hello_class);
  • 作用:为后续创建设备节点提供 "分类标识",udev 会监听/sys/class下的变化。
(2)device_create:创建设备节点
复制代码
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello");
  • 入参:
    • hello_class:设备类指针;
    • MKDEV(major, 0):将主设备号和次设备号(0)组合为dev_t类型;
    • "hello":设备节点名称(最终生成/dev/hello);
  • 功能:在/dev/目录下生成设备节点文件,同时在/sys/class/hello_class/下创建设备目录。

对应出口函数的资源释放

复制代码
// 销毁设备节点
device_destroy(hello_class, MKDEV(major, 0));
// 销毁设备类
class_destroy(hello_class);

最终补充:驱动元信息

驱动必须声明许可证(否则内核会报 "tainted" 警告),常用GPL

复制代码
MODULE_LICENSE("GPL"); // 声明许可证(必填)
MODULE_AUTHOR("Your Name"); // 可选:作者信息
MODULE_DESCRIPTION("Hello Char Device Driver"); // 可选:驱动描述
相关推荐
HLC++5 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室6 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy9 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕9 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫10 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生11 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1112 小时前
linux基础IO
linux·运维·服务器
BelongPanda14 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯14 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983414 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器