对码当歌,猿生几何?

第二章从内核出发之Linux内核开发特点

2.4 内核开发的特点

        相对于用户空间内应用程序的开发,内核开发有一些独特之处。尽管这些差异并不会使开发内核的难点超过开发用户代码,但依然有很大不同。

        内核编程时既不能访问C库也不能访问标准的C头文件。

        内核编程时必须使用GNU C。

        内核编程时缺乏像用户空间那样的内存保护机制。

        内核编程时难以执行浮点运算。

        内核给每个进程只有一个很小的定长堆栈。

        由于内核支持异步中断、抢占和SMP,因此必须时刻注意同步和并发。

        要考虑可移植性的重要性。

1、无libc库抑或无标准头文件

        与用户空间的应用程序不同,内核不能链接使用标准C函数库-或者其他的那些库也不行。造成这种情况的原因有许多,最主要的原因还是速度和大小。对内核来说,完整的C库-哪怕是它的一个子集,都太大且太低效了。

        大部分常用的C库函数在内核中都已经得到实现。比如操作字符串的函数组就位于lib/string.c文件中。只要包含<linux/string.h>头文件,就可以使用它们。

        头文件:指的是组成内核源代码树的内核头文件,内核源代码文件不能包含外部头文件。基本的头文件位于内核源代码树顶级目录下的include目录中。例如,头文件<linux/inotify.h>对应内核源代码树的include/linux/inotify.h。

        体系结构相关的头文件位于内核源代码树的arc/<architecture>/include/asm目录下。例如,如果编译的是x86体系结构,则体系结构相关的头文件是arch/x86/include/asm。内核代码通过以asm/为前缀的方式包含这些头文件,例如<asm/ioctl.h>。

        在所有没有实现的函数中,最著名的就是printf()函数。内核代码虽然无法调用printf(),但内核提供的printk()函数几乎与printf()相同。printk()函数负责把格式化好的字符串拷贝到内核日志缓冲区上,syslog程序可以通过读取该缓冲区来获取内核信息。

         printk(KERN_INFO "bus unregister ");

        printk()和printf()之间的一个显著区别在于,printk()允许通过指定一个标志来设置优先级。syslogd会根据这个优先级标志来决定在什么地方显示这条系统消息。

备注:

        在KERN_INFO和要打印的消息之间没有逗号。优先级标志是预处理程序定义的一个描述性字符串,在编译时优先级标志就与要打印的消息绑定在一起处理。

2、GNU C

        Linux内核是用C语言编写的,内核并不完全符号ANSI C标准。只要有可能,内核开发者总是要用到gcc提供的许多语言扩展部分。

        内核开发者使用的C语言涵盖ISO C99标准和GNU C扩展特性。

1)内联函数

C99和GNU C均支持内联函数。inline可以反映出它的工作方式,函数会在它所调用的位置上展开。这么做可以消除函数调用和返回所带来的开销(寄存器存储和恢复)。而且,由于编译器会把调用函数的代码和函数本身放在一起进行优化,所以也有进一步优化代码的可能。这么做是有代价的,代码会变长,也就意味着占用更多的内存空间或者占用更多的指令缓存。内核开发者通常把那些对时间要求比较高,而本身长度又比较短的函数定义成内联函数。如果一个函数较大,会被反复调用,且没有特别的时间上的限制,不赞成把它做成内联函数。

        定义内联函数时,需要使用static关键字,并且用inline来限定。例如:

static inline int memory_dev_init(void)

内联函数必须在使用之前就定义好,否则编译器就没法把这个函数展开。一般在头文件中定义内联函数。由于使用了static作为关键字限制,所以编译时不会为内联函数单独建立一个函数体。如果一个内联函数仅仅在某个源文件中使用,也可以把它定义在该文件开始的地方。

        在内核中,为了类型安全和易读性,优先使用内联函数而不是复杂的宏。

2)内联汇编

        gcc编译器支持在C函数中嵌入汇编指令。在内核编程时,只有知道对应的体系结构,才能使用这个功能。通常使用asm()指令嵌入汇编代码。Linux内核混合使用了C语言和汇编语言。在偏近体系结构的底层或对执行时间要求严格的地方,一般使用的是汇编语言。而内核其它部分的大部分代码是用C语言编写的。

3)分支声明

        对于条件选择语句,gcc内建了一条指令用于优化,在一个条件经常出现,或者该条件很少出现时,编译器根据这条指令对条件分支选择进行优化。内核把这条指令封装成了宏,比如likely()和unlikely(),这样使用起来比较方便。例如,下面是一个条件选择语句:

if(error){

        /*......*/

}

如果要把这个选择标记成绝少发生的分支:


if(unlikely(error)){//error绝大多数时间都会为0

        /*......*/

}

如果把一个分支标记为通常为真的选择:

if(likely(success)){//success通常都不会为0

        /*......*/

}

        在想要对某个条件选择语句进行优化之前,一定要搞清楚其中是不是存在这么一个条件,在绝大多数情况下都会成立。这点十分重要:如果判断正确,确实是这个条件占压倒性的地位,那么性能就会提升;如果搞错了,性能反而会下降。通常在对一些错误条件进行判断时使用likely()或unlikely()。unlikely()在内核中会得到更广泛的应用,因为if语句往往判断一种特殊情况。

3、没有内存保护机制

        如果一个用户程序视图进行一次非法的内存访问,内核会发现这个错误,发送SIGSEGV(段错误)信号,并结束整个进程。如果是内核自己非法访问了内存,后果很难控制。内核中发生的内存错误会导致Oops,这是内核中出现的最常见的一类错误。在内核中,不应该去做访问非法的内存地址,引用空指针之类的事情,否则它可能会导致死掉。

        此外,内核中的内存都不分页。也就是说,每用掉一个字节,物理内存就减少一个字节。所以,往内核中加入新功能时,要记住这一点。

4、不要轻易在内核中使用浮点数

        在用户空间的进程内进行浮点操作时,内核会完成从整数操作到浮点数操作的模式转换。在执行浮点指令时到底会做些什么,因体系结构不同,内核的选择也不同,但是,内核通常捕获陷阱并着手于整数到浮点方式的转变。

        与用户空间进程不同,内核并不能完美地支持浮点操作,因为它本身不能陷入。在内核中使用浮点数时,除了要人工保存和恢复浮点寄存器,还有其他一些琐碎的事情要做。除了一些极少的情况,不要在内核中使用浮点操作。

5、容积小而固定的栈

        用户空间的程序可以从栈上分配大量的空间来存放变量,甚至巨大的结构体或者是包含数以千计的数据项的数组都没有问题。之所以可以这么做,是因为用户空间的栈本身比较大,而且还能动态地增长。

        内核栈的准确大小随体系结构而变。在x86上,栈的大小在编译时配置,可以是4KB也可以是8KB。从历史上说,内核栈的大小是两页,这意味着,32位机的内核栈是8KB,而64位机的内核栈是16KB,这是固定不变的。每个处理器都有自己的栈。

6、同步和并发

        内核很容易产生竞争条件。和单线程的用户空间程序不同,内核的许多特性都要求能够并发地访问共享数据,这就要求有同步机制以保证不出现竞争条件,特别是:

        Linux是抢占多任务操作系统。内核的进程调度程序即对进程进行调度和重新调度。内核必须和这些任务同步。

        Linux内核支持对称多处理器系统SMP。如果没有适当的保护,同时在两个或两个以上的处理器上执行的内核代码很可能会同时访问共享的同一个资源。

        中断是异步到来的,完全不顾及当前正在执行的代码。如果不加以适当的保护,中断完全有可能在代码访问资源的时候到来,中断处理程序就有可能访问同一资源。

        Linux内核可以抢占。如果不加以适当的保护,内核中一段正在执行的代码可能会被另一段代码抢占,从而有可能导致几段代码同时访问相同的资源。

        常用的解决竞争的办法是自旋锁和信号量。

7、可移植性的重要性

        尽管用户空间的应用程序不太注意移植问题,然而Linux是一个可移植的操作系统。大部分C代码应该与体系结构无关,在许多不同体系结构的计算机上都能编译和执行,因此,必须把与体系结构相关的代码从内核代码树的特定目录中适当地分离出来。

        例如保持字节序、64位对齐、不假定字长和页面长度等一系列准则都有助于移植性。

 

     

阅读更多