嵌入式 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"); // 可选:驱动描述
相关推荐
wadesir2 小时前
Linux文件系统创建(从零开始构建你的存储空间)
linux·运维·服务器
Paper_Love2 小时前
RK3568-linux升级用户程序
linux
2gexmxy2 小时前
FTP服务器搭建详解(Linux、Windows)
linux·centos·ftp
边疆.2 小时前
【Linux】库制作与原理
linux·运维·动态库·静态库·动态链接·静态链接
松涛和鸣2 小时前
28、Linux文件IO与标准IO详解:从概念到实战
linux·网络·数据结构·算法·链表·list
修己xj2 小时前
外网下载内网部署:Yum离线升级Linux软件包
linux
嵌入式小能手3 小时前
飞凌嵌入式ElfBoard-文件I/O的深入学习之I/O多路复用
linux·服务器·学习
Konwledging3 小时前
Linux memblock
linux
小嘟嘟133 小时前
从基础到进阶:掌握 userdel,玩转 Linux 用户管理的 “减法” 艺术
linux·运维·网络·shell