操作系统基础 | 2.2 linux内核与用户空间的不同

与众不同的“野兽”:内核与用户空间的区别

Linux 内核与普通用户空间程序相比,有许多独特之处。这些差异并不一定让内核开发更难,但确实让它与用户空间开发大不相同。内核开发有一些“特殊规则”,有些显而易见,有些则不那么直观。主要区别包括:

  1. 内核无法使用 C 标准库(libc)和标准 C 头文件。
  2. 内核使用 GNU C 语言编写。
  3. 内核没有用户空间的内存保护机制。
  4. 内核中不能轻易执行浮点运算。
  5. 内核每个进程的栈空间很小且固定。
  6. 由于内核有异步中断、支持抢占和多处理器(SMP),因此同步和并发问题非常重要。
  7. 可移植性很重要。

下面简要解释这些差异:


没有 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
2
3
unsigned int low, high;
asm volatile("rdtsc" : "=a" (low), "=d" (high));
/* low 和 high 现在分别保存了 64 位 tsc 的低 32 位和高 32 位 */
  • asm 关键字用于插入汇编代码。
  • 这种用法主要用于体系结构相关或对性能极致要求的代码。
  • 大部分内核代码还是用 C 语言编写,汇编只用于底层和关键路径。

分支预测注解(Branch Annotation)

gcc 提供了分支预测指令,可以告诉编译器某个条件分支更可能被执行,从而优化生成的代码。内核通过 likely()unlikely() 宏来使用这些特性。

例如:

1
2
3
4
5
6
7
if (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 位、不要假设字长或页面大小等。后续章节会详细讨论可移植性。