Linux字符驱动程序(下)
4.重要的数据结构
如同你想象的, 注册设备编号仅仅是驱动代码必须进行的诸多任务中的第一个. 我们将很快看到其他重要的驱动组件, 但首先需要涉及一个别的. 大部分的基础性的驱动操作包括 3 个重要的内核数据结构, 称为 file_operations, file, 和 inode. 需要对这些结构的基本了解才能够做大量感兴趣的事情, 因此我们现在在进入如何实现基础性驱动操作的细节之前, 会快速查看每一个.
文件操作
到现在, 我们已经保留了一些设备编号给我们使用,但是我们还没有连接任何我们设备操作到这些编号上.file_operation 结构是一个字符驱动如何建立这个连接. 这个结构, 定义在 <linux/fs.h>, 是一个函数指针的集合. 每个打开文件(内部用一个 file 结构来代表, 稍后我们会查看)与它自身的函数集合相关连( 通过包含一个称为f_op 的成员, 它指向一个 file_operations 结构). 这些操作大部分负责实现系统调用, 因此, 命名为 open, read, 等等. 我们可以认为文件是一个"对象"并且其上的函数操作称为它的"方法", 使用面向对象编程的术语来表示一个对象声明的用来操作对象的动作. 这是我们在Linux 内核中看到的第一个面向对象编程的现象, 后续章中我们会看到更多.
传统上,一个file_operation 结构或者其一个指针称为 fops( 或者它的一些变体). 结构中的每个成员必须指向驱动中的函数,这些函数实现一个特别的操作,或者对于不支持的操作留置为 NULL.当指定为 NULL 指针时内核的确切的行为是每个函数不同的,如同本节后面的列表所示.
下面的列表介绍了一个应用程序能够在设备上调用的所有操作.我们已经试图保持列表简短, 这样它可作为一个参考,只是总结每个操作和在 NULL 指针使用时的缺省内核行为.
在你通读file_operations 方法的列表时,你会注意到不少参数包含字串__user.这种注解是一种文档形式,注意,一个指针是一个不能被直接解引用的用户空间地址.对于正常的编译,__user 没有效果,但是它可被外部检查软件使用来找出对用户空间地址的错误使用.
struct module *owner 第一个 file_operations 成员根本不是一个操作; 它是一个指向拥有这个结构的模块的指针. 这个成员用来在它的操作还在被使用时阻止模块被卸载. 几乎所有时间中, 它被简单初始化为 THIS_MODULE, 一个在 <linux/module.h> 中定义的宏. |
loff_t (*llseek) (struct file *, loff_t, int); llseek 方法用作改变文件中的当前读/写位置, 并且新位置作为(正的)返回值. loff_t 参数是一个"long offset", 并且就算在 32位平台上也至少 64 位宽. 错误由一个负返回值指示. 如果这个函数指针是 NULL, seek 调用会以潜在地无法预知的方式修改 file 结构中的位置计数器( 在"file 结构" 一节中描述). |
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); 用来从设备中获取数据. 在这个位置的一个空指针导致 read 系统调用以 -EINVAL("Invalid argument") 失败. 一个非负返回值代表了成功读取的字节数( 返回值是一个 "signed size" 类型, 常常是目标平台本地的整数类型). |
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); 发送数据给设备. 如果 NULL, -EINVAL 返回给调用 write 系统调用的程序. 如果非负, 返回值代表成功写的字节数. |
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long); ioctl 系统调用提供了发出设备特定命令的方法(例如格式化软盘的一个磁道, 这不是读也不是写). 另外, 几个 ioctl 命令被内核识别而不必引用 fops 表. 如果设备不提供 ioctl 方法, 对于任何未事先定义的请求(-ENOTTY, "设备无这样的 ioctl"), 系统调用返回一个错误. |
int (*mmap) (struct file *, struct vm_area_struct *); mmap 用来请求将设备内存映射到进程的地址空间. 如果这个方法是 NULL, mmap 系统调用返回 -ENODEV. |
int (*open) (struct inode *, struct file *); 尽管这常常是对设备文件进行的第一个操作, 不要求驱动声明一个对应的方法. 如果这个项是 NULL, 设备打开一直成功, 但是你的驱动不会得到通知. |
int (*release) (struct inode *, struct file *); 在文件结构被释放时引用这个操作. 如同 open, release 可以为 NULL. |
ctest 设备驱动只实现最重要的设备方法. 它的 file_operations 结构是如下初始化的:
struct file_operations ctest_fops = {
.owner = THIS_MODULE,
.llseek = ctest_llseek,
.read = ctest_read,
.write = ctest_write,
.ioctl = ctest_ioctl,
.open = ctest_open,
.release = ctest_release,
};
这个声明使用标准的 C 标记式结构初始化语法. 这个语法是首选的, 因为它使驱动在结构定义的改变之间更加可移植, 并且, 有争议地, 使代码更加紧凑和可读. 标记式初始化允许结构成员重新排序;在某种情况下, 真实的性能提高已经实现, 通过安放经常使用的成员的指针在相同硬件高速存储行中.
文件结构
struct file, 定义于 <linux/fs.h>, 是设备驱动中第二个最重要的数据结构. 注意 file 与用户空间程序的 FILE 指针没有任何关系. 一个 FILE 定义在 C 库中, 从不出现在内核代码中. 一个 struct file, 另一方面, 是一个内核结构, 从不出现在用户程序中.
文件结构代表一个打开的文件. (它不特定给设备驱动; 系统中每个打开的文件有一个关联的 struct file 在内核空间). 它由内核在 open 时创建, 并传递给在文件上操作的任何函数, 直到最后的关闭. 在文件的所有实例都关闭后, 内核释放这个数据结构.
在内核源码中, struct file 的指针常常称为 file 或者 filp("file pointer"). 我们将一直称这个指针为 filp 以避免和结构自身混淆. 因此, file 指的是结构, 而 filp 是结构指针.
struct file 的最重要成员在这展示. 如同在前一节, 第一次阅读可以跳过这个列表. 但是, 在本章后面,当我们面对一些真实 C 代码时, 我们将更详细讨论这些成员.
mode_t f_mode; 文件模式确定文件是可读的或者是可写的(或者都是), 通过位 FMODE_READ 和 FMODE_WRITE. 你可能想在你的 open 或者 ioctl 函数中检查这个成员的读写许可, 但是你不需要检查读写许可, 因为内核在调用你的方法之前检查. 当文件还没有为那种存取而打开时读或写的企图被拒绝, 驱动甚至不知道这个情况. |
loff_t f_pos; 当前读写位置. loff_t 在所有平台都是 64 位( 在 gcc 术语里是 long long ). 驱动可以读这个值, 如果它需要知道文件中的当前位置, 但是正常地不应该改变它; 读和写应当使用它们作为最后参数而收到的指针来更新一个位置, 代替直接作用于 filp->f_pos. 这个规则的一个例外是在 llseek 方法中, 它的目的就是改变文件位置. |
unsigned int f_flags; 这些是文件标志, 例如 O_RDONLY, O_NONBLOCK, 和 O_SYNC. 驱动应当检查O_NONBLOCK 标志来看是否是请求非阻塞操作; 其他标志很少使用. 特别地, 应当检查读/写许可, 使用 f_mode 而不是f_flags. 所有的标志在头文件 <linux/fcntl.h> 中定义. |
struct file_operations *f_op; 和文件关联的操作. 内核安排指针作为它的 open 实现的一部分, 接着读取它当它需要分派任何的操作时. filp->f_op 中的值从不由内核保存为后面的引用; 这意味着你可改变你的文件关联的文件操作, 在你返回调用者之后新方法会起作用. 例如, 关联到主编号 1 (/dev/null, /dev/zero, 等等)的 open 代码根据打开的次编号来替代 filp->f_op 中的操作. 这个做法允许实现几种行为, 在同一个主编号下而不必在每个系统调用中引入开销. 替换文件操作的能力是面向对象编程的"方法重载"的内核对等体. |
void *private_data; open 系统调用设置这个指针为 NULL, 在为驱动调用 open 方法之前. 你可自由使用这个成员或者忽略它; 你可以使用这个成员来指向分配的数据, 但是接着你必须记住在内核销毁文件结构之前, 在 release 方法中释放那个内存. private_data 是一个有用的资源, 在系统调用间保留状态信息, 我们大部分例子模块都使用它. |
struct dentry *f_dentry; 关联到文件的目录入口( dentry )结构. 设备驱动编写者正常地不需要关心 dentry 结构, 除了作为 filp->f_dentry->d_inode 存取 inode 结构. |
真实结构有多几个成员, 但是它们对设备驱动没有用处. 我们可以安全地忽略这些成员, 因为驱动从不创建文件结构; 它们真实存取别处创建的结构.
inode 结构
inode 结构由内核在内部用来表示文件. 因此, 它和代表打开文件描述符的文件结构是不同的. 可能有代表单个文件的多个打开描述符的许多文件结构, 但是它们都指向一个单个 inode 结构.inode 结构包含大量关于文件的信息. 作为一个通用的规则, 这个结构只有 2 个成员对于编写驱动代码有用:
dev_t i_rdev; 对于代表设备文件的节点, 这个成员包含实际的设备编号. |
struct cdev *i_cdev; struct cdev 是内核的内部结构, 代表字符设备; 这个成员包含一个指针, 指向这个结构, 当节点指的是一个字符设备文件时. |
i_rdev 类型在 2.5 开发系列中改变了, 破坏了大量的驱动. 作为一个鼓励更可移植编程的方法, 内核开发者已经增加了 2 个宏, 可用来从一个 inode 中获取主次编号:
unsigned int iminor(struct inode *inode);
unsigned int imajor(struct inode *inode);
为了不要被下一次改动抓住, 应当使用这些宏代替直接操作 i_rdev.
cdev 结构体
在Linux 2.6 内核中,使用cdev 结构体描述一个字符设备,cdev 结构体的定义如下:
-------------------------------------------------------------------------------------
struct cdev{
struct kobject kobj; /* 内嵌的kobject 对象 */
struct module *owner; /*所属模块*/
struct file_operations *ops; /*文件操作结构体*/
struct list_head list;
dev_t dev; /*设备号*/
unsigned int count;
};
-----------------------------------------------------------------------
ü cdev 结构体的dev_t 成员定义了设备号,关于dev_t和主设备号和次设备号的内容已经在主设备号和次设备号一节描述过。
ü cdev 结构体的另一个重要成员file_operations 定义了字符设备驱动提供给虚拟文件系统的接口函数,前面已做描述。
ü struct list_head list用于把一个cdev的结构体添加到Linux内核管理字符设备的链表中。
5.字符驱动的注册和注销
Linux 2.6 内核提供了一组函数用于操作cdev 结构体:
void cdev_init( struct cdev *, struc t file_operations *);
struct cdev *cdev_alloc(void) ;
int cdev_add(struct cdev *, dev_t, unsigned) ;
void cdev_del(struct cdev *);
cdev_init()函数用于初始化cdev的成员,并建立cdev和file_operations之间的连接。
cdev_alloc()函数用于动态申请一个cdev 内存。
cdev_add()函数和cdev_del()函数分别向系统添加和删除一个cdev,完成字符设备的注册和注销。对cdev_add()的调用通常发生在字符设备驱动模块加载函数中,而对cdev_del()函数的调用则通常发生在字符设备驱动模块卸载函数中。
下面是一个最简单的字符驱动的实例(ctest.c),将会引用这些函数想内核中注册一个字符驱动:
-----------------------------------------------------------------------
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/moduleparam.h>
#include <linux/kdev_t.h>
#include <linux/types.h>
#include <linux/fs.h>
#include <linux/cdev.h> //#支持cdev,以及对应的操作的头文件
MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("stephanxu@eetek");
MODULE_DESCRIPTION("the first kernel module");;
static dev_t devnum;
static struct cdev ctest; //#(1)定义全局变量ctest,代表一个字符设备。
static struct file_operations ctest_fops={ //(2)定义对这个字符设备支持的操作
.owner = THIS_MODULE,
.open = ctest_open,
.release = ctest_release,
};
static int __init hello_init(void)
{
int ret=0;
ret = alloc_chrdev_region(&devnum,0,1,"dynamic ctest");
if(ret<0){
goto out1;
}
printk("register char device number successfully,
major=%d,minor=%d/n",MAJOR(devnum),MINOR(devnum));
cdev_init(&ctest,ctest_fops); //(3)初始化字符设备ctest,将于对应的操作绑定
ret=cdev_add(&ctest,devnum,1); //(4)将设备加入到内核中,与devnum建立关系
if(ret<0){
printk(“add the ctest device into kernel failed/n”);
goto out2;
}else{
return 0;
}
out2: //为何可以使用goto语句?
Unregister_chrdev_region(devnum,1);
out1:
return ret;
}
static void __exit hello_exit(void)
{
cdev_del(&ctest); //(5)将ctest这个设备从内核中删除
unregister_chrdev_region(devnum,1);
}
module_init(hello_init);
module_exit(hello_exit);
-----------------------------------------------------------------------
其Makefile只需将hello模块的Makefile的obj-m :=hello.o 改称obj-m := ctest。
对应的代码解释如下:
(1) static struct cdev ctest
这一行代码的目的是定义一个struct cdev类型的全局变量:ctest, 我们在程序中使用这个变量来代表一个字符设备,到现在为止,这个ctest到底代表那个设备我们还不得而知,所以,它仅仅是操作系统虚拟出来的,用来代表一个字符设备。
(2) static struct file_operations ctest_fops
这行代码用来定义对ctest这个设备,支持的文件操作有哪些;在前面我们已经了解了”一切皆是文件”的思想,在这里就是定义对这个设备文件,可以支持哪一些操作,在本例中,我们仅仅支持open和close(release也就是close的意思)的操作。
(3) cdev_init(&ctest,ctest_fops);
这一行代码的目的,就是将ctest初始化,并且与ctest_fops建立绑定关系。(1),(2)两句仅仅只是定义了两个变量而已,他们之间并没有对应关系,而这一行的目的就是在ctest与ctest_fops之间建立绑定关系。以后当我们操作ctest的时候,将会调用fops中的对应的函数。
(4) ret=cdev_add(&ctest,devnum,1);
这一行代码的作用是将ctest添加到内核中,这样内核就知道了ctest这个设备的存在,并且将ctest与设备号之间建立一一对应的关系。这样,内核不仅知道这个设备,而且知道这个设备的设备号是多少。
(5) cdev_del(&ctest);
一旦模块被删除,或者说这个驱动程序将不再需要的时候,调用这一行代码就可以将先前已添加到内核中的字符驱动从内核中删除。
【注】:我们仔细研究完整段代码,我们会发现,这个ctest虽然代表某个字符设备,但是我们并没有发现它与某个具体的硬件设备扯上关系,那这能算是一个字符设备驱动吗?通过这个例子,我们现在也就不难理解cdev实际上只是有一个字符驱动的表示,而且也该知道Linux中的很多虚拟设备是怎么得来的,比如/dev/null等。
从上面的代码,我们得出几个知识点的关联:
ü ctest与设备号的一一对应的关联,也就是说,由设备号可以查找到对应的设备,由设备可以查找对应的设备号,而这些都是由内核管理。
ü ctest与file_operations的关联,就是使用ctest可以查找到对应于它的操作是什么。
6.文件操作接口
在上例中,我们仅仅实现了最简单的file_operations的操作,它是与任何设备无关的。但是一个驱动程序更多的时候应该操作一个硬件单元,比如说这是一个串口的驱动程序的话,那么open的时候就应该打开串口设备,read就应该从串口设备读取数据,这样说来: 只有在实现file_operations的操作的时候,才知道我们具体的是针对那个设备的操作,如果file_operations不操作任何具体的硬件的话,那么这个字符驱动程序将不会与一个真正的物理硬件相对应。上面的代码,也就是一般意义上字符驱动程序的框架。
我们在这里还是用上述的虚拟设备,实现file_operations的另外两个重要的接口:read,write。
read是希望当我们可以从这个设备读取数据,我们在这里做一个简单的定义:返回”hello,user”.表示内核对用户程序的应答。同理,我们让write操作向这个设备发送一条数据,表示对这个设备的问候。那么我们如何实现呢?
-----------------------------------------------------------------------
#include <asm/uaccess.h>
//. . . . ctest_release及其以上不变
static ssize_t ctest_read(struct file *file,char __user *buf,
size_t count,loff_t *offset)
{
int cnt;
char *greeting = “hello,user”;
if(count>(strlen(greeting)+1))
cnt = strlen(greeting)+1;
else
cnt = count;
if(!copy_to_user(buf,greeting,cnt)) //(1)为何?
return cnt;
else
return -1;
}
static ssize_t ctest_write(struct file *file,char __user *buf,
size_t count,loff_t *offst)
{
char ctestbuf[256];
int cnt;
memset(ctestbuf,0,256);
if(count<256)
cnt = count;
else
cnt = 255;
if(!copy_from_user(ctestbuf,buf,cnt)){ //(2)???
printk(“%s/n”,ctestbuf);
return cnt;
}else{
return -1;
}
}
Static struct file_operations ctest_fops = {
.owner = THIS_MODULE,
.open = ctest_open,
.release = ctest_release,
.read = ctest_read,
.write = ctest_write
};
//下同. . . . . .
-----------------------------------------------------------------------
上面的代码实现了一个简单的read,write的功能,当read的时候,从驱动程序返回一段数据给用户程序,write的时候,从用户程序接受一段数据。如果是一个真正的设备驱动的话,就可以对设备进行相应操作,从设备读取数据,返回给用户,从用接受数据,写向设备。
read 和 write 方法的 buff 参数是用户空间指针. 因此, 它不能被内核代码直接解引用. 这个限制有几个理由:
ü 依赖于你的驱动运行的体系, 以及内核被如何配置的, 用户空间指针当运行于内核模式可能根本是无效的. 可能没有那个地址的映射, 或者它可能指向一些其他的随机数据.
ü 就算这个指针在内核空间是同样的东西, 用户空间内存是分页的, 在做系统调用时这个内存可能没有在 RAM 中. 试图直接引用用户空间内存可能产生一个页面错, 这是内核代码不允许做的事情. 结果可能是一个"oops", 导致进行系统调用的进程死亡.
ü 置疑中的指针由一个用户程序提供, 它可能是错误的或者恶意的. 如果你的驱动盲目地解引用一个用户提供的指针, 它提供了一个打开的门路使用户空间程序存取或覆盖系统任何地方的内存. 如果你不想负责你的用户的系统的安全危险, 你就不能直接解引用用户空间指针.
显然, 你的驱动必须能够存取用户空间缓存以完成它的工作. 但是, 为安全起见这个存取必须使用特殊的, 内核提供的函数.几个这样的函数定义于 <asm/uaccess.h>,
读写代码需要拷贝一整段数据到或者从用户地址空间. 这个能力由下列内核函数提供, 它们拷贝一个任意的字节数组, 并且位于大部分读写实现的核心中.
unsigned long copy_to_user(void __user *to,const void *from,
unsigned long count);
unsigned long copy_from_user(void *to,const void __user *from,
unsigned long count);
尽管这些函数表现象正常的 memcpy 函数, 必须加一点小心在从内核代码中存取用户空间.
这两个函数的角色不限于拷贝数据到和从用户空间: 它们还检查用户空间指针是否有效. 如果指针无效, 不进行拷贝; 如果在拷贝中遇到一个无效地址, 另一方面, 只拷贝部分数据. 在两种情况下, 返回值是还要拷贝的数据量.
要检查用户空间指针, 你可以调用 __copy_to_user 和 __copy_from_user 来代替. 这是有用处的, 例如, 如果你知道你已经检查了这些参数. 但是, 要小心; 事实上, 如果你不检查你传递给这些函数的用户空间指针, 那么你可能造成内核崩溃和/或安全漏洞.
【注】我们这里没有讨论
loff_t *pos 的内容,实际的驱动中应该要考虑偏移量的问题,大家可以参考 LDD 中的介绍。7.使用新设备
当我们把驱动程序模块加载到内核之后,可以通过查看/proc/devices文件来获得我们动态申请的主设备号,也可以通过在模块加载的时候通过printk打印出其主设备号和次设备号。如果采用的是静态的设备号,这将更简单的。
在使用新的驱动程序之前,我们必须要把硬件设备抽象成为文件,这也就是我们前面多次提到的”一切皆是文件”的思想。我们在这里使用前面所讲的mknod来创建一个设备节点,如果想让我们的应用程序访问节点的时候,会调用前面的驱动程序,必须把设备节点名和设备号关联起来:
#mknod /dev/ctest c major minor
这里的major,minor用驱动程序打印出来的major和minor的值代替。这样子我们就建立了一个设备节点/dev/ctest,这个名称,与我们在申请设备号的时候给的name没什么关系。
下面是测试的应用程序的代码:
-----------------------------------------------------------------------
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#define DEVNAME “/dev/ctest”
int main()
{
int fd = fopen(DEVNAME,O_RDWR);
char buf[16]={0};
if(fd<0){
printf(“open %s error/n”,DEVNAME);
exit(1);
}
read(fd,buf,16);
printf(“%s/n”,buf);
getchar();
memset(buf,0,sizeof(buf));
strcpy(buf,”hello,kernel”)
write(fd,buf,strlen(buf));
close(fd);
return 0;
}
-----------------------------------------------------------------------
编译并运行,这样我们可以看到main程序可以从/dev/ctest读取内容hello,user
同理,我们通过
dmesg 命令可以查看从 main 写到 /dev/ctest 的数据 ”hello,kernel”.那这里的操作流程我们解释一下:
ü 在main程序里操作的都是/dev/ctest的设备节点,比如打开,关闭,读写操作。
ü 而这个节点在创建的时候和字符设备的主设备号次设备号绑定在一起。可以设备节点得到设备号。
ü 当我们把字符设备添加的内核的时候我们将它与主次设备号一一对应,这样由设备号就可以找到对应的cdev的结构。
ü 在对cdev初始化的时候将他与fops绑定在一起,这样就可以找到对应的操作。
所以你仔细观察,我们对/dev/ctest执行的几个操作都是在fops中定义的,open调用file operations中对应的open,close调用file_operations中的release,一次类推。
这些关系,可由下图总结之:
(1) 完成编写一个完整的字符驱动程序结构以及Makefile
(2) 根据模块加载的设备号结果,建立设备节点
(3) 编写测试程序,要求能够对设备进行读写操作。
(4) 用统一的Makefile进行driver和application的管理
(5) 要求在ARM开发板上进行测试,开发板使用NFS作为根文件系统。