前言
上篇中我们介绍了字符设备模型的基本概念和一些核心的结构体,本文我们接着上篇的内容,讲解一下相关的 API 以及它们的使用方法,同时写一个完整的字符设备驱动并编写用户程序进行测试。
话不多说,我们直接进入正题。
1. 相关 API 介绍
1.1 设备号管理
Linux 通过设备号dev_t来标识设备,设备号包含主设备号和次设备号,dev_t 是一个 32 位整数,高 12 位为主设备号,低 20 位为次设备号。
1.1.1 设备号相关宏
这里有三个设备号相关的宏:
MAJOR(dev_t dev): 从设备号中提取主设备号。MINOR(dev_t dev): 从设备号中提取次设备号。MKDEV(int major, int minor): 将主、次设备号合成设备号dev_t。
1.1.2 静态申请设备号
在已经知道要使用哪个设备号的情况下,可以用这种方法来申请设备号:
c
int register_chrdev_region(dev_t from, unsigned count, const char *name);
from: 起始设备号,包含主、次设备号。如果要注册的设备号已经被其他的设备注册了,那么就会导致注册失败。count: 要申请的连续设备号数量。name: 设备名称,显示在/proc/devices目录下。- 返回值: 0 成功,负数失败。
从静态申请的特性可以看出,这种申请设备号的方式并不友好,如果我们不去事先查看该设备号是否已经被使用,那么就有可能导致申请失败。因此这种方式现在并不常用,但是由于我们有可能在一些比较古老的代码中看到它,这里还是有必要了解一下的。
1.1.3 动态申请设备号
这种方式是目前常用的申请设备号的方式,它会让内核自动分配一个可用的主设备号,避免冲突:
c
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
dev: 内核会将分配到的第一个设备号填入这个地址。baseminor: 起始次设备号,通常为 0。count: 要申请的设备号数量。name: 设备的名称。- 返回值: 0 成功,负数失败。
后面编写字符设备驱动时我们使用的就是这种动态申请设备号的方式。
1.1.4 释放设备号
模块卸载时,必须要释放申请到的设备号:
c
void unregister_chrdev_region(dev_t from, unsigned count);
from:指定需要注销的字符设备的设备号起始值,一般将定义的dev_t变量作为实参。count:指定需要注销的字符设备编号的个数,这个值应该与申请函数的count值相等。
1.2 cdev 相关操作
Linux 内核使用 struct cdev 来描述一个字符设备,我们需要将其与文件操作集file_operations绑定,并添加到内核中。
1.2.1 初始化 cdev
编写一个字符设备驱动最重要的事情,就是实现 file_operations 这个结构体中的函数指针指向的对应函数。实现之后,需要将该结构体与我们的字符设备结构体相关联,这里内核提供了 cdev_init 函数,来实现这个过程。
该函数原型如下:
c
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
cdev: 指向需要初始化的字符设备结构体。fops: 指向文件操作函数集,定义了open,read,write等函数的具体实现。
1.2.2 添加 cdev 到内核
cdev_add 函数用于向内核的 cdev_map 散列表添加一个新的字符设备,这一步完成后,用户空间就可以通过设备号访问它了。
c
int cdev_add(struct cdev *p, dev_t dev, unsigned count);
p: 已经使用cdev_init初始化的cdev指针。dev: 起始设备号。count: 设备数量,通常为 1。- 返回值: 0 成功,负数失败。
1.2.3 从内核删除 cdev
在卸载模块时,需要从内核删除该cdev:
c
void cdev_del(struct cdev *p);
p:struct cdev类型的指针,指定需要删除的字符设备。
1.3 文件操作集合
这些操作是驱动程序的核心,我们需要实现这些回调函数,当用户层调用 open(), read() 等系统调用时,内核会执行这里对应的函数。如下代码块,我只列出了比较常见的成员:
c
struct file_operations {
struct module *owner; //通常填THIS_MODULE
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *); //对应close
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*mmap) (struct file *, struct vm_area_struct *);
//...其他
};
要注意file_operations结构体中函数指针的返回值类型 和参数,我们编写对应函数时要严格按照这里的形式。
1.4 用户空间与内核空间数据传输
由于虚拟地址空间的隔离机制,我们绝不能在内核空间通过指针解引用来访问用户空间的数据。必须使用专用的 API。
1.4.1 从用户空间读取数据
copy_from_user用于将数据从用户空间拷贝到内核空间 ,这种情况通常发生在要将用户空间的数据写入到设备文件中,也就是用户空间调用write函数时,由于驱动程序在内核空间运行,不能直接访问用户空间的数据,所以要在内核空间创建一个内核缓冲区,再使用copy_from_user将数据拷贝到内核缓冲区,从而能够正常访问数据。
c
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
to: 内核空间缓冲区。from: 用户空间缓冲区的指针。n: 要拷贝的字节数。- 返回值: 未拷贝成功的字节数,如果返回 0 表示成功,非 0 表示出错了。
1.4.2 向用户空间发送数据
copy_to_user用于将内核空间的数据拷贝到用户空间 ,当用户程序使用read函数读取设备文件的数据时,还是由于内核空间与用户空间的地址隔离,用户空间不能直接访问内核空间的数据,因此我们在驱动程序中需要将设备文件中的数据通过copy_to_user拷贝到用户空间中,从而让用户程序能正常访问。
c
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
to: 指向用户空间缓冲区的指针。from: 内核空间缓冲区指针。n: 要拷贝的字节数。- 返回值: 未拷贝成功的字节数。
1.5 自动创建设备节点
在旧的 Linux 驱动中,加载驱动后需要手动使用 mknod 命令创建 /dev/xxx 节点,现代驱动改进后可以使用 API 自动在 /dev 下创建节点。
1.5.1 创建类
类 是一种将具有相似功能属性的设备进行分组的高层视图。实际上 class_create 是一个宏,如下:
c
#define class_create(owner, name) \
__class_create(owner, name, &__key)
struct class *__class_create(struct module *owner, const char *name,
struct lock_class_key *key);
owner: 指向拥有这个类的模块的指针,通常填写宏THIS_MODULE。这会增加模块的引用计数,防止在该类还在使用时模块被强制卸载。name: 类的名称,是一个字符串,这个名字会直接出现在/sys/class/目录下。例如,如果你填"my_driver", 就会生成目录/sys/class/my_driver/。
- 成功 时返回指向
struct class结构体的指针,我们需要保存这个指针,因为创建设备时要用到。 - 失败 时返回一个错误指针
ERR_PTR,而不是简单的NULL。我们必须使用IS_ERR(ptr)宏来判断是否出错,并使用PTR_ERR(ptr)将指针转换为int类型的错误码。
后面编写程序时会有使用的方法示例。
1.5.2 创建设备
这个函数完成了最关键的步骤,它在 sysfs 中注册设备信息,并发送 uevent,让 udev 创建 /dev/xxx 节点。
这是一个可变参数 函数,支持类似 printf 的格式化命名,函数原型如下:
c
struct device *device_create(struct class *class, struct device *parent,
dev_t devt, void *drvdata, const char *fmt, ...);
class:上一步class_create返回的结构体指针,这表示该设备属于哪个类。parent:父设备指针。如果该字符设备是挂载在某个物理设备下的,这里填该物理设备的&pdev->dev。对于虚拟的字符设备通常填NULL。devt:udev会读取这个设备号,用来创建/dev/下的设备节点,并关联到对应的驱动。drvdata:驱动私有数据指针,可以在这里存入任何你想关联到这个struct device的数据结构指针,后面可以通过dev_get_drvdata(struct device *dev)取出来。不需要的话直接填NULL。fmt:设备名称的格式字符串,这是最终在/dev/目录下生成的文件名。- 再后面的参数对应
fmt的可变参数,类似于printf函数。
成功 返回 struct device * 指针。失败 返回 ERR_PTR 错误指针,同样需要 IS_ERR() 检查。
1.5.3 销毁
当模块卸载或初始化失败需要回滚释放资源时,必须删除设备,否则 /dev/ 下的节点不会消失,且内核中会残留垃圾数据。
c
void device_destroy(struct class *class, dev_t devt);
class: 创建时使用的类指针。devt: 要销毁的那个设备的设备号。这里不需要传struct device *指针,而是通过设备号来索引并删除的。
在销毁了该类下的所有设备后,才可以销毁类:
c
void class_destroy(struct class *cls);
cls: 要销毁的类指针。
2. 实战
2.1 驱动程序
本章我们写一个最简单但是完整的字符设备驱动。简单介绍一下这个程序中我们要干什么:
我们在内核中维护一个 4096 字节的全局缓冲区,支持 open/close/read/write操作,用户程序写数据时会覆盖内核缓冲区,读数据时返回当前缓冲区的内容。
完整代码如下:
c
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEV_NAME "my_chrdev"
#define BUF_SIZE 4096
static char kernel_buffer[BUF_SIZE] = "Hello Linux Kernel!\n";//初始内容
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;
static int my_open(struct inode *inode,struct file *file)
{
printk(KERN_INFO "my_chrdev: open\n");
return 0;
}
static int my_release(struct inode *inode,struct file *file)
{
printk(KERN_INFO "my_chrdev: release\n");
return 0;
}
static ssize_t my_read(struct file *file,char __user *user_buf,size_t count,loff_t *ppos)
{
size_t to_read = min(count,BUF_SIZE - (size_t)*ppos);//计算剩余可读字节,防止越界
if(to_read == 0)
{
return 0;//EOF
}
if(copy_to_user(user_buf,kernel_buffer + *ppos,to_read))
{
return -EFAULT;
}
*ppos += to_read;
printk(KERN_INFO "my_chrdev: read %zu bytes\n",to_read);
return to_read;
}
static ssize_t my_write(struct file *file,const char __user *user_buf,size_t count,loff_t *ppos)
{
size_t to_write = min(count,BUF_SIZE - (size_t)*ppos);
if(to_write == 0)
{
return -ENOSPC;//缓冲区满
}
memset(kernel_buffer + *ppos,0,BUF_SIZE - *ppos);//将缓冲区清空
if(copy_from_user(kernel_buffer + *ppos,user_buf,to_write))
{
return -EFAULT;
}
*ppos += to_write;
printk(KERN_INFO "my_chrdev: write %zu bytes\n",to_write);
return to_write;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.llseek = default_llseek,//允许用户程序修改文件读写位置
};
static int __init my_char_dev_init(void)
{
int ret;
//动态分配设备号
ret = alloc_chrdev_region(&dev_num,0,1,DEV_NAME);
if(ret < 0)
{
printk(KERN_ERR "my_chrdev: alloc_chrdev_region failed!\n");
return ret;
}
//初始化cdev
cdev_init(&my_cdev,&my_fops);
//添加cdev到系统
ret = cdev_add(&my_cdev,dev_num,1);
if(ret < 0)
{
printk(KERN_ERR "my_chrdev: cdev_add failed!\n");
goto err_unregister;
}
//创建类
my_class = class_create(THIS_MODULE,DEV_NAME);
if(IS_ERR(my_class))
{
ret = PTR_ERR(my_class);
printk(KERN_ERR "my_chrdev: class_create failed!\n");
goto err_cdev_del;
}
//创建设备节点
my_device = device_create(my_class,NULL,dev_num,NULL,DEV_NAME);
if(IS_ERR(my_device))
{
ret = PTR_ERR(my_device);
printk(KERN_ERR "my_chrdev: device_create failed!\n");
goto err_class_destroy;
}
printk(KERN_INFO "my_chrdev: my_char_dev registered, major=%d\n",MAJOR(dev_num));
return 0;
err_class_destroy:
class_destroy(my_class);
err_cdev_del:
cdev_del(&my_cdev);
err_unregister:
unregister_chrdev_region(dev_num,1);
return ret;
}
static void __exit my_char_dev_exit(void)
{
device_destroy(my_class,dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num,1);
printk(KERN_INFO "my_chrdev: my_char_dev unregistered!\n");
}
module_init(my_char_dev_init);
module_exit(my_char_dev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("XLP");
MODULE_DESCRIPTION("A Simple Char Device Kernel Module");
代码中的 API 都是我们介绍过的,唯一需要注意的是在 my_fops 中添加内核通用的 default_llseek,方便用户程序重定位光标。
2.2 用户程序
用户程序比较简单,我们先读取驱动程序中内核缓冲区的初始字符串,将它打印到终端,再将光标移动到文件头,用write写入我们要传到内核缓冲区的字符串,再将光标移动到文件头,再进行读取并打印到终端。
完整代码如下:
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define TO_COPY "Test write from userspace"
int main()
{
int fd;
char buf[4096] = {0};
fd = open("/dev/my_chrdev", O_RDWR);
if(fd < 0)
{
perror("open");
return -1;
}
read(fd,buf,sizeof(buf));
printf("Read from kernel: %s\n", buf);
lseek(fd,0,SEEK_SET); //移动光标,从头读
strcpy(buf,TO_COPY);
write(fd,buf,strlen(buf));
lseek(fd,0,SEEK_SET);
read(fd,buf,sizeof(buf));
printf("Read from kernel: %s\n", buf);
close(fd);
return 0;
}
3. 代码运行结果
编译好上面的程序后,我们在板子上运行。
先使用insmod加载模块并查看内核日志:


然后我们进入用户程序所在目录,运行用户程序:

从运行结果来看,用户程序已经可以成功读取内核缓冲区的数据了。
在查看一下内核日志:

最后卸载模块再查看内核日志:

4. 结语
通过这个简单的驱动,我们从字符设备的名字由来,一路走到了亲手实现一个能读写的内核模块。希望这个系列能帮你建立起清晰的框架,理解"为什么叫字符设备"、看到它在系统中的体现、掌握核心结构体与 VFS 的协作原理,最后还能自己跑通简单的示例代码。
字符设备驱动是嵌入式 Linux 的入门,掌握了它,后续学习平台驱动、设备树、中断、并发等都会轻松很多。如果你有开发板,可以尝试把这个驱动改造成控制 GPIO 或读取一个简单的传感器。
本文完。