写Linux驱动简明教程
一份关于编写Linux设备驱动程序的快速容易介绍文档
作者 Xavier Calbet 2006.04.26
目录
1. 1 您需要
2. 2 用户空间和内核空间
3. 3 用户空间和内核空间的连接函数
4. 4 内核空间和硬件资源的交互函数
5. 5 第一个驱动:在用户空间加载和移除驱动程序
6. 6 hello,world驱动:在内核空间加载和移除驱动
7. 7 memory驱动
1. 7.1 完整的驱动memory:驱动初始化部分
2. 7.2 memory驱动:设备和文件的连接
3. 7.3 memory驱动:移除驱动
4. 7.4 memory驱动:象文件一样打开设备
5. 7.5 memory驱动:象文件一样关闭设备
6. 7.6 memory驱动:读设备
7. 7.7 memory驱动:写设备
8. 7.8 完整的memory驱动
8. 8 真实的并口驱动:并口介绍
1. 8.1 并口驱动:初始化模块
2. 8.2 并口驱动:移除模块
3. 8.3 并口驱动:读设备
4. 8.4 并口驱动:写设备
5. 8.5 完整的并口驱动
1. 8.5.1 初始化部分
2. 8.5.2 初始化模块
3. 8.5.3 移除模块
4. 8.5.4 打开设备
5. 8.5.5 关闭设备
6. 8.5.6 读设备
7. 8.5.7 写设备
6. 8.6 使用LED来测试并口驱动
7. 8.7 最后的应用:跑马灯
9. 9 结论
您需要
为了开发linux内核驱动程序,掌握以下知识是必须的:
? C程序开发。精通C语言编程是必须的,掌握指针,位操作等。
? 处理器编程。熟悉处理器是如何工作的:地址分配、中断等。这些必须为编译人员所掌握
linux下有几种不同的设备。为了简洁,这个教程只涉及可模块加载的字符型设备。内核版本为2.6.x(我所使用的是内核2.6.8,Debian Sarge即现在的Debian Stable)。[Handson]
用户空间和内核空间
当你编写设备程序时,区分用户空间和内核空间是必须的。
? 内核空间。Linux以简单有效的方式管理机器的硬件,提供给用户一个简单、统一的编程接口。同理,内核和设备驱动,是硬件和用户之间的桥梁或接口。组成内核的任何子程序或者函数(例如:模块和设备驱动)被认为是内核空间的一部分。
? 用户空间。终端程序,例如UNIX的shell或者其他基于应用的GUI(例如kpresenter),是用户空间的一部分。显然,这些应用程序是和硬件交互的。但是,它们不是以直接的方式,而是通过内核。
方式如下:
用户空间(应用程序)<-->内核空间(模块或驱动)<-->硬件资源
用户空间和内核空间的连接函数
在用户空间中,内核提供一些子程序或函数,它们允许终端应用程序和硬件资料打交道。通常,在UNIX和Linux系统中,这些会话通过函数或子程序来执行读、写文件的功能。这么做的原因是UNIX把设备也看成文件。
另一方面,linux的内核空间也提供一些函数或子程序来进行和硬件的直接低层交互,允许从内核空间到用户空间的信息交互。
通常,对于用户空间的每一个函数(允许使用设备或文件),在内核空间存在一个“等价物”(允许内核空间和用户空间的信息交互)。如表1所示。现在是空的。当不同的设备驱动被调用的时候,它将会被填上。
表1 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块
打开设备
读设备
写设备
关闭设备
移除模块
内核空间和硬件资源的交互函数
在内核空间中,同样存在着一些函数,用来控制设备或者在内核和硬件之间交互信息。表2对此进行了说明。
表2 设备驱动事件和它们在内核空间、硬件资源的相关交互
事件 内核函数
读数据
写数据
第一个驱动:在用户空间加载和移除驱动程序
现在,我来介绍如何开发第一个linux设备驱动,它将作为内核的一个模块。
下面我们写一个文件,nothing.c
源码:
#include <linux/module.h>
MODULE_LICENSE("Dual BSD/GPL");
随着内核2.6.x的发布,编译模块变得稍微复杂了。首先,你需要一个完整的、可编译的内核源码树。如果你使用Debian Sarge操作系统,可以按照附录B来操作。接下来,我们假定使用的内核版本为2.6.8。
接下来,你需要生成一个makefile文件。内容如下:
Makefile1 源码:
obj-m:=nothing.o
不同于先前的内核版本,此处我们使用的内核必须就是模块将要加载和使用的内核。接下来,我们输入:
$ make -C /usr/src/kernel-source-2.6.8 M=pwd modules
(这里需要使用绝对路径方可编译成功)
这个非常简单的模块属于内核空间,并且一旦当其加载的时候就属于内核的一部分。在用户空间,可以使用root用户来加载模块。
# insmod nothing.ko
这个insmod命令来完成内核模块的加载,当然这个模块没有任何作用。
我们可以通过查看已经按照的模块来确定模块是否安装正确。
#lsmod
最后,模块可以通过下面命令从内核中移除。
#rmmod nothing
为了证明模块不再属于内核,可以再次运行lsmod命令。
综合上述,可以查看表3.
表3 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块 insmod
打开设备
读设备
写设备
关闭设备
移除模块 rmmod
hello,world驱动:在内核空间加载和移除驱动
当驱动加载到内核中时,一些初始化任务将会被执行,例如重启设备、保存RAM、保存中断和I/O口。
在内核空间,这些任务的执行,通过两个函数来执行:module_init和module_exit;它们响应于用户空间的insmod和rmmod命令。用户空间的命令是使用内核空间的module_init和module_exit函数。[Handson]
下面我们来看一个hello world的典型程序,hello.c
hello.c 源码:
#include<linux/init.h>
#include<linux/module.h>
#include<linux/kernel.h>
MODULE_LICENSE("Dual BSD/GPL");
static int hello_init(void) {
printk("<1> hello world!/n");
return 0;
}
static void hello_exit(void) {
printk("<1> bye,curel world!/n");
}
module_init(hello_init);
module_exit(hello_exit);
实际函数hello_init,hello_exit可以是任意名字。然而,为了它们能够响应加载和移除函数,它们必须得作为参数传递给module_init和module_exit。
printk和printf非常相近,但其只用于内核空间。<1>表明了这个消息是高优先级(数字越低,优先级越高)。这样,你可以在系统的控制台得到这个消息,另外在系统的日志文件中也可以看到。
这个模块可以使用和前面相同的命令进行编译,首先添加名字到Makefile。
Makefile 2 源码:
obj-m:=nothing.o hello.o
接下来,我不再书写makefile文件,读者可以自行练习。附录A中也可查询。
当模块被加载或移除时,printk后面的信息将会在系统控制台显示出来。如果没有显示,你可以使用dmesg命令或者查看系统日志文件(/var/log/syslog)。
表4 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块 insmod module_int()
打开设备
读设备
写设备
关闭设备
移除模块 rmmod module_exit()
memory驱动
完整的驱动memory:驱动初始化部分
接下来,我们建立完整的设备驱动:memory.c。这个驱动允许一个字符读取和写入。这个设备,作为一个完成的驱动,提供了一个例子。它也是容易实现的,因为它并不和真实的硬件打交道。[Handson]
为了开发这个驱动,很多新的库将被包含。
memory initial 源文件:
#include<linux/init.h>
#include<linux/config.h>
//这个头文件在我的系统里没有找到,注释即可
#include<linux/module.h>
#include<linux/kernel.h> //printk()
#include<linux/slab.h> //kmalloc()
#include<linux/fs.h> //everything
#include<linux/errno.h> //error codes
#include<linux/types.h> //size_t
#include<linux/proc_fs.h>
#include<linux/fcntl.h> //O_ACCMODE
#include<asm/system.h> //cli() *_flags
#include<asm/uaccess.h> //copy_from/to_user
MODULE_LICENSE("Dual BSD/GPL");
/*memory.c 函数声明*/
int memory_open(struct inode * inode, struct file * filp);
int memory_release(struct inode * inode, struct file * filp);
ssize_t memory_read(struct file * filp, char *buf, size_t count, loff_t *f_pos);
ssize_t memory_writestruct file * filp, char *buf, size_t count, loff_t *f_pos);
void memory_exit(void);
int memory_init(void);
/*声明普通文件的结构*/
/*访问函数*/
struct file_operations memory_fops={
read:memory_read,
write:memory_write,
open:memory_open,
release:memory_release,
};
/*声明init和exit函数*/
module_init(memory_init);
module_exit(memory_exit);
/*驱动的全局变量*/
/*主设备号码*/
int memory_major=60;
/*存储数据的缓冲区*/
char * memory_buffer;
库文件之后,接下来是函数声明。用来操作文件的函数在file_operations结构体中进行声明。下面是初始化、退出函数--用来加载和移除模块。最后,是全局变量的声明:一个是主设备号码,另一个是指向存储区的指针memory_buffer,用来存储驱动的数据。
memory驱动:设备和文件的连接
在unix和linux中,访问设备和访问文件是完全相同的。这些设备文件一般在/dev下面的子目录下。
与内核模块建立连接,有两个号码是要用到的:主设备号和从设备号。内核使用前者来连接驱动的文件,后者用于设备的内部使用,本文不讲解。
为了能够访问设备驱动,我们必须建立一个文件,如下。
#mknod /dev/memory c 60 0
c说明是一个字符设备被创建,60是主设备号,0是从设备号。
在驱动内部,为了能够和内核空间/dev 下的相关文件建立连接。函数register_chrdev被使用,其原型如下:
int register_chrdev(unsigned int major, const char * name, struct file_operations *fops);
其中,name为模块名称,file_operations结构是各个调用的入口点。当加载函数后,设备注册会被使用。
memory init module 源码:
int memory_init(void) {
int result;
/*注册设备*/
result=register_chrdev(memory_major, "memory", &memory_fops );
if(result<0) {
printk("<1> memory:cannot obtain major number %d /n",memory_major);
return result;
}
/*为缓冲区分配内存*/
memory_buffer=kmalloc(1, GFP_KERNEL);
if(!memory_buffer) {
result=-ENOMEM;
goto fail;
}
memset(memory_buffer, 0, 1);
printk("<1>Inserting memory module/n");
return 0;
fail:
memory_exit();
return result;
}
kmalloc函数用于在内核空间进行设备驱动的内存缓冲区分配,其和malloc非常相近。最后,如果注册主设备号码或分配内存时失败,模块将相应动作。
memory驱动:移除驱动
在memory_exit函数中,如果要移除模块,unregister_chrdev函数需要被调用。它将会向内核释放主设备号。
memory exit module 源码:
void memory_exit(void) {
/*释放主设备号*/
unregister_chrdev(memory_major,"memory");
/*释放缓冲区*/
if(memory_buffer) {
kfree(memory_buffer);
}
printk("<1>Removing memory module/n");
}
为了移出驱动时,能够得到一个干净的内核,在该函数中,buffer缓冲区也要被释放。
memory驱动:象文件一样打开设备
在内核空间中,和用户空间fopen函数相对应的,是函数open,其包含于调用register_chrdev时的file_operations结构体中。在本例中,就是memory_open函数。它包含如下参数:一个inode结构体(以主设备号和从设备号的方式发送信息到内核),一个file结构体(对应于作为文件可以进行的不同操作)。这里不作深入探讨。
当文件被打开后,初始化设备变量或者重启设备是必须的。在本例中,不涉及这些操作。
memory open源码:
int memory_open(struct inode * inode, struct file * flip) {
/*成功*/
return 0;
}
新的函数添加后,再看下表。
表5 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块 insmod module_int()
打开设备 fopen file_operations:open
读设备
写设备
关闭设备
移除模块 rmmod module_exit()
memory驱动:象文件一样关闭设备
在内核空间中,和用户空间fclose函数相对应的,是函数release,其包含于调用register_chrdev时的file_operations结构体中。在本例中,就是memory_release函数。它包含如下参数:一个inode结构体(以主设备号和从设备号的方式发送信息到内核),一个file结构体(对应于作为文件可以进行的不同操作)。
当文件关闭时,通常要释放使用的内存和与打开文件相关的变量。但是,本例不涉及这些操作。
memory release源码:
int memory_release(struct inode * inode, struct file * flip) {
/*成功*/
return 0;
}
表6 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块 insmod module_int()
打开设备 fopen file_operations:open
读设备
写设备
关闭设备 fclose file_operations:close
移除模块 rmmod module_exit()
memory驱动:读设备
在内核空间中,和用户空间fread函数相对应的,是函数read,其包含于调用register_chrdev时的file_operations结构体中。在本例中,就是memory_read函数。它包含参数如下:
一个文件类型结构,一个缓冲(用户空间fread将要读取的),一个传输字节数的计数器(count,其含有和用户空间fread相同的值),最后,是一个从读取文件的地址。
在这个简单例子中,memory_read函数调用copy_to_user从设备缓冲区中传输一个单子节给用户空间。
memory read源码:
ssize_t memory_read(struct file *flip, char *buf, size_t count, loff_t *f_pos) {
/*传输字节到用户空间*/
copy_to_user(buf,memory_buffer,1);
/*调整读取位置*/
if(*f_pos==0) {
*f_pos+=1;
return 1;
}
else return 0;
}
文件中读取位置f_pos也是被改变的。如果这个位置是文件的起始位置,它的增加步长为1,返回值为1。如果没有从文件的起始位置读取,将返回0值。
表7 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块 insmod module_int()
打开设备 fopen file_operations:open
读设备 fread file_operations:read
写设备
关闭设备 fclose file_operations:close
移除模块 rmmod module_exit()
memory驱动:写设备
在内核空间中,和用户空间fwrite函数相对应的,是函数write,其包含于调用register_chrdev时的file_operations结构体中。在本例中,就是memory_read函数。它包含参数如下:
一个文件类型结构,一个缓冲(用户空间fwrite将要写入的),一个传输字节数的计数器(count,其含有和用户空间fwrite相同的值),最后,f_pos是一个从开始写入文件的起始地址。
memory write源码:
ssize_t memory_write(struct file *flip, char *buf, size_t count, loff_t *f_pos) {
char *tmp;
tmp=buf+count-1;
copy_from_user(memory_buffer,tmp,1);
return 1;
}
其中,copy_from_user从用户空间传输数据到内核空间。
表8 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 用户函数 内核函数
加载模块 insmod module_int()
打开设备 fopen file_operations:open
读设备 fread file_operations:read
写设备 fwrite file_operations:write
关闭设备 fclose file_operations:close
移除模块 rmmod module_exit()
完整的memory驱动
将以前的所有代码合并起来,完整的驱动就完成了。
memory.c 源码:
<memory initial>
<memory init module>
<memory exit module>
<memory open>
<memory release>
<memory read>
<memory write>
通过insmod命令进行加载。
#insmod memory.ko
#chmod 666 /dev/memory
你可以写一个字符串给/dev/memory,其最后一个字母将会保存其中。你可以如下执行:
$echo -n abcdef > /dev/memory
(这里不知如何测试)
通过如下命令来检查是否正常工作。
$cat /dev/memory
真实的并口驱动:并口介绍
现在我们来看一个驱动实例,它是从以前所做的一个真实设备上的驱动改动而来的。它使用并口,叫它并口驱动吧。
并口允许输入输出数字信息,含有25针的D-25母头。从CPU的视角来看,它使用了3字节内存,其基址是0x378。在这个实验中,我们使用第一个地址,其全部是数字输出。
基址与外围连接针脚如图2所示。
图二 并口第一个字节与外围D-25母头连接情况
并口驱动:初始化模块
先前的memory_init函数需要改动一下:改变RAM内存分配为并口预留地址(0x378)。此处需要check_region函数,它用来检查内存区域是否可用;还需要request_region函数,用来为设备保存内存地址。它们都有如下参数:使用的内存基址和长度。request_region函数也接收非模块的字符串。
parlelport init源码:
/*注册端口*/
port=check_region(0x378, 1);
if(port) {
printk("<1>parlelpor:cannot reserve 0x378 /n");
result=port;
goto fail;
}
request_region(0x378, 1, "parlelport");
并口驱动:移除模块
和memory模块相似,但是它不释放并口预留地址。由函数release_region来实现,这个函数和check_region有一样的参数。
parlelport init源码:
/*释放端口*/
if(!port) {
realease_region(0x378, 1);
}
并口驱动:读设备
在本例中,为了传输信息到用户空间,一个读取真实设备的动作需要添加进来。inb函数可以完成这个功能,它的参数为并口地址,其返回读取的内容。
parlelport 输入口源码:
/*读端口*/
parlelport_buffer=inb(0x378);
表9(等价于表2)展示了这个新函数。
表9 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 内核函数
读数据 inb
写数据
并口驱动:写设备
同样,需要添加写设备函数outb()来传输信息到用户空间,其参数为:写到端口的内容和它的地址。
parlelport 输出口源码:
/*写端口*/
outb(parlelport_buffer, 0x378);
表10 设备驱动事件和它们在内核空间、用户空间的相关交互
事件 内核函数
读数据 inb
写数据 outb
完整的并口驱动
可以替换memory代码中memory为parlelport。最后结果如下:
parlelport.c源码:
<parlelport initial>
<parlelport init module>
<parlelport exit module>
<parlelport open>
<parlelport release>
<parlelport read>
<parlelport write>
初始化部分
在初始化部分,用到一个不同的主设备号(61),当然,全局变量memory_buffer也变成端口。还要添加两个库ioport.h和io.h。
parlelport initial 源文件:
#include<linux/init.h>
#include<linux/config.h>
#include<linux/module.h>
#include<linux/kernel.h> //printk()
#include<linux/slab.h> //kmalloc()
#include<linux/fs.h> //everything
#include<linux/errno.h> //error codes
#include<linux/types.h> //size_t
#include<linux/proc_fs.h>
#include<linux/fcntl.h> //O_ACCMODE
#include<linux/ioport.h>
#include<asm/system.h> //cli() *_flags
#include<asm/uaccess.h> //copy_from/to_user
#include<asm/io.h> //*inb ,outb
MODULE_LICENSE("Dual BSD/GPL");
/*parlelport.c 函数声明*/
int parlelport_open(struct inode * inode, struct file * filp);
int parlelport_release(struct inode * inode, struct file * filp);
ssize_t parlelport_read(struct file * filp, char *buf, size_t count, loff_t *f_pos);
ssize_t parlelport_write(struct file * filp, char *buf, size_t count, loff_t *f_pos);
void parlelport_exit(void);
int parlelport_init(void);
/*声明普通文件的结构*/
/*访问函数*/
struct file_operations parlelport_fops={
read:parlelport_read,
write:parlelport_write,
open:parlelport_open,
release:parlelport_release,
};
/*声明init和exit函数*/
module_init(parlelport_init);
module_exit(parlelport_exit);
/*驱动的全局变量*/
/*主设备号码*/
int parlelport_major=61;
/*存储数据的缓冲区*/
/*并口预留*/
int port;
初始化模块
在这个模块初始化部分,我将会重提并口的内存预留问题。
parlelport init module 源码:
int parlelport_init(void) {
int result;
/*注册设备*/
result=register_chrdev(parlelport_major, "parlelport", &parlelport_fops );
if(result<0) {
printk("<1> parlelport:cannot obtain major number %d /n",parlelport_major);
return result;
}
/*更改的并口初始化模块*/
printk("<1>Inserting parlelport module/n");
return 0;
fail:
parlelport_exit();
return result;
}
移除模块
如前面所讲。
parlelport exit module 源码:
void parlelport_exit(void) {
/*释放主设备号*/
unregister_chrdev(parlelport_major,"parlelport");
/*更改的并口移除模块*/
printk("<1>Removing memory module/n");
}
打开设备
这个和memory相同。
parlelport open源码:
int parlelport_open(struct inode * inode, struct file * flip) {
/*成功*/
return 0;
}
关闭设备
和memory相同。
parlelport release源码:
int parlelport_release(struct inode * inode, struct file * flip) {
/*成功*/
return 0;
}
读设备
按照前面提到的方法更改一小部分。
parlelport read源码:
ssize_t parlelport_read(struct file *flip, char *buf, size_t count, loff_t *f_pos) {
/*读取设备缓冲区*/
char parlelport_buffer;
/*并口输入口*/
parlelport_buffer=inb(0x378);
/*传输字节到用户空间*/
copy_to_user(buf,parlelport_buffer,1);
/*调整读取位置*/
if(*f_pos==0) {
*f_pos+=1;
return 1;
}
else return 0;
}
写设备
除了写入设备之外,其他和memory是一样的。
parlelport write源码:
ssize_t parlelport_write(struct file *flip, char *buf, size_t count, loff_t *f_pos) {
char *tmp;
/*写入设备的缓冲区*/
char parlelport_buffer;
tmp=buf+count-1;
copy_from_user(&parlelport_buffer,tmp,1);
/*<并口输出口>*/
outb(parlelport_buffer, 0x378);
return 1;
}
使用LED来测试并口驱动
在这一节中,我们通过一块电路板的LED来测试驱动是否正常工作。
警告:连接并口有可能危害您的计算机。当连接硬件时确保计算机是关闭状态。责任自负。
电路图如图3所示。
图三 监视并口的LED阵列
首先,确保电路板连接正确。关闭计算机,将电路板连接并口。打开主机,和并口相关的驱动需要移除(例如,lp,parport等)。Debian Sarge系统的hotplug模块要去掉。如果/dev/parlelport设备文件不存在,使用root建立。
#mknod /dev/parlelport c 61 0
改变属性对任何用户可读写
#chmod 666 /dev/parlelport
现在可以安装模块了。可以查看0x378 I/O口是否保留着。
#cat /proc/ioports
通过如下命令点亮led。
$ echo -n A > /dev/parlelport
这应该可以点亮第0个和第6个led,其他是暗的。
可以通过如下命令来查看并口状态
$cat /dev/parlelport
最后的应用:跑马灯
在用户空间中,写一个程序,每次拉高/dev/parlelport一个位,可以实现跑马灯效果。
lights.c 源码:
#include<stdio.h>
#include<unistd.h>
int main(){
unsigned char byte,dummy;
FILE * PARLELPORT;
/*开并口*/
PARLELPORT=fopen("/dev/parlelport","w");
/*从i/o文件中移除buffer*/
setvbuf( PARLELPORT,&dummy,_IONBF,1);
/*初始化变量*/
byte=1;
/*死循环*/
while(1) {
/*写并口点亮LED*/
printf("byte value is %d/n",byte);
fwrite(&byte,1,1 PARLELPORT);
sleep(1);
/*更新byte数据*/
byte<<=1;
if(byte==0) byte=1;
}
fclose( PARLELPORT);
}
编译:
$ gcc -o lights lights.c
执行:
./lights