操作系统基础 | 2.2 linux内核与用户空间的不同
与众不同的“野兽”:内核与用户空间的区别
Linux 内核与普通用户空间程序相比,有许多独特之处。这些差异并不一定让内核开发更难,但确实让它与用户空间开发大不相同。内核开发有一些“特殊规则”,有些显而易见,有些则不那么直观。主要区别包括:
- 内核无法使用 C 标准库(libc)和标准 C 头文件。
- 内核使用 GNU C 语言编写。
- 内核没有用户空间的内存保护机制。
- 内核中不能轻易执行浮点运算。
- 内核每个进程的栈空间很小且固定。
- 由于内核有异步中断、支持抢占和多处理器(SMP),因此同步和并发问题非常重要。
- 可移植性很重要。
下面简要解释这些差异:
没有 libc 或标准头文件
与用户空间程序不同,内核不会链接标准 C 库(libc)或其他外部库。主要原因是速度和体积——完整的 C 库太大、效率太低,不适合内核使用。
不用担心,内核自己实现了很多常用的 libc
函数。例如,常见的字符串操作函数在 lib/string.c
中实现,只需包含 <linux/string.h>
头文件即可使用。
头文件
内核源码只能包含内核源码树中的头文件,不能引用外部头文件或库。
- 基础头文件位于源码根目录的 include/
目录下。例如,<linux/inotify.h>
实际路径为
include/linux/inotify.h
。 - 架构相关的头文件位于
arch/<architecture>/include/asm
,如 x86 架构下为
arch/x86/include/asm
,引用时用
<asm/ioctl.h>
。
没有 printf(),用 printk()
内核没有 printf()
,但提供了类似的 printk()
用于内核日志输出。例如: 1
printk("Hello world! A string '%s' and an integer '%d'\n", str, i);
printf()
不同,printk()
可以指定优先级(priority flag),用于
syslogd 判断消息显示位置。例如: 1
printk(KERN_ERR "this is an error!\n");
KERN_ERR
和消息之间没有逗号,这是因为优先级标志是字符串宏,编译时会自动拼接。
GNU C
和大多数 Unix 内核一样,Linux 内核主要用 C 语言编写。但它并不是严格的 ANSI C,而是大量使用了 gcc(GNU 编译器套件)提供的各种语言扩展。内核开发者会用到 ISO C99 和 GNU C 的扩展特性,这使得 Linux 内核基本只能用 gcc 编译(近年 Intel C 编译器也支持了大部分 gcc 特性,可以编译内核)。目前推荐使用 gcc 4.4 或更高版本。
C99 的扩展比较常见,而 GNU C 的扩展则是 Linux 内核代码区别于普通 C 项目的重要原因。下面介绍几个常见的 GNU C 扩展:
内联函数(Inline Functions)
C99 和 GNU C 都支持内联函数(inline function)。内联函数会在每个调用点直接插入函数体,避免了函数调用和返回的开销(如寄存器保存/恢复),有利于编译器整体优化调用者和被调用者的代码。但缺点是会增加代码体积和内存消耗。
内核开发者通常只对小型、对性能要求高的函数使用内联。大函数或不常用的函数不建议内联。
内联函数的声明方式如下:
1 | static inline void wolf(unsigned long tail_size) |
static inline
关键字用于定义内联函数。- 内联函数的声明要在使用前,否则编译器无法内联。
- 通常内联函数会放在头文件中(因为是 static,不会导出符号)。
内核更倾向于用内联函数而不是复杂的宏,因为内联函数有类型安全和可读性好等优点。
内联汇编(Inline Assembly)
gcc 支持在 C 代码中嵌入汇编指令,这在与硬件密切相关的内核代码中很有用。用法如下:
1 | unsigned int low, high; |
asm
关键字用于插入汇编代码。- 这种用法主要用于体系结构相关或对性能极致要求的代码。
- 大部分内核代码还是用 C 语言编写,汇编只用于底层和关键路径。
分支预测注解(Branch Annotation)
gcc
提供了分支预测指令,可以告诉编译器某个条件分支更可能被执行,从而优化生成的代码。内核通过
likely()
和 unlikely()
宏来使用这些特性。
例如: 1
2
3
4
5
6
7if (unlikely(error)) {
/* 这里假设 error 很少为真 */
}
if (likely(success)) {
/* 这里假设 success 几乎总为真 */
}unlikely()
常用于错误处理分支,因为大多数情况下不会出错。
没有内存保护
当用户空间程序非法访问内存时,内核可以捕获错误,发送 SIGSEGV 信号并终止该进程。但如果内核自身非法访问内存,后果就不可控了(毕竟,谁来保护内核呢?)。内核中的内存违规会导致 oops(严重内核错误)。因此,在内核中绝不能非法访问内存,比如解引用 NULL 指针——在内核里,这样的错误代价更高!
另外,内核内存是不可换页的(not pageable),即内核占用的每一字节物理内存都不能被换出。你在内核里多用一点内存,系统可用物理内存就少一点。每次想给内核加新功能时,请记住这一点!
不能(轻易)使用浮点运算
用户空间进程使用浮点指令时,内核会负责从整数模式切换到浮点模式。具体做法依赖于体系结构,通常是通过陷阱(trap)实现的。
但内核自身不能像用户空间那样方便地使用浮点运算,因为内核无法轻松地“陷阱”自己。内核里用浮点数需要手动保存和恢复浮点寄存器等操作,非常麻烦。简而言之:不要在内核里用浮点运算! 除极少数特殊情况外,内核代码中基本没有浮点操作。
小而固定的栈空间
用户空间可以在栈上分配大量变量,包括大结构体和大数组,因为用户空间的栈很大且可以动态增长。但内核栈既不大也不能动态扩展,而是小且固定的。
- 栈的具体大小依赖于体系结构。例如 x86 架构下,栈大小可在编译时配置为 4KB 或 8KB。
- 通常,32 位系统为 8KB,64 位系统为 16KB,每个进程有自己的内核栈,这个大小是固定的。
因此,内核开发时要避免在栈上分配大对象。
同步与并发
内核容易出现竞态条件(race condition)。与单线程的用户空间程序不同,内核有多种并发访问共享资源的情况,必须通过同步机制防止竞态:
- Linux 是抢占式多任务操作系统,进程会被调度器随时切换,内核需要在这些任务间同步。
- Linux 支持对称多处理(SMP),多个处理器上的内核代码可能同时访问同一资源。
- 中断是异步发生的,可能在访问资源时被打断,导致中断处理程序也访问同一资源。
- 内核本身是可抢占的,内核代码可能被抢占,切换到另一个访问同一资源的代码。
常见的同步机制有自旋锁(spinlock)和信号量(semaphore)。后续章节会详细介绍。
可移植性的重要性
虽然用户空间程序不一定要追求可移植性,但 Linux 作为一个可移植操作系统,必须保证代码能在多种体系结构上正确编译和运行。体系结构相关的代码要放在专门的目录下,体系结构无关的代码要保持通用。
一些基本规则包括:保持字节序中立、支持 64 位、不要假设字长或页面大小等。后续章节会详细讨论可移植性。