LDD之模块

来源:互联网 发布:大麦网络机顶盒 编辑:程序博客网 时间:2024/05/13 00:26

Linux内核允许在运行时insmod模块,以扩展内核的功能或者使新设备可用。因此理解如何编写模块和编译模块是必须掌握的。接下来,我们来讨论如何建立并运行一个完整的模块,并且讨论所有模块共用的基础代码。

前面我们提到过内核源码树,不管是发布的内核还是自己编译的内核,我们要保证,编译的模块只给对应的内核使用。


Hello World模块

每个程序员都编写过不同语言的Hello World。我们也可以给内核编写一个Hello World模块。

#include <linux/init.h>#include <linux/module.h>MODULE_LICENSE("Dual BSD/GPL");static int hello_init(void){printk(KERN_ALERT "Hello, world\n");//printk("<0>Hello, world\n");return 0;}static void hello_exit(void){printk(KERN_ALERT "Goodbye, cruel world\n");//printk("<0>Goodbye, cruel world\n");}module_init(hello_init);module_exit(hello_exit);

hello_init在insmod模块时调用,hello_exit在rmmod模块时调用。MODULE_LICENSE用来告诉内核,该模块带有一个自由的许可。

printk是内核打印函数,类似于C库里的printf,只是要等到insmod进内核后才能连接到内核公用符号,printk不支持浮点。KERN_ALERT是一个消息优先级,使用<0>也可以。在insmod或者rmmod模块时,有可能看不到打印,那么cat /var/log/messges应该可以看到。


模块编译

编译器版本太新、太旧都会导致问题,请阅读Documentation/Changes列出了所有的工具版本;Documentation/kbuild讲述了整个内核的编译系统。

现在写一个简单的makefile,内容如下(上面的代码保存为hello.c):

obj-m := hello.o

如果一个模块由一些文件构成,那么makefile可能如下:

obj-m := hello.o

module-objs := hello1.o hello2.o

然后执行如下命令:

make -C /usr/src/linux-headers-2.6.32-38-generic/ M=`pwd` modules

这个命令使用-C改变目录到内核源码树目录,这里有内核编译的顶层makefile;M=使得在编译模块目标前回到模块的源码目录;


每次都输入这样的命令很麻烦,可以像下面一样编写makefile:

ifneq ($(KERNELRELEASE), )

obj-m := hello.o

else

KERNELDIR := /usr/src/linux-headers-2.6.32-38-generic/

PWD := $(shell pwd)

default:

make -C $(KERNELDIR) M=$(PWD) modules

endif

这样第一次,KERNELRELEASE为空,执行else下面的部分,当make -C 回来,KERNELRELEASE不为空了,执行obj-m := hello.o部分,进行模块编译。


模块加载和卸载

insmod加载模块的代码段和数据段到内核,接着执行一个像ld一样的程序,解析模块中所有未解析的符号到内核符号表,但是只是修改内存中的拷贝,而硬盘中的保持不变。insmod还可以带参数。

sys_init_module通过vmalloc来分配内核内存,然后把模块的代码段拷贝到这段内存,解析模块的内核引用,调用模块初始化函数启动模块的初始化工作。

modprobe完成跟insmod一样的工作,不过它还会加载模块依赖的模块。modprobe在定义相关符号的当前模块搜索路径中查找模块。

rmmod用来卸载模块,当模块正被使用或者内核被配置为不允许卸载模块时,rmmod会失败。

lsmod通过读取/proc/modules虚拟文件提供一个内核中加载的模块列表,包含模块大小和被使用情况。也可以在/sys/module的sysfs虚拟文件系统找到。


内核版本依赖

模块是紧密结合到一个特定版本的内核的数据结构和函数原型上的,所以必须为不同内核重新编译模块。在编译模块的时候,会链接当前内核树中的vermagic.o,这个文件包含了目标内核版本,编译器版本,以及许多重要配置变量的设置,当加载一个模块时,运行的内核会检查这些信息,如果不匹配,模块不会被加载,而是抛出如下错误信息:

Error inserting 'xxx.ko' : -l Invalid module format

看一下系统日志/var/log/message可以发现是什么导致无法加载模块。

如果需要为不同版本的内核编写模块,需要在代码中检查内核版本,这样其实兼容了很多版本的内核,但是代码结构很复杂。处理不兼容的最好方式是把它们限制到特定的头文件,版本或者平台依赖的代码应当隐藏在一个低级的宏定义或者函数里。

linux/version.h定义了如下宏:

UTS_RELEASE - 内核树的版本字符串;

LINUX_VERSION_CODE - 内核版本的二进制形式;

KERNEL_VERSION(major, minor, release)  - 从组成一个版本号的单个数字计算出整形版本编码。


平台依赖性

定制的内核可以为特定的计算机做优化,以便更好的利用处理器。现代处理器引入了很多新的指令,比如,快速进入内核,处理器间的加锁,拷贝数据等等。可以采用更多位的地址,以便寻址超过4GB(32位地址)的内存。

基于GPL,以源码的形式发布模块代码是最好的选择。


内核模块VS应用程序


模块编程类似于事件驱动编程,hello_init告诉内核,本模块现在可用,提供什么功能,而hello_exit告诉内核,本模块现在不可用了。

在模块被rmmod时,一定要清理insmod时分配的资源,否则那些资源只有重启才能被释放。


只有实际上是内核的一部分的函数(不在同一个模块需要EXPORT_SYMBOL或者EXPORT_SYMBOL_GPL导出模块符号)才可以在内核模块里使用。内核相关的任何东西都在头文件里声明,这些头文件可以在内核源码树中找到,大部分相关头文件位于include/linux和include/asm。


应用程序的段错误只会挂掉应用程序,使用gdb很容易发现错误所在,而内核模块错误如果没有挂起整个系统,最少会kill掉当前进程。


内核空间VS用户空间


模块运行于内核空间,而应用程序运行于用户空间。

操作系统要给程序提供一个一致的硬件环境,操作系统必须承担程序的独立操作,防止未授权的资源访问。

现代处理器至少有2个保护级别,而X86有更多的级别,当几个级别存在时,使用最高和最低级别,Linux内核运行在最高级别,任何事情都是允许的,应用程序运行在最低级别,CPU控制了对硬件的直接访问和对内存的非法访问。

当应用程序发出一个系统调用或者一个硬件中断到来时,内核从用户空间转换到内核空间,执行系统调用的内核代码代表调用进程并且可以访问该调用进程的地址空间;而中断执行的内核代码是异步的,不和应用程序进程相关。

模块扩展了内核的功能,模块代码在内核空间运行,驱动模块完成2种任务:一些为系统调用服务,一些负责中断处理。


内核的并发

单线程的应用程序是顺序执行的,多线程的应用程序和内核一样,需要考虑并发。

内核并发的来源有:多进程,中断,内核定时器,SMP,抢占。

内核并发要求内核代码包含驱动代码,必须是可重入的,也就是必须能在多个上下文中运行。数据结构必须小心设计以保持多个执行线程分开,代码必须小心访问共享数据,避免数据的破坏。编写内核代码必须考虑正确的并发管理。


当前进程

内核做的大部分操作都是代表当前进程(调用系统调用陷入内核空间)的,除了中断。因此内核可以访问当前进程,全局变量current定义在<asm/current.h>中,它是指向task_struct(定义于<linux/sched.h>)的指针。

在SMP系统下,需要到相关CPU上寻找current,内核通过一个依赖体系结构的方法,把current隐藏于内核堆栈中。

current->comm和current->pid分别是进程调用了系统调用的名字和进程ID。


内核符号表

一个模块完成自己的功能是不需要输出符号的,只有模块需要为其他模块提供功能时,才需要输出符号。

模块堆叠使得代码更加简洁、结构清晰,例如USB输入设备堆叠在usbcore和输入模块上。模块堆叠能提供一个特定硬件实现的插入点;模块堆叠可以使得分层开发成为可能。

modprobe在处理加载模块堆叠的模块时很有用。

内核提供了如下宏定义来管理模块符号的可见性,并减少了命名空间的污染,促使了正确的信息隐藏:

EXPORT_SYMBOL

EXPORT_SYMBOL_GPL

这些宏定义扩展成一个特殊用途的并被期望是全局访问的变量声明。这个变量存储于模块的一个特殊可执行部分,内核用这个部分在加载时找到模块输出的变量。


模块头文件

大部分内核代码都需要包含很多数量的头文件来获取数据结构,函数,变量的定义。如下头文件是每个模块必须包含的:

<linux/module.h> - 加载模块需要的函数和符号的定义;

<linux/init.h> - 初始化和清理函数;

<linux/moduleparam.h> - 模块参数;


MODULE_LICENSE - 内核认识的特定许可有,GPL,GPL V2, GPL and additional rights, Dual BSD/GPL, Dual MPL/GPL, Proprietray。

此外还有如下描述性的宏定义:

MODULE_AUTHOR

MODULE_DESCRIPTION

MODULE_VERSION

MODULE_ALIAS

MODULE_DEVICE_TABLE


初始化和清理

实际的初始化函数类似于:

static int __init init_func(){}module_init(init_func);

声明中的__init用来告诉内核,该函数只在初始化时使用,模块加载后会被丢弃。类似的,__initdata只在初始化时用的数据。

module_init增加了特别的段到模块目标代码中,表明在哪里找到模块的初始化函数。

模块可以注册许多不同的设施,包括不同类型的设备,文件系统,加密转换等等,它们由不同的内核函数来完成注册,传给内核注册函数的参数常常是一些数据结构的指针,这些数据结构包含了模块的函数指针。

可以注册的设备类型,包括,串口,sysfs入口,/proc文件等等,这些可注册项的大部分都支持不直接和硬件相关的函数,并以各种方式集成在驱动中。


模块清理函数在模块被去除之前释放资源给内核。实际的清理函数类似于:

static void __exit cleanup_func(){}module_exit(cleanup_func);
如果模块被编译进内核,或者内核被配置成不可卸载模块,cleanup_func被丢弃。cleanup_func只在模块被卸载或者系统停止时被调用。

如果模块没有声明清理函数,那么内核不允许该模块被卸载。


初始化中的错误处理

注册内核设备时,可能会失败,即使是分配内存也可能失败,那么要求代码必须检查函数调用返回值,确保实际操作正常完成。

当发生错误时,首先要确认模块是否能继续初始化自己,尽管功能可能降级,但是要尽力让模块注册失败后能继续操作。

如果证实模块注册失败后,不能正常操作和加载,那么要确保之前完成的操作能正确的回退,否则,内核将处于不稳定的状态,那么只有重启才能恢复。

在错误恢复时,使用goto语句通常能去掉大量复杂的,过度对齐,形式化的代码。

static int __init init_func(){     int err;     err = register_1(ptr1, "xxx1");     if (err)     {        goto fail_1;     }     err = register_2(ptr2, "xxx2");     if (err)     {        goto fail_2;     }     return 0;fail_2:     unregister_1(ptr1, "xxx1");fail_1:     return err;}
这里err是一个错误码,在内核里,错误码是一个负数,包含头文件<linux/errno.h>并返回一个正确的错误码,以便应用程序通过perror能得到描述错误的字符串。

另一种代码排布如下,需要通过一些全局标志来判断之前的操作是否成功,进而是否需要释放。


struct node1 *item1;struct node2 *item2;int init_ok;void cleanup_func(){    if (item1)    {        release_node1(item1);    }    if (item2)    {        release_node2(item2);    }    if (init_ok)    {        unregister_this();    }}static int __init init_func(){    int err;    item1 = alloc_node1();    item2 = alloc_node2();    if (!item1 || !item2)    {        goto fail;    }    err = register_this(item1, item2);    if (err)    {        goto fail;    }    else    {        init_ok = 1;    }    return 0;fail:    cleanup_func();    return err;}


这里要注意,cleanup_func没有带__exit,是因为它需要在其他地方也调用。

确保在注册设备之前,所有关于该设备的初始化已经完成,因为一旦注册后,内核就有可能调用该设备,如果该设备的初始化还未完成,这将导致不稳定的结果。

如果已经注册了部分设备,但是初始化失败了,那么这时候就要小心,内核可能已经在使用已经注册的设备,那么需要考虑清理函数必须在内核对已经注册的设备的操作完成后再开始动作。


模块参数

内核通过加载驱动的模块时,指定可变参数的值。insmod和modprobe在加载模块时,可以指定参数的值。

定义在moduleparam.h里的module_param用于声明模块参数,该宏扩展为,变量名,类型,权限掩码。

模块参数支持如下类型:

bool

invbool - 一个布尔值(true或false),它颠倒了真值,true变false,false变true

charp - 字符串指针

int

long

short

uint

ulong

ushort

基本的变长整形值,以u开头的为无符号值。

module_param_array(name, type, num, perm)

name为数组名字,type为数组元素的类型,num为数组的大小,perm为权限掩码。


模块参数必须给定初始值,因为只有insmod和modprobe显式指定参数值时,模块参数才会有值。

使用<linux/stat.h>中定义的值来赋值权限掩码或者perm,如果perm设置为0,在sysfs中就不会出现对应的模块参数项,否则,它出现在/sys/module下面,并且带有给定权限;使用S_IRUGO作为参数可以被所有人读取,但是不能改变;S_IRUGO|S_IWUSR允许root来改变参数。如果一个参数被sysfs修改,模块看到的参数值也改变了,但是模块没有任何通知。如果模块没有检测模块参数改变,并因此做相应的动作的话,就不要让模块参数可写。


用户空间驱动

如下列出了在用户空间编写驱动的好处:

完整的C库;

可以在用户空间调试驱动;

可以kill掉用户空间的驱动;

用户空间驱动内存是可交换的;

精心设计的用户空间驱动可以并行访问设备;

如果你编写一个非开源的用户空间驱动,用户空间的选项可以避免不明朗的许可和改变的内核接口带来的问题。

libusb项目(libusb.sourceforge.net)是一个在用户空间编写驱动的例子。然而X Server是另外一个例子,它知道它能处理的硬件,并且提供图形资源给所有的X Client;不过现在framebuffer将成为主流,X Server将基于内核图形驱动,提供图形资源服务。


如下列出了用户空间驱动的缺点:

中断在用户空间无法使用,虽然某些体系结构提供了系统调用来解决这个问题;

只有特权用户通过内存映射/dev/mem才能使用DMA;

只有特权用户通过调用ioperm或者iopl才能访问I/O端口,某些体系结构可能不支持这些系统调用,访问/dev/port太慢;

响应时间太长,需要在用户和硬件之间传递信息;

如果驱动已经从内存交换到硬盘,那么会更慢,如果特权用户使用mlock来锁住驱动所在内存,那么内存开销会比较大;

一些重要的设备不能在用户空间处理,例如网络设备和块设备。


在用户空间调试新硬件,熟悉了操作流程后,再在内核空间写驱动将会很快。


其他注意项

内核堆栈很小,一般是一个页(4096KB),所以不要使用大体积的本地变量,确实需要的话,使用动态分配。

小心调用以双下划线开头(__)的内核API,这些API一般是底层接口组件。

内核不能做浮点运算。








0 0
原创粉丝点击