字符设备驱动程序
嵌入式Linux设备驱动开发之字符设备驱动程序
《嵌入式Linux应用完全开发手册》第4篇第19章总结归纳
本章目标
- 了解Linux系统中驱动程序的地位和作用
- 了解驱动程序开发的一般流程
- 掌握简单的字符设备驱动程序的开发方法
Linux驱动程序开发概述
应用程序、库、内核、驱动程序的关系
从上到下,一个软件系统可以分为:应用程序、库、操作系统、驱动程序。开发人员可以专注于自己熟悉的部分,对于相邻层,只需要了解它的接口,无需关注它的实现细节。
以点亮一个LED为例,这4层软件的协作关系如下:
- 应用程序使用库提供的open函数打开代表LED的设备文件。
- 库根据open函数传入的参数执行“swi”指令,这条指令会引起CPU异常,进入内核。
- 内核的异常处理函数根据这些参数找到相应的驱动程序,返回一个句柄给库,进而返回给应用程序。
- 应用程序得到文件句柄后,使用库提供的write或ioctl函数发出控制命令。
- 库根据write或ioctl函数传入的参数执行“swi”指令,这条指令会引起CPU异常,进入内核。
- 内核的异常处理函数根据这些参数调用驱动程序的相关函数,点亮LED。
库(例如glic)给应用程序提供的open、read、write、ioctl、mmap函数等接口函数称为系统调用,它们都是设置好相关寄存器后,执行某条指令引发异常进入内核。对于ARM架构的CPU,这条指令为swi。除系统调用接口之外,库还提供其他函数,比如字符串处理函数,输入输出函数,数学库,还有应用程序的启动代码。
在异常处理函数中,内核会根据传入的参数执行各种操作,比如根据设备文件找到对应的驱动程序,调用驱动程序的相关函数等。
一般来说,当应用程序调用open、read、write、ioctl、mmap等函数后,将会使用驱动程序中的open、read、write、ioctl、mmap函数来进行相关操作。比如初始化、读、写等。
实际上,内核和驱动程序之间并没有界限,因为驱动程序最终要编进内核去的:通过静态链接或动态加载。
从上面操作LED的过程可以知道,与应用程序不同,驱动程序从不主动进行,它是被动的:根据应用程序的要求进行初始化,根据应用程序的要求进行读写。驱动程序加载进内核时,只是告诉内核“我这这里,我能做这些工作”,至于“工作”何时开始,取决于应用程序。当然,这不是绝对的,比如用户完全可以写一个由系统时钟触发的驱动程序,让它自己点亮LED。
在Linux系统中,应用程序运行于“用户空间”,拥有MMU的系统能够限制应用程序的权限(比如将它限制在某一块内存中),这可以避免应用程序的错误使整个操作系统崩溃。而驱动程序运行于内核空间,它是系统信任的一部分,驱动程序的错误可能导致整个操作系统崩溃。
Linux 驱动程序的分类和开发步骤
Linux 驱动程序分类
Linux的外设可以分为3类:字符设备、块设备和网络接口。
字符设备是能够像字节流一样被访问的设备,就是说对它的读写是以字节为单位的。比如串口在进行收发数据时就是一个一个字节进行的,我们可以在驱动程序内部使用缓冲区来存放数据以提高效率,但是串口本身对这并没有要求。字符设备的驱动程序中实现了open、close、read、write等系统调用,应用程序可以通过设备文件(比如/dev/ttySAC0等)来访问字符设备。
块设备上的数据以块的形式存放,比如NAND Flash上的数据就是以页为单位存放的。块设备驱动程序向用户层提供的接口与字符设备一样,应用程序也可以通过相应的设备文件(/dev/mtdblock0、/dev/hda1)来调用open、close、read、write等系统调用,与块设备传送任意字节的数据。对用户而言,字符设备和块设备的访问方式没有差别。块设备驱动程序的特别之处如下:
- 操作硬件的接口实现方式不一样。
块设备驱动程序先将用户发来的数据组织成块,在写入设备;或从设备中读出若干块数据,再从中挑出用户需要的。 - 数据块上的数据可以有一定的格式
通常在块设备上按照一定的格式存放数据,不同文件系统类型就是用来定义这些格式的。内核中,文件系统的层次位于块设备块驱动程序上面,这意味着块设备驱动程序除了向用户层提供像字符设备一样的接口之外,还要向内核其他部件提供一些接口,这些接口用户是看不到的。这些接口使得可以在块设备上存放文件系统,挂接块设备。
网络设备同时具有字符设备、块设备的部分特点,无法将它归入两类中:如果说它是字符设备,它的输入输出确是有结构的、成块的(报文、包、帧);如果说它是块设备,它的块又不是固定大小的,大到几百几千字节,小到几字节。UNIX式的操作系统访问网络接口的方法是给它们分配一个唯一的名字(eth0),但这个名字在文件系统中不存在对应的文件节点。应用程序、内核和网络驱动程序之间的通信完全不同于字符设备、块设备。库、内核还提供了一套和数据包传输相关的函数,而不是open、read、write等。
Linux 驱动程序开发步骤
Linux内核就是由各种驱动组成,内核源码中有大约85%是各种驱动程序的代码。内核中驱动程序种类齐全,可以在同类型驱动的基础上进行修改以符合具体单板。
编写驱动程序的难点并不是硬件的具体操作,而是弄清楚现有驱动程序的框架,在这个框架中加入这个硬件。比如,x86架构的内核对IDE硬盘的支持非常完善:首先通过BIOS得到硬盘的信息,或者使用默认的I/O地址去枚举硬盘,然后识别分区、挂接文件系统。对于其他架构的内核,只要指定了硬盘的访问地址和中断号,后面的枚举、识别和挂接的过程完全是一样的。也许修改的代码不超过10行,花费精力的地方在于:了解硬盘驱动的框架,找到修改的位置。
编写驱动程序还有很多需要注意的地方,比如:驱动程序可能同时被多个进程使用,这需要考虑并发的问题;尽可能发挥硬件的作用以提高性能。比如在硬盘驱动程序中既可以使用DMA也可以不用,使用DMA时程序比较复杂,但是可以提高效率;处理硬件的各种异常情况,否则出错时可能导致整个系统崩溃。
一般来说,编写一个Linux设备驱动程序的大致流程如下。
- 查看原理图、数据手册,了解设备的操作方法。
- 在内核中找到相近的驱动程序,以它为模板进行开发,有时候需要从零开始。
- 实现驱动程序的初始化:比如向内核注册这个驱动程序,这样应用程序传入文件名时,内核才能找到相应的驱动程序。
- 设计所要实现的操作:open、read、write、close等。
- 实现中断服务(中断并不是每个设备驱动所必需的)。
- 编译该驱动程序到内核中,或者用insmod命令加载。
- 测试驱动程序。
驱动程序的加载和卸载
可以将驱动程序静态编译进内核中,也可以将它作为模块在使用时加载。在配置内核时,如果某个配置项被设为m,就表示它将会被编译成一个模块。在2.6的内核中,模块的扩展名为.ko,可以只用insmod命令加载,使用rmmod命令卸载,使用lsmod命令查看内核中已经加载了哪些模块。
当使用insmode加载模块时,模块的初始化函数被调用,它用来向内核注册驱动程序;当使用rmmod卸载模块时,模块的清除函数被调用。在驱动代码中,这两个函数要么取固定的名字:init_module和cleanup_module,要么使用以下两行来标记它们。
1 | module_init(my_init); |
字符设备驱动程序开发
字符设备驱动程序中重要的数据结构和函数
Linux操作系统将所有的设备看作文件,以操作文件的方式访问设备。应用程序不能直接操作硬件,而是使用统一的接口函数调用硬件驱动程序。这组接口被称为系统调用,在库函数中定义。可以在glibc的fcntl.h、unistd.h、sys/ioctl.h等文件中看到如下定义,这些文件也可以在交叉编译工具链的/usr/local/arm/3.4.1/include
目录下找到。
1 | extern int open(__const char *__file,int __oflag,...)__nonnull((1)); |
对于上述每个系统调用,驱动程序都有一个与之对应的函数。对于字符设备驱动程序,这些函数集合在一个file_operations类型的数据结构中。file_operations结构在Linux内核的include/linux/fs.h
文件中定义。
1 | struct file_operations { |
当应用程序使用open函数打开某个设备时,设备驱动程序的file_operations结构中的open成员就会被调用;当应用程序使用read、write、ioctl等函数读写、控制设备时,驱动程序的file_operations结构中的相应成员(read、write、ioctl等)就会被调用。从这个角度来说,编写字符设备驱动程序就是为具体硬件的file_operations结构编写各个函数(并不需要全部实现file_operations结构中的成员)。
那么,当应用程序通过open、read、write等系统调用访问某个设备文件时,Linux系统怎么知道去调用哪个驱动程序的file_operations结构中的open、read、write等成员呢。
- 设备文件有主次设备号。
设备文件分为字符设备、块设备,比如PC机上的串口属于字符设备,硬盘属于块设备。在PC上运行命令”ls /dev/ttyS0 /dev/hda1 -l”可以看到。“brw-rw—-”中的“b”表示/dev/hda1是个块设备,它的主设备号是3,次设备号是1;“crw-rw—-”中的“c”表示/dev/ttyS0是一个字符设备,它的主设备号是4,次设备号是64。1
2brw-rw---- 1 root disk 3, 1 Jan 30 2003 /dev/hda1
crw-rw---- 1 root uucp 4, 64 Jan 30 2003 /dev/ttyS0 - 模块初始化时,将主设备号与file_operations结构一起向内核注册。
驱动程序有一个初始化函数,在安装驱动程序时会调用它。在初始化函数中,会将驱动程序的file_operations结构连同其主设备号一起向内核进行注册。对于字符设备使用如下函数进行注册。这样,应用程序操作设备文件时,Linux系统就会根据设备文件的类型(字符设备或者块设备)、主设备号找到在内核中注册的file_operations(对于块设备为block_device_operations结构),次设备号供驱动程序自身来分辨它是同类设备中的第几个。1
int register_chrdev(unsigned int major,const char *name,struct file_operations *fops);
编写字符设备驱动的过程大概如下:
①编写驱动程序初始化函数
进行必要的初始化,包括硬件初始化、向内核注册驱动程序等。
②构造file_operations结构中要用到的各个成员函数。
实际的驱动程序当然比上述两个步骤复杂,但是这两个步骤已经可以让我们编写比较简单的驱动程序,比如LED控制。其他比较高级的技术,比如中断、select机制、fsync异步通知机制,将在其他章节的例子中介绍。
LED 驱动程序源码分析
本书以一个简单的LED驱动程序作为例子,让读者初步了解驱动程序的开发。
本书的开发板使用引脚使用引脚GPB5-8外接4个LED,它们的操作方法之前的章节已经做了细致的说明。
- 引脚功能设置为输出。
- 点亮LED,引脚输出0;熄灭LED,引脚输出1。
硬件连接如下图所示:
LED驱动代码分析
模块的初始化函数和卸载函数如下:
1 | /* |
最后两行用来指明装载、卸载模块时所调用的函数。也可以不使用这两行,但是需要将这两个函数的名字改为init_module、cleanup_module。
执行“insmod s3c24xx_leds.ko”命令时就会调用s3c24xx_leds_init
函数,这个函数的核心的代码是register_chrdev
函数。它向内核注册驱动程序:将主设备号LED_MAJOR与file_operations结构s3c24xx_leds_fops联系起来。以后应用程序操作主设备号为LED_MAJOR的设备文件时,比如open、read、write、ioctl,s3c24xx_leds_fops中的相应成员函数将会被调用。但是并不需要实现所有成员函数,用到哪个实现哪个。
执行“rmmod s3c24xx_leds.ko”命令时就会调用s3c24xx_leds_exit
函数,它进而调用unregister_chrdev
函数卸载驱动程序,它的功能与register_chrdev
函数相反。s3c24xx_leds_init
、s3c24xx_leds_exit
函数前的“__init”、“__exit”只有在将驱动程序静态链接进内核时才有意义。前者表示s3c24xx_leds_init
函数的代码被放在“.init.text”
段中,这个段在使用一次之后被释放(可以节省内存);后者表示s3c24xx_leds_exit
函数的代码被放在“.exit.data”
段中,在连接内核时这个段没有使用,因为不可能卸载静态链接的驱动程序。
1 | /* |
宏THIS_MODULE在include/linux/module.h
中定义如下,__this_module变量在编译模块时自动创建,无需关注。
1 |
file_operations类型的s3c24xx_leds_fops结构是驱动中最重要的数据结构,编写字符设备驱动程序的主要工作也是填充其中的各个成员。比如本驱动程序中用到的open、ioctl成员被设为s3c24xx_leds_open
、s3c24xx_leds_fops
函数前者用来初始化LED所用的GPIO引脚,后者用来根据用户传入的参数设置GPIO的输出电平。
s3c24xx_leds_open函数的代码如下:
1 | /* |
在应用程序执行open("/dev/leds",...)
系统调用时,s3c24xx_leds_open
函数将被调用。它用来将LED所涉及的GPIO引脚设为输出功能。不在模块的初始化函数中进行这些设置的原因是:虽然加载了模块,但是这个模块却不一定被用到,就是说这些引脚不一定用于这些用途,它们可能在其他模块中另作他用。所以,在使用时才去设置它,我们把对引的初始化放在open操作中。s3c2410_gpio_cfgpin
函数是内核中实现的,它用来选择引脚的功能。其实现原理是设置GPIO的控制寄存器。
s3c24xx_leds_ioctl函数的代码如下:
1 | /* |
应用程序执行系统调用ioctl(fd,cmd,arg)
时,s3c24xx_leds_ioctl
函数将被调用。第18、22行根据传入的cmd、arg参数调用s3c2410_gpio_setpin
函数,来设置引脚的输出电平;输出0时点亮LED,输出1时熄灭LED。s3c2410_gpio_setpin
函数也是内核中实现的,它通过GPIO的数据寄存器来设置输出电平。
系统调用函数原型如下:
1 | int open(const char *pathname,int flags); |
file_operations结构中的成员如下:
1 | int (*open) (struct inode *, struct file *); |
可以看到,这些参数有很大一部分相似。
- 系统调用open传入的参数已经被内核文件系统层处理了,在驱动程序中看不出原来的参数了。
- 系统调用ioctl的参数个数可变,一般最多传入3个:后面两个参数与file_operations结构中ioctl成员后的两个参数对应。
- 系统调用read传入的buf、count参数,对应file_operations结构中read成员的buf、count参数。而参数offp表示用户在文件中进行存取的位置,当执行完读写操作后由驱动程序进行设置。
- 系统调用write与file_operations结构中write成员的参数关系,与第3点相似。
在驱动程序的最后,有如下描述信息,它们不是必须的。
1 | /*描述驱动程序的一些信息,不是必须的*/ |
驱动程序编译
将驱动文件放入内核drivers/char
子目录下,在drivers/char/Makefile
中增加下面一行:
1 | obj-m += s3c24xx_leds.o |
然后在内核根目录下执行“make modules”,就可以生成模块drivers/char/s3c24xx_leds.ko
。把它放到单板根文件系统的lib/modules/2.6.22.6
目录下,就可以使用“insmode s3c24xx_leds”、“rmmod s3c24xx_leds”命令进行加载卸载了。
驱动程序测试
首先要编译测试程序led_test.c
,它的代码很简单,关键部分如下:
1 |
|
其中的open、ioctl最终会调用驱动程序中的s3c24xx_leds_open
、s3c24xx_leds_ioctl
函数。
在测试程序目录下执行“make”命令生成可执行程序led_test,将它放入单板根文件系统/usr/bin
目录下后。
然后在单板根文件系统中建立设备文件:
1 | mknod /dev/leds c 231 0 |
运行测试程序
1 | led_test 1 on |