引言:当代码成为史诗

亲爱的读者,在上一篇文章中,我们像一位战略家一样,俯瞰了 main.c 的全貌:它如何划分内存、如何调用各个初始化函数、如何从内核态切换到用户态,以及如何创建第一个用户进程。那是一张“作战地图”,我们看清了战场的全貌。

现在,我们要做的,是俯下身子,用放大镜逐行审视每一行代码。这不是枯燥的阅读,而是一场考古发掘——每一行代码都是一个文物,背后都藏着设计者的思考、历史的局限,以及超越时代的智慧。

main.c 只有大约 103 行。但正是这 103 行,开启了 Linux 内核的“创世记”。我们将跟随每一行代码,见证操作系统从混沌到有序的全部过程。

你可能会问:为什么区区100多行代码,值得花一整篇文章来拆解?我的回答是:这100多行代码中的每一行,都对应着现代操作系统教科书里的一整章。理解它们,你就理解了操作系统内核初始化的全部精髓。

📖 阅读指南:本文的完整源码集中呈现在下一节(带行号)。后续的逐行解析将通过行号引用来定位具体代码,不再重复整段代码。建议您在阅读解析时,随时回头对照前面的完整源码。你可以把这篇文章当作一本“古籍注释本”——原文在第一节,注释在后续各节,两者对照阅读,方能得其三昧。

第一部分:完整源码(带行号)

以下为 init/main.c 的完整源代码,所有 103 行(含注释和空行),统一编号,供全文对照使用:

     1  /*
     2   *  linux/init/main.c
     3   *
     4   *  (C) 1991  Linus Torvalds
     5   */
     6  
     7  #define __LIBRARY__
     8  #include <stddef.h>
     9  #include <stdarg.h>
    10  #include <unistd.h>
    11  #include <fcntl.h>
    12  #include <sys/types.h>
    13  
    14  #include <linux/fs.h>
    15  #include <linux/sched.h>
    16  #include <linux/tty.h>
    17  #include <linux/head.h>
    18  #include <asm/system.h>
    19  #include <asm/io.h>
    20  
    21  #include <linux/kernel.h>    /* for printk, etc. */
    22  
    23  extern int vsprintf(char * buf, const char * fmt, va_list args);
    24  extern void init(void);
    25  extern void blk_dev_init(void);
    26  extern void chr_dev_init(void);
    27  extern void hd_init(void);
    28  extern void floppy_init(void);
    29  extern void mem_init(long start, long end);
    30  extern long rd_init(long mem_start, int length);
    31  extern long kernel_mktime(struct tm * tm);
    32  extern long startup_time;
    33  
    34  static char printbuf[1024];
    35  
    36  /* ROOT_DEV is defined in fs/super.c */
    37  extern int ROOT_DEV;
    38  
    39  /*
    40   * This is set up by the setup-routine at boot-time
    41   */
    42  #define ORIG_ROOT_DEV (*(unsigned short *)0x901FC)
    43  #define DRIVE_INFO (*(struct drive_info *)0x90080)
    44  #define EXT_MEM_K (*(unsigned short *)0x90002)
    45  
    46  struct drive_info { char dummy[32]; };
    47  
    48  static long memory_end = 0;
    49  static long buffer_memory_end = 0;
    50  static long main_memory_start = 0;
    51  
    52  static inline _syscall0(int, fork);
    53  static inline _syscall0(int, pause);
    54  static inline _syscall1(int, setup, void *, BIOS);
    55  static inline _syscall0(int, sync);
    56  
    57  void main(void)     /* This really IS void, no error here. */
    58  {                   /* The startup routine assumes (well, ...) this */
    59      /*
    60       * Interrupts are still disabled. Do necessary setups, then
    61       * enable them
    62       */
    63      ROOT_DEV = ORIG_ROOT_DEV;
    64      drive_info = DRIVE_INFO;
    65      memory_end = (1<<20) + (EXT_MEM_K<<10);
    66      memory_end &= 0xfffff000;
    67      if (memory_end > 16*1024*1024)
    68          memory_end = 16*1024*1024;
    69      if (memory_end > 12*1024*1024)
    70          buffer_memory_end = 4*1024*1024;
    71      else if (memory_end > 6*1024*1024)
    72          buffer_memory_end = 2*1024*1024;
    73      else
    74          buffer_memory_end = 1*1024*1024;
    75      main_memory_start = buffer_memory_end;
    76  #ifdef RAMDISK
    77      main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
    78  #endif
    79      mem_init(main_memory_start, memory_end);
    80      trap_init();
    81      blk_dev_init();
    82      chr_dev_init();
    83      tty_init();
    84      time_init();
    85      sched_init();
    86      buffer_init(buffer_memory_end);
    87      hd_init();
    88      floppy_init();
    89      sti();
    90      move_to_user_mode();
    91      if (!fork()) {      /* we count on this going ok */
    92          init();
    93      }
    94      /*
    95       *   NOTE!! For any other task 'pause()' would mean we have to get a
    96       *   signal to awaken, but task0 is the sole exception (see 'schedule()')
    97       *   as task 0 gets activated at every idle moment (when no other tasks
    98       *   can run). For task0 'pause()' just means we go check if some other
    99       *   task can run, and if not we return here.
   100       */
   101      for(;;)
   102          pause();
   103  }

本文所有解析都将通过引用上述行号来定位代码。

第二部分:逐行深度解析

2.1 文件头与版权声明(第 1-5 行)

第 1-5 行是标准的 Unix 源码文件头:

     1  /*
     2   *  linux/init/main.c
     3   *
     4   *  (C) 1991  Linus Torvalds
     5   */

这五行注释,如今看来已是计算机史上的文物。1991年,Linus Torvalds 还是赫尔辛基大学的一名21岁学生。他在一台配备4MB内存的386兼容机上,用Minix系统作为开发环境,开始编写自己的操作系统内核。linux/init/main.c 这个路径,在当时只是他个人项目目录下的一个普通文件,没有人能预料到,这个文件会成为后来支撑全球互联网基础设施的操作系统的源代码。

(C) 1991 Linus Torvalds 这条版权声明也很值得玩味。当时Linux还没有采用GPL许可证(GPL是在1992年Linux 0.12版本中引入的),所以这里使用的是传统的Copyright声明。从1992年起,Linux开始使用GNU通用公共许可证,这标志着Linux正式加入了自由软件运动,成为整个GNU生态系统的核心组件。

2.2 头文件包含与宏定义(第 7-21 行)

第7行是整个文件最关键的一行宏定义:

     7  #define __LIBRARY__

这行代码的长度只有17个字符,但它的作用极其关键。在 include/unistd.h 中,系统调用相关的宏(如 _syscall0_syscall1 等)被条件编译包裹着:只有在内核代码中定义了 __LIBRARY__ 的时候,这些宏才会展开为内联汇编形式的系统调用入口;而在用户程序中不定义这个宏,则通过C库来调用系统调用。

为什么需要这样的设计?

这涉及到操作系统开发中一个经典的“鸡生蛋”问题。内核本身需要调用系统调用(比如 fork() 创建进程),但系统调用通常是由C标准库(libc)封装的。而C标准库本身又运行在操作系统之上——它依赖内核提供的系统调用接口才能工作。如果我们要求内核依赖C库来调用系统调用,那就陷入了循环依赖:内核需要C库,C库需要内核。

Linux 0.11 解决这个问题的方式非常巧妙。它让 unistd.h 这个头文件具备了“双重身份”:

  • 当被用户程序包含时(未定义 __LIBRARY__),它只提供系统调用号和标准符号常量的定义。用户程序通过链接 libc 来获得实际的系统调用函数。
  • 当被内核代码包含时(定义了 __LIBRARY__),它额外激活 _syscallN 系列宏,这些宏使用GCC的内联汇编功能,直接生成 int $0x80 指令来触发系统调用,完全绕过了C库。

这种设计让同一个头文件在内核和用户空间中扮演了不同的角色,减少了代码重复,是早期Linux设计中的一个精巧范例。

第 8-21 行:头文件包含——内核的“工具箱”

     8  #include <stddef.h>
     9  #include <stdarg.h>
    10  #include <unistd.h>
    11  #include <fcntl.h>
    12  #include <sys/types.h>
    13  
    14  #include <linux/fs.h>
    15  #include <linux/sched.h>
    16  #include <linux/tty.h>
    17  #include <linux/head.h>
    18  #include <asm/system.h>
    19  #include <asm/io.h>
    20  
    21  #include <linux/kernel.h>    /* for printk, etc. */

这14个头文件可以分为三组,每一组都有明确的职责范围:

第一组:标准头文件(第8-12行)——兼容性层

行号 头文件 关键内容 在 main.c 中的用途
8 stddef.h NULLsize_toffsetof 基础定义,其他头文件依赖
9 stdarg.h va_listva_startva_argva_end printk 格式化字符串所需
10 unistd.h 系统调用号(__NR_fork=2等)、_syscallN 被第7行的 __LIBRARY__ 激活,提供内联系统调用
11 fcntl.h O_RDONLY(0)、O_WRONLY(1)、O_RDWR(2)、O_CREAT 第92行 init() 打开终端时使用
12 sys/types.h pid_tuid_ttime_tsize_t 等类型别名 保证跨平台类型一致性

这些标准头文件在内核源码树中都有自己的实现,而非使用系统自带的 /usr/include/ 下的版本。这是因为内核不能依赖用户空间的任何东西——它必须自给自足。例如,内核的 stddef.h 只定义了最基本的 NULL(void *)0)和 offsetof 宏,远没有用户空间的同名头文件那么丰富。

第二组:内核通用头文件(第14-17行)——核心数据结构

行号 头文件 关键内容 在 main.c 中的用途
14 linux/fs.h super_blockinodefileNR_BUFFERS 文件系统核心结构定义
15 linux/sched.h task_structTASK_RUNNINGswitch_to 第85行 sched_init() 和后续 fork() 使用
16 linux/tty.h tty_structtty_queue 第83行 tty_init() 使用
17 linux/head.h desc_struct(段描述符)、GDT索引常量 段描述符相关操作使用

linux/fs.h 定义了文件系统的核心数据结构。特别值得关注的是 NR_BUFFERS,它决定了高速缓冲区的块数,直接影响磁盘I/O性能。

linux/sched.h 是进程调度系统的灵魂。它定义了 task_struct——进程控制块(PCB)。每个进程在内核中都有一个 task_struct 实例,包含了进程的状态、优先级、时间片、内存信息、文件描述符表等所有内核需要跟踪的信息。第85行的 sched_init() 会初始化任务0的 task_struct,为多任务做好准备。

第三组:体系结构相关头文件(第18-19行)——硬件接口

行号 头文件 关键内容 在 main.c 中的用途
18 asm/system.h move_to_user_modesti()cli()set_trap_gate 第89行、第90行使用
19 asm/io.h inboutbinb_poutb_p time_init() 中CMOS读取使用

asm/system.hmain.c 中最关键的体系结构头文件。它包含了第90行使用的 move_to_user_mode() 宏——这是整个内核从内核态切换到用户态的核心魔法。它还包含了 sti()cli() 宏,分别用于开启和关闭硬件中断。

第四组:内核实用函数头文件(第21行)

    21  #include <linux/kernel.h>    /* for printk, etc. */

linux/kernel.h 声明了内核常用的打印函数 printk。虽然 main() 函数本身没有直接调用 printk,但它调用的各个初始化函数(如 mem_initbuffer_init)都会使用 printk 向控制台输出信息。在1991年,printk 几乎是内核唯一的“人机交互”手段——没有图形界面,没有日志系统,只有屏幕上滚动的白色文字。

2.3 外部函数声明(第 23-32 行)

第23-32行是 main.c 的“分包合同”——声明了所有在其他文件中定义的函数:

    23  extern int vsprintf(char * buf, const char * fmt, va_list args);
    24  extern void init(void);
    25  extern void blk_dev_init(void);
    26  extern void chr_dev_init(void);
    27  extern void hd_init(void);
    28  extern void floppy_init(void);
    29  extern void mem_init(long start, long end);
    30  extern long rd_init(long mem_start, int length);
    31  extern long kernel_mktime(struct tm * tm);
    32  extern long startup_time;

extern 关键字是C语言的“前向声明”。它告诉编译器:“这些函数(或变量)的定义在别的 .c 文件中,你现在只需要记住它们的名字和参数类型,不需要看到它们的实现。” 这就像一份建筑项目的分包合同——总指挥(main.c)知道泥瓦工(mem_init)、电工(trap_init)、水暖工(buffer_init)的存在和联系方式,但具体怎么干活是人家自己的事情。

这些函数分布在源码树的各个角落,构成了Linux 0.11内核的完整功能体系:

第23行 vsprintf:来自 kernel/vsprintf.c。它是 printk 的核心引擎,负责把格式化字符串(如 "Free mem: %d bytes")展开成最终的字符串。在1991年的内核中,vsprintf 是唯一一个可以处理 %d%s%x 等格式化符号的函数。它不依赖任何C库,完全自己实现。

第24行 init:这是本文件后面会定义的函数(在原文第168行附近)。它是进程1的入口函数,负责挂载根文件系统、打开终端、启动Shell。进程1是系统中所有用户进程的祖先。

第25-28行 设备驱动初始化函数blk_dev_init(块设备请求队列初始化)、chr_dev_init(字符设备初始化)、hd_init(硬盘驱动初始化)、floppy_init(软盘驱动初始化)。这些函数分别在 kernel/blk_drv/kernel/chr_drv/ 目录中实现。它们会注册中断处理函数、初始化设备状态、建立请求队列。

第29行 mem_init:来自 mm/memory.c。这是内存管理子系统的入口。它会根据 main_memory_startmemory_end,将主内存区的所有物理页框组织成一个空闲链表(free_page_list)。从此以后,内核就可以通过 get_free_page() 函数动态分配物理内存了。

第30行 rd_init:来自 kernel/blk_drv/ramdisk.c。如果在Makefile中定义了 RAMDISK 宏,就会调用它来初始化虚拟盘。虚拟盘是把一部分内存当作磁盘来使用,数据读写都在内存中完成,速度极快,适合存放临时文件。

第31行 kernel_mktime:来自 kernel/mktime.c。这个函数将 struct tm 格式的日期时间(年、月、日、时、分、秒)转换为从1970年1月1日0时0分0秒起算的UNIX时间戳。该函数在 time_init() 中被调用,用于初始化系统时钟。

第32行 startup_time:这是 kernel/sched.c 中定义的全局变量,存储系统启动时的UNIX时间戳。它在 time_init() 中被赋值,后续 sys_time() 系统调用会根据它和当前时钟中断计数来计算当前时间。

2.4 全局变量与常量定义(第 34-50 行)

第34行:printbuf——内核的“黑板”

    34  static char printbuf[1024];

这是一个1024字节的静态字符数组。把它声明为 static 意味着它只能在 main.c 文件内部被访问,外部文件看不到它。这个缓冲区是 printk 函数的“工作台”——printk 先把格式化后的字符串写到这里,然后再通过 console_write 发送到屏幕。

你可能好奇为什么是1024字节?这其实是一个经验值。在内核初始化阶段,一条日志信息通常不会超过几百字节。1024字节足够容纳一条完整的错误信息,同时又不会浪费太多宝贵的内存。在只有4MB内存的机器上,每一个字节都要精打细算。

第36-37行:ROOT_DEV——根设备的“户口本”

    36  /* ROOT_DEV is defined in fs/super.c */
    37  extern int ROOT_DEV;

ROOT_DEVfs/super.c 中定义的全局变量。它的注释恰好解释了它为何是extern——它的定义在文件系统超级块管理模块中。ROOT_DEV 存储的是根文件系统所在的设备号。在 Linux 0.11 中,设备号的编码规则是:

  • 高字节:主设备号(major),表示设备类型。比如3表示硬盘,2表示软盘。
  • 低字节:次设备号(minor),表示同类设备中的编号。比如 0x0306 表示第二个硬盘的第一个分区(主设备号3,次设备号6)。

这个变量将在第63行被赋值为 ORIG_ROOT_DEV,也就是从引导扇区传来的原始根设备号。

第39-44行:从“临时信息板”读取数据的宏——内核与BIOS的最后握手

    39  /*
    40   * This is set up by the setup-routine at boot-time
    41   */
    42  #define ORIG_ROOT_DEV (*(unsigned short *)0x901FC)
    43  #define DRIVE_INFO (*(struct drive_info *)0x90080)
    44  #define EXT_MEM_K (*(unsigned short *)0x90002)

这三个宏是 main.c 最精妙的设计之一。它们是内核与BIOS世界的最后一次握手——从此以后,内核便不再依赖任何实模式下的遗留数据,完全凭借自己的力量运行。

物理地址 大小 含义 填写者
ORIG_ROOT_DEV 0x901FC 2字节 根设备号 bootsect.sbuild.c
DRIVE_INFO 0x90080 32字节 两个硬盘的参数表(各16字节) setup.s 第72-87行
EXT_MEM_K 0x90002 2字节 扩展内存大小(单位KB) setup.s 第47行

为什么选择这些地址?

这就要回溯到 setup.s 的硬件侦察阶段了。setup.s 在实模式下通过BIOS中断获取了大量硬件信息,并将它们存放在了从 0x900000x901FF 的内存区域——这正是 bootsect.s 搬家后留下的空地。这个区域在进入保护模式后不会被立即覆盖,因此成为了汇编代码和C语言代码之间传递参数的“临时信息板”。

具体的地址分配:

  • 0x90000:光标位置(2字节)
  • 0x90002:扩展内存大小(2字节)——即 EXT_MEM_K
  • 0x90004~0x9000C:显卡参数
  • 0x90080~0x9008F:第一个硬盘的参数表(16字节)——即 DRIVE_INFO 的前半
  • 0x90090~0x9009F:第二个硬盘的参数表(16字节)——即 DRIVE_INFO 的后半
  • 0x901FC:根设备号(2字节)——即 ORIG_ROOT_DEV

这些宏的C语言写法非常值得学习。以 ORIG_ROOT_DEV 为例:

#define ORIG_ROOT_DEV (*(unsigned short *)0x901FC)

从内到外解读:

  1. 0x901FC:一个整数常量,代表物理地址。
  2. (unsigned short *)0x901FC:将这个整数强制转换为指向 unsigned short 的指针。
  3. * 解引用:读取该地址处的2字节数据(unsigned short 是2字节)。

这种写法在操作系统内核中非常常见,被称为“绝对地址访问”。它绕过了C语言的类型系统,直接操作物理内存地址。在用户程序中这样做会导致段错误(Segmentation Fault),但内核运行在特权级0,可以合法访问任何物理地址。

第46行:drive_info 结构体

    46  struct drive_info { char dummy[32]; };

这是一个结构体声明,内部只有一个32字节的字符数组,名为 dummy(哑元)。这个结构体的实例会在第64行被赋值:

drive_info = DRIVE_INFO;

结构体在C语言中支持整体赋值(编译器会自动生成 memcpy 或内联复制代码)。这里把 0x90080 处的32字节完整复制到 drive_info 变量中。后续的 sys_setup() 系统调用会解析这32字节,提取出硬盘的柱面数、磁头数、每磁道扇区数等关键参数。

为什么不直接在 sys_setup() 中读 0x90080 呢?因为此时已经开启了分页机制,而且 0x90000 这个区域很快就会被用作磁盘缓冲区,数据会被覆盖。所以必须赶在覆盖之前把数据复制到一个安全的变量中。

第48-50行:内存布局三剑客

    48  static long memory_end = 0;
    49  static long buffer_memory_end = 0;
    50  static long main_memory_start = 0;

这三个静态全局变量,用三个简单的数字定义了整个物理内存的“行政区划”。它们全部被初始化为0,真正的值会在 main() 函数中被计算出来。

变量 含义 计算方式
memory_end 物理内存总量的字节数 1MB + 扩展内存,然后向下对齐到4KB边界,上限16MB
buffer_memory_end 高速缓冲区的末端地址 根据 memory_end 的大小动态决定:>12MB→4MB,>6MB→2MB,≤6MB→1MB
main_memory_start 主内存区的起始地址 等于 buffer_memory_end(如果有RAMDISK则再加上其大小)

这三个变量之间的关系可以直观地表示为:

0x00000 ───────────────────────────────────────────── memory_end
  │                                                       │
  ├── 内核代码+数据 ────┤←── 高速缓冲区 ──→←── 主内存区 ──→│
                         │                 │               │
                    buffer_memory_end  main_memory_start   │

为什么要把它们声明为 static?因为它们是 main.c 的内部状态,不应该被其他文件直接访问。其他模块如果需要知道内存大小,应该通过专门的接口函数(如 get_free_page())来间接获取,而不是直接读取全局变量。这种封装性是良好软件工程实践的体现。

2.5 内联系统调用函数(第 52-55 行)

第52-55行生成了四个内联的系统调用包装函数:

    52  static inline _syscall0(int, fork);
    53  static inline _syscall0(int, pause);
    54  static inline _syscall1(int, setup, void *, BIOS);
    55  static inline _syscall0(int, sync);

_syscall0_syscall1 是定义在 include/unistd.h 中的宏。由于第7行定义了 __LIBRARY__,这些宏被激活。它们利用GCC的内联汇编功能,生成调用系统调用的代码。

_syscall0(int, fork) 展开后的效果

我们以 fork 为例,看看 static inline _syscall0(int, fork) 实际上生成了什么代码。宏展开后大致等价于:

static inline int fork(void) {
    long __res;
    __asm__ volatile (
        "int $0x80"           // 触发0x80软中断
        : "=a" (__res)        // 输出:将eax寄存器的值放入__res
        : "0" (__NR_fork)     // 输入:将__NR_fork(值为2)放入eax
        : "memory"            // 告知编译器内存可能被修改
    );
    if (__res >= 0)
        return (int) __res;   // 成功:返回子进程PID或0
    errno = -__res;           // 失败:设置错误码
    return -1;
}

逐条解读这段内联汇编

  1. int $0x80:这是x86的软中断指令。在Linux中,0x80号中断被预留为系统调用入口。当CPU执行这条指令时,它会查找IDT(中断描述符表)中的第0x80项,跳转到 system_call 函数执行。

  2. "=a" (__res):这是GCC内联汇编的输出约束。= 表示输出操作数,a 表示使用 eax 寄存器。这告诉编译器:汇编代码执行完后,eax 中的值应该保存到 __res 变量中。

  3. "0" (__NR_fork):这是输入约束。"0" 是一种简写,表示这个输入操作数应该被放在和输出操作数0(即 =a)相同的寄存器中。所以 __NR_fork(值为2)会被放入 eax。这样在执行 int $0x80 之前,eax 就已经存放好了系统调用号。

  4. "memory":这是clobber约束。它告诉GCC:“这段汇编代码可能会修改内存中的任意数据”。这防止编译器在寄存器中缓存内存变量,确保数据一致性。

_syscall1(int, setup, void *, BIOS) 的区别

_syscall1 多了一个参数。它生成的内联汇编会把第一个参数(这里是硬盘参数表指针)放入 ebx 寄存器。Linux的系统调用约定是:

  • eax:系统调用号
  • ebx:第一个参数
  • ecx:第二个参数
  • edx:第三个参数

所以 setup(BIOS) 调用会设置 eax = __NR_setupebx = BIOS指针,然后执行 int $0x80

这些内联函数的使用场景

函数 系统调用号 使用位置 用途
fork() 2 第91行 创建第一个用户进程(进程1)
pause() 29 第102行 任务0进入空闲等待
setup(BIOS) 0 init() 函数中 挂载根文件系统
sync() 36 init() 函数中 同步文件系统缓冲区

💡 为什么必须用内联汇编?
因为内核不能链接C库。C库本身运行在内核之上——它的 fork() 函数内部也是通过 int $0x80 来调用内核的。如果内核依赖C库来调用系统调用,就会陷入“先有鸡还是先有蛋”的死循环。所以内核必须在内部“手工打造”一套系统调用接口。这种自举能力,是任何操作系统内核都必须具备的基本素质。

2.6 main() 函数体(第 57-103 行)

现在,我们终于来到了核心中的核心——main() 函数。它是整个Linux 0.11内核的第一个C语言函数,也是整个系统初始化的总指挥。

2.6.1 函数签名与环境注释(第57-62行)
    57  void main(void)     /* This really IS void, no error here. */
    58  {                   /* The startup routine assumes (well, ...) this */

第57行的注释非常有趣。通常C语言的 main 函数应该返回 int,但这里的返回类型是 void。Linus特意在注释中强调“This really IS void, no error here.”——他是在回应那些可能会质疑“main不应该返回void”的人。这个函数确实永远不会返回:任务0进入无限 pause() 循环,任务1进入 init() 后也不会返回。如果它们真的返回了,系统也就会陷入未知状态。

第58行的注释提到“startup routine”——指的是 head.shead.s 在最后通过 ret 指令跳转到 main()。这种从汇编到C语言的交接方式,就像接力赛中两人在高速奔跑中传递接力棒,需要双方的精确配合。

    59      /*
    60       * Interrupts are still disabled. Do necessary setups, then
    61       * enable them
    62       */

第59-62行的注释是Linus对当前状态的重要提醒。在 head.s 执行期间,为了安全地进行硬件初始化和分页设置,中断通过 cli 指令被关闭了。此时CPU虽然已经运行在保护模式下,但任何外部硬件中断(时钟、键盘、硬盘等)都不会被响应。

这就像在进行一项精密的外科手术——手术室的门是锁着的,任何人不得进入,直到手术完成。main() 必须先建立好所有的中断处理机制(设置IDT、注册中断处理函数),然后才能在第89行通过 sti() 打开中断。如果顺序反了,中断一发生就会跳转到无效的中断处理程序,导致系统崩溃。

2.6.2 从“信息板”读取数据(第63-64行)
    63      ROOT_DEV = ORIG_ROOT_DEV;
    64      drive_info = DRIVE_INFO;

这两行代码虽然简单,但却是内核从汇编世界“继承遗产”的关键时刻。

第63行:把保存在内存 0x901FC 处的根设备号读取出来,赋值给全局变量 ROOT_DEVORIG_ROOT_DEV 是前面第42行定义的宏,它会直接读取那个物理地址。这个值最初是由 bootsect.s 在引导扇区中设置,然后由 build.c 工具在构建内核镜像时写入的。

第64行:把保存在内存 0x90080 处的32字节硬盘参数表复制到 drive_info 结构体中。由于结构体支持值拷贝,编译器会自动完成内存复制。这个参数表包含了两个硬盘的柱面数、磁头数、每磁道扇区数等物理参数,是后续 sys_setup() 挂载文件系统所必需的。

💡 一个小细节drive_info 的结构体声明(第46行)中只有一个 char dummy[32] 字段。为什么不定义具体的字段结构?因为不同的硬盘控制器可能返回不同格式的参数表。用原始字节数组来接收,然后在使用时再按需解析,是一种更灵活的设计。

2.6.3 内存规划:给物理内存“划地盘”(第65-75行)

这是 main.c 中最重要的一段数值计算代码。它将决定内核如何使用有限的物理内存。

第65-66行:计算总内存并向下对齐

    65      memory_end = (1<<20) + (EXT_MEM_K<<10);
    66      memory_end &= 0xfffff000;

第65行的计算公式:

  • 1<<20 = 1,048,576 = 1MB。这是“常规内存”——所有IBM PC兼容机至少拥有1MB物理内存。
  • EXT_MEM_K 是从 0x90002 读取的扩展内存大小,单位是KB。它由 setup.s 通过 int 0x15, AH=0x88 获取。
  • EXT_MEM_K << 10:将KB转换为字节(1KB = 1024字节,1024 = 2^10)。

假设机器的扩展内存是3072KB(3MB),则:

memory_end = 1,048,576 + 3072 × 1024
           = 1,048,576 + 3,145,728
           = 4,194,304 字节 = 4MB

第66行的 &= 0xfffff000 是一个“向下对齐”操作。0xfffff000 的二进制是32位全1,最低12位全0。按位与操作会将低12位清零,使得 memory_end 向下对齐到4KB(一页)的边界。因为内核的内存管理是以4KB页为单位的,不满一页的零头无法分配,直接舍去。

第67-68行:限制最大为16MB

    67      if (memory_end > 16*1024*1024)
    68          memory_end = 16*1024*1024;

Linux 0.11 只能管理最多16MB物理内存。这是因为在 head.s 中,页目录和4个页表恰好覆盖了前16MB(4个页表 × 1024项/表 × 4KB/页 = 16MB)。如果机器恰好有更多内存,内核也只能使用前16MB。在1991年,16MB已经是非常奢侈的配置了——Linus自己的机器只有4MB。

第69-74行:动态确定缓冲区大小

    69      if (memory_end > 12*1024*1024)
    70          buffer_memory_end = 4*1024*1024;
    71      else if (memory_end > 6*1024*1024)
    72          buffer_memory_end = 2*1024*1024;
    73      else
    74          buffer_memory_end = 1*1024*1024;

这段代码根据物理内存总量,将高速缓冲区划分为三个档位:

物理内存总量 缓冲区大小 设计考量
> 12MB(富裕) 4MB 大缓冲区可缓存更多磁盘数据,极大提升文件I/O性能
> 6MB(中等) 2MB 平衡缓存与用户进程的内存需求
≤ 6MB(紧张) 1MB 内存稀缺,优先保证进程可用内存

这是一个经典的“空间换时间”策略。缓冲区越大,命中率越高,磁盘访问次数越少,系统响应越快。但缓冲区不能无限大——它每占用1MB,用户进程就少1MB可用内存。在内存稀缺的1991年,1MB足以运行一个像样的程序。

Linus选择这三个档位并非随意。让我们做一个简单的计算:

  • 4MB内存的机器(典型低配):1MB内核+缓冲区 + 3MB用户空间。如果缓冲区占用2MB,用户只剩2MB,对多任务来说太紧张了。所以1MB缓冲区是合理选择。
  • 8MB内存的机器(主流配置):内核+2MB缓冲区 + 约6MB用户空间。2MB缓冲区能缓存大量文件数据。
  • 16MB内存的机器(高配):4MB缓冲区 + 约12MB用户空间。这种配置可以让整个Minix文件系统都装进缓冲区。

第75行:确定主内存起始地址

    75      main_memory_start = buffer_memory_end;

主内存区从缓冲区末端开始,一直到 memory_end。所有后续的内存分配(进程创建、内核对象等)都从主内存区中获取。这个起始地址被传递给第79行的 mem_init,它会把主内存区组织成空闲页框链表。

2.6.4 RAMDISK 支持(第76-78行)
    76  #ifdef RAMDISK
    77      main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
    78  #endif

#ifdef RAMDISK 是条件编译指令。只有当 RAMDISK 宏被定义时(在Makefile中通过 -DRAMDISK=xxx 指定),这段代码才会被编译进内核。

rd_init() 函数(在 kernel/blk_drv/ramdisk.c 中实现)会从主内存区头部划出一块区域用作虚拟盘(RAM disk)。虚拟盘是一种把内存当作磁盘来使用的技术——读写虚拟盘实际上就是在读写内存,速度极快,但数据在关机后会丢失。

rd_init() 的返回值是虚拟盘占用的字节数。第77行用 +=main_memory_start 相应地后移,确保主内存区不会与虚拟盘重叠。虚拟盘的大小由 RAMDISK*1024 指定——RAMDISK 宏的值是KB数。

2.6.5 子系统初始化:按“依赖图”有序启动(第79-88行)

这是 main.c 中最密集的初始化调用块。10个函数在11行代码中依次被调用:

    79      mem_init(main_memory_start, memory_end);
    80      trap_init();
    81      blk_dev_init();
    82      chr_dev_init();
    83      tty_init();
    84      time_init();
    85      sched_init();
    86      buffer_init(buffer_memory_end);
    87      hd_init();
    88      floppy_init();

这10个初始化的顺序不是随意的,它体现了严格的依赖关系。让我们逐一分析每个函数的作用以及它在这个顺序中的位置。

第79行 mem_init(main_memory_start, memory_end)

这是必须第一个执行的初始化函数,没有任何争议。mem_initmm/memory.c 中实现,它负责:

  1. main_memory_start 开始,到 memory_end 为止,将整个主内存区划分为4KB的物理页框。
  2. 为每个页框建立一个 mem_map 条目(一个字节的引用计数),初始值为 FREE(0)。
  3. 将所有空闲页框链接成一个链表,供后续的 get_free_page() 函数分配。

mem_init 传递了两个参数——main_memory_startmemory_end。这两个值在 main.c 的第65-75行被计算出来。在执行 mem_init 之前,内核虽然知道自己有多少内存,但还没有任何管理机制——内存就像一堆散沙。mem_init 之后,内核有了一个井井有条的“内存仓库”,可以随时分配和回收物理页面了。

第80行 trap_init()

trap_initkernel/traps.c(或 kernel/asm.s)中实现。它的任务是设置IDT(中断描述符表),将各种CPU异常和陷阱的处理函数注册到IDT中。在 head.s 中建立的临时IDT(256个哑中断门全部指向 ignore_int)被替换为真正的处理函数:

  • 除零错误(int 0):divide_error
  • 调试异常(int 1):debug
  • NMI(int 2):nmi
  • 断点(int 3):int3
  • 溢出(int 4):overflow
  • 边界检查(int 5):bounds
  • 无效操作码(int 6):invalid_op
  • 设备不可用(int 7):device_not_available
  • 双重错误(int 8):double_fault
  • 无效TSS(int 10):invalid_TSS
  • 段不存在(int 11):segment_not_present
  • 堆栈异常(int 12):stack_segment
  • 一般保护性错误(int 13):general_protection
  • 缺页异常(int 14):page_fault
  • 协处理器错误(int 16):coprocessor_error

此外,trap_init 还设置了系统调用入口(int 0x80),指向 system_call 函数。这是用户程序与内核交互的唯一通道。

第81行 blk_dev_init()

blk_dev_initkernel/blk_drv/ll_rw_blk.c 中实现。它初始化块设备的请求队列。块设备(硬盘、软盘、虚拟盘)的读写操作不是立即执行的,而是先放入一个请求队列中,然后由设备驱动程序异步处理。

这个函数必须在 hd_initfloppy_init 之前执行,因为它建立的请求队列是硬盘和软盘驱动工作的基础——如果队列还没建好,驱动初始化时试图注册请求将导致错误。

第82行 chr_dev_init()

字符设备(键盘、串口、控制台等)的初始化。在Linux 0.11中,这个函数实际为空——它只是预留了扩展点。真正的字符设备初始化在后续的 tty_init() 中完成。

第83行 tty_init()

终端(TTY)子系统的初始化。TTY是Teletypewriter的缩写,源自早期的电传打字机。在Linux中,TTY子系统负责处理控制台、串口终端等字符设备。tty_init 会初始化控制台驱动和串口驱动,设置输入输出缓冲区。

main.c 的上下文中,tty_init 非常重要——它初始化了控制台终端,使得后续的 printk 输出能够真正显示在屏幕上。在 tty_init 执行之前,printk 的输出只是被写入缓冲区,不会真正出现在屏幕上。

第84行 time_init()

系统时钟初始化。time_init 通过读取CMOS芯片(主板上的实时钟芯片)获取当前日期和时间,然后用 kernel_mktime 转换为UNIX时间戳,存入 startup_time 全局变量。

CMOS芯片使用端口 0x70(地址端口)和 0x71(数据端口)来访问。time_init 会通过 outbinb 指令从CMOS中读取年、月、日、时、分、秒,然后构造出 struct tm 结构,交给 kernel_mktime 计算时间戳。

此外,time_init 还会初始化时钟中断(IRQ0)的处理程序。时钟中断是操作系统的“心跳”,每秒钟触发约100次(在Linux 0.11中通常是每10ms一次)。每次时钟中断发生时,内核会更新系统时间、检查是否有定时器到期、触发进程调度。

第85行 sched_init()

进程调度初始化。这是整个多任务系统的基石。sched_initkernel/sched.c 中实现,它的核心工作是:

  1. 初始化任务0(当前运行的代码)的进程控制块(task_struct)。任务0是系统中第一个任务,也称为“空闲任务”。
  2. 为任务0设置TSS(任务状态段)和LDT(局部描述符表)。TSS用于保存任务的寄存器状态,LDT定义了任务私有的代码段和数据段。
  3. 设置时钟中断处理程序,使得每次时钟中断触发时,调度器有机会检查是否需要切换任务。

sched_init 必须在 fork() 调用之前执行。因为 fork() 会复制当前任务的 task_struct,如果这个结构还没有初始化好,fork() 就无从下手。

第86行 buffer_init(buffer_memory_end)

高速缓冲区初始化。buffer_initfs/buffer.c 中实现。它接收 buffer_memory_end 作为参数(即缓冲区的大小),然后:

  1. 将缓冲区内存区域划分为大小为1024字节(1KB)的缓冲块。每个缓冲块可以缓存一个磁盘块的数据。
  2. 将所有空闲缓冲块链入一个双向链表,供后续的块设备读写操作使用。

缓冲区的块大小(1KB)与Minix文件系统的块大小一致,确保了磁盘块和缓冲区块的一对一映射。

buffer_init 必须在 hd_initfloppy_init 之前执行,因为硬盘和软盘驱动在初始化时需要从缓冲区池中分配缓冲块来作为DMA传输的目标。

第87行 hd_init()

硬盘驱动初始化。hd_initkernel/blk_drv/hd.c 中实现。它会:

  1. 设置硬盘中断处理程序(IRQ14)。
  2. 通过向硬盘控制器发送命令,获取硬盘的几何参数(柱面数、磁头数、扇区数)。
  3. 初始化硬盘请求队列。

第88行 floppy_init()

软盘驱动初始化。floppy_initkernel/blk_drv/floppy.c 中实现。类似硬盘驱动,它会设置软盘中断处理程序(IRQ6),并初始化软盘控制器。

依赖关系总览

这10个初始化函数的依赖关系可以用下面这张图来清晰表示:

第79行: mem_init
内存管理初始化
必须先执行

第80行: trap_init
中断向量初始化

第86行: buffer_init
缓冲区初始化

第85行: sched_init
进程调度初始化
依赖时钟中断

第87行: hd_init
硬盘驱动

第88行: floppy_init
软盘驱动

第81行: blk_dev_init
块设备请求队列

第82行: chr_dev_init
字符设备

第83行: tty_init
终端初始化

第84行: time_init
时钟初始化

从图中可以看出,mem_init 处于绝对的根位置——所有其他初始化都依赖于内存管理的就绪。trap_init 是第二层——中断系统必须在设备驱动之前初始化。设备驱动(hd_initfloppy_init)处于最末梢,它们几乎依赖前面所有的模块。

2.6.6 开启中断——系统“活”过来的瞬间(第89行)
    89      sti();

这是整个内核初始化过程中最激动人心的一行代码。sti 是x86的机器指令,用于设置EFLAGS寄存器中的IF(中断允许)标志。执行这一行之后,CPU开始响应外部硬件中断。

sti() 之前的世界,CPU就像一个在隔音室中工作的人——外面的声音(键盘敲击、时钟滴答、硬盘就绪信号)一概听不到。sti() 就像打开了隔音室的门,世界的声音瞬间涌入。

为什么要把 sti() 放在这么靠后的位置?

因为中断处理程序在中断发生的那一刻就会被调用。如果某个中断的处理程序还没有注册好(比如硬盘中断处理程序还没有被 hd_init 设置),但中断却发生了,CPU就会跳转到一个无效的中断门,触发“一般保护性错误”(General Protection Fault),导致系统崩溃。

这就像在医院开张之前,必须确保所有科室的医生都已到岗。如果在医生到岗之前就开放急诊,病人来了却找不到医生,医院就会陷入混乱。同样,在所有中断处理程序就绪之前开启中断,系统就会陷入混乱。

sti() 之后会发生什么?

最可能立即发生的是时钟中断。时钟芯片以固定的频率(通常是100Hz)向CPU发送中断信号。sti() 之后的下一个时钟滴答就会触发IRQ0,调用 do_timer() 函数,然后触发进程调度。由于此时只有任务0一个进程,调度器会继续选择任务0运行。

键盘中断和硬盘中断也可能随时发生。当用户敲击键盘时,IRQ1被触发,键盘中断处理程序将按键码读入缓冲区。当硬盘完成上一次读取操作时,IRQ14被触发,硬盘中断处理程序将数据从控制器缓冲区复制到内存。

2.6.7 从内核态到用户态的“降维打击”(第90行)
    90      move_to_user_mode();

这是整个 main.c 中最具“魔术”色彩的一行。它只有一句,却完成了从内核态(特权级0)到用户态(特权级3)的切换。

这个宏的定义来自 include/asm/system.h,我们来看一下它的完整展开:

#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \
         "pushl $0x17\n\t" \
         "pushl %%eax\n\t" \
         "pushfl\n\t" \
         "pushl $0x0f\n\t" \
         "pushl $1f\n\t" \
         "iret\n" \
         "1:\tmovl $0x17,%%eax\n\t" \
         "movw %%ax,%%ds\n\t" \
         "movw %%ax,%%es\n\t" \
         "movw %%ax,%%fs\n\t" \
         "movw %%ax,%%gs\n\t" \
         ::: "ax")

这段代码的核心思想:利用 iret 指令的特性来降低特权级。

iret(Interrupt Return)是中断返回指令。当CPU执行 iret 时,它会从堆栈中按顺序弹出:

  1. EIP(指令指针)
  2. CS(代码段选择子)
  3. EFLAGS(标志寄存器)

如果弹出的 CS 的RPL(请求特权级)低于当前的CPL(当前特权级),CPU还会额外弹出:
4. ESP(堆栈指针)
5. SS(堆栈段选择子)

并切换到新的特权级。这正是 move_to_user_mode 要利用的特性。

逐条指令解释

  1. movl %%esp, %%eax:保存当前的栈指针到 eax

  2. pushl $0x17:压入用户态堆栈段选择子(0x17 = 二进制 0001 0111,表示LDT索引2,RPL=3)。

  3. pushl %%eax:压入之前保存的栈指针(作为用户态的ESP)。

  4. pushfl:压入当前的EFLAGS。

  5. pushl $0x0f:压入用户态代码段选择子(0x0f = 二进制 0000 1111,表示LDT索引1,RPL=3)。

  6. pushl $1f:压入标签 1: 的地址(作为用户态的EIP)。

  7. iret:执行中断返回。CPU从堆栈中弹出EIP、CS、EFLAGS、ESP、SS,进入用户态,并跳转到标签 1:

  8. 1: 标签后的指令:将DS、ES、FS、GS都设置为用户态数据段选择子 0x17

选择子的含义

  • 0x0f:Index=1(LDT[1]),TI=0(GDT),RPL=3。这是 sched_init() 中为任务0设置的用户代码段。
  • 0x17:Index=2(LDT[2]),TI=0(GDT),RPL=3。这是任务0的用户数据段。

堆栈帧的布局

执行 iret 前,堆栈(从高到低):
+------------------+
| SS = 0x17        | ← 栈顶(iret 首先弹出)
+------------------+
| ESP = 原ESP      |
+------------------+
| EFLAGS           |
+------------------+
| CS = 0x0f        |
+------------------+
| EIP = 1f         |
+------------------+

执行 move_to_user_mode()main.c 的后续代码(包括第91行的 fork() 调用和第101-102行的 pause() 循环)都在用户态(Ring 3)运行。这是任务0——系统中的第一个用户态任务,也是所有后续用户进程的“模板”。

用户态 Ring 3

内核态 Ring 0

iret 切换特权级

fork()

main() 开始执行

内存规划 + 子系统初始化

sti() 开启中断

move_to_user_mode()

任务0: fork() + pause()

任务1: init() → /bin/sh

2.6.8 创建第一个用户进程:fork()init()(第91-93行)
    91      if (!fork()) {      /* we count on this going ok */
    92          init();
    93      }

第91行fork() 是UNIX中最神奇的系统调用。它被调用一次,但返回两次——在父进程中返回子进程的PID(正整数),在子进程中返回0。

因此,if (!fork()) 这个条件判断精妙地分离了父子进程的执行路径:

  • 子进程(fork返回0)!0 = 真,进入 if 块,执行第92行的 init() 函数。子进程成为进程1(init进程)。
  • 父进程(fork返回PID>0)!正数 = 假,跳过 if 块,执行第101行的 for(;;) pause();。父进程保持为任务0(空闲进程)。

第92行init() 函数在 main.c 的末尾定义(约在第168行)。它是进程1的入口函数,负责:

  1. 调用 setup() 系统调用,挂载根文件系统。
  2. 打开 /dev/tty0 控制台终端作为标准输入、标准输出和标准错误。
  3. 创建子进程运行 /etc/rc 启动脚本。
  4. 进入无限循环,不断创建子进程运行 /bin/sh,等待用户登录。

💡 为什么用 if (!fork()) 而不是 if (fork() == 0)

这是一个C语言风格的约定。fork() 成功时,子进程中返回0(逻辑假),父进程中返回正数(逻辑真)。因此 !fork() 在子进程中为真,在父进程中为假。这种写法比 fork() == 0 更简洁,也是UNIX老手的惯用写法。Linus的注释说“we count on this going ok”——他表示完全信任 fork() 能够正常工作。

2.6.9 任务0的永恒职责:空闲循环(第94-102行)
    94      /*
    95       *   NOTE!! For any other task 'pause()' would mean we have to get a
    96       *   signal to awaken, but task0 is the sole exception (see 'schedule()')
    97       *   as task 0 gets activated at every idle moment (when no other tasks
    98       *   can run). For task0 'pause()' just means we go check if some other
    99       *   task can run, and if not we return here.
   100       */
   101      for(;;)
   102          pause();

第94-100行:Linus的这段注释是他代码风格的典型体现。他特意解释了为什么任务0的 pause() 行为与其他任务不同。

通常,pause() 系统调用会让进程进入 TASK_INTERRUPTIBLE 状态(可中断睡眠),直到收到一个信号才会被唤醒。但任务0是特殊的——在 schedule() 调度函数中,如果遍历了整个任务数组都没有找到可运行的任务,调度器会直接选择任务0,而不检查它是否处于睡眠状态。

因此,任务0的 pause() 实际上不会让它进入真正的睡眠——它只是触发了一次调度检查:“有没有别的任务需要运行?如果有,就切过去;如果没有,我继续。” 这就是为什么Linus说“For task0 pause() just means we go check if some other task can run, and if not we return here.”

第101-102行for(;;) 是C语言中无限循环的标准写法(等价于 while(1))。任务0永远在这个循环中运行,只有在其他任务就绪时才会被调度出去。

💡 任务0的意义:在现代操作系统中,这个角色被称为“idle task”(空闲任务)。当CPU没有正事可做时,它运行空闲任务。在现代Linux中,空闲任务还会执行一些省电操作(如HLT指令让CPU暂停),但在1991年,任务0只是简单地不断检查是否有新任务可以运行。

第三部分:全局回顾——完整的代码执行路径

现在,让我们把整个 main.c 的执行路径串联起来,形成一张完整的流程图:

子进程 (返回0)

父进程 (返回PID>0)

head.s 通过 ret 跳转到 main()

第63-64行: 读取根设备号和硬盘参数
ROOT_DEV = ORIG_ROOT_DEV
drive_info = DRIVE_INFO

第65-68行: 计算物理内存总量
memory_end = 1MB + 扩展内存
向下对齐到4KB,限制 ≤ 16MB

第69-74行: 根据内存大小
动态确定缓冲区容量
>12MB → 4MB, >6MB → 2MB, ≤6MB → 1MB

第75行: main_memory_start = buffer_memory_end

第76-78行: 可选 RAMDISK 初始化
main_memory_start += rd_init(...)

第79行: mem_init
初始化主内存区页框管理

第80行: trap_init
设置IDT,注册CPU异常和系统调用

第81-84行: 块设备/字符设备/TTY/时钟
blk_dev_init, chr_dev_init, tty_init, time_init

第85行: sched_init
初始化任务0的PCB,设置调度器

第86行: buffer_init
初始化高速缓冲区链表

第87-88行: hd_init, floppy_init
硬盘和软盘驱动初始化

第89行: sti
★ 开启中断!系统正式“活”了

第90行: move_to_user_mode
通过伪造的iret栈帧
★ 从内核态切换到用户态

第91行: if (!fork())

第92行: init()
★ 进程1诞生
挂载根文件系统
启动 /bin/sh

第101-102行: for(;;) pause()
★ 任务0进入空闲循环
CPU无事可做时运行

系统就绪,用户可交互

从上图可以清晰地看到,main.c 的执行分为两个明显的大阶段:

内核态阶段(第63-89行):所有硬件初始化、子系统设置、中断开启都在内核态完成。这是一个“建设期”——系统的所有基础设施都在这个阶段搭建完毕。

用户态阶段(第90行之后):通过 move_to_user_mode() 切换到用户态,然后通过 fork() 分裂为两个进程。任务0成为空闲进程,任务1启动用户空间。

第四部分:总结与思考

main.c 的设计哲学

回顾这103行代码,我们可以提炼出以下几个核心设计原则:

  1. 简约而不简单:103行代码涵盖了内存管理、中断系统、设备驱动、进程调度、模式切换等所有子系统。没有一行多余,每一个变量、每一个函数调用都有其不可替代的作用。

  2. 模块化设计:每个子系统(内存、中断、驱动、调度)都有独立的初始化函数。main.c 不关心这些函数的内部实现,只负责按正确顺序调用它们。这种职责分离使得内核代码在只有200行的规模时就具备了良好的可维护性。

  3. 依赖有序:初始化顺序经过精心设计,确保每个函数在执行时,它所依赖的下层模块已经就绪。mem_init 第一个执行,sti() 最后一个执行,中间的10个函数按依赖关系排列。如果顺序错了,系统会在启动时崩溃。

  4. 自举能力:通过 __LIBRARY__ 宏和内联汇编,内核在没有用户空间、没有C库的情况下,依然能够调用 fork()pause() 等系统调用。这种“自己举起自己”的能力,是操作系统内核区别于普通应用程序的根本特征。

  5. 硬件抽象:关键参数(内存大小、硬盘参数、根设备号)从BIOS和引导扇区获取,而不是硬编码。这使得同一个内核镜像可以在不同配置的机器上运行。

与现代内核的对比

今天,Linux内核已经膨胀到数千万行,但它的初始化灵魂与1991年的 main.c 一脉相承。在最新版内核中,init/main.c 中的 start_kernel() 函数仍然按照类似的顺序调用各个初始化函数:

start_kernel()
  → mm_init()          // 内存管理
  → sched_init()       // 进程调度
  → trap_init()        // 中断和异常
  → blk_dev_init()     // 块设备
  → chr_dev_init()     // 字符设备
  → ...
  → rest_init()        // 创建init进程

30多年过去了,函数名基本没变,调用顺序基本没变,只是每个函数内部变得无比复杂。这就是经典设计的生命力——核心架构足够稳固,可以支撑数十年的演化。

实践延伸

如果你有自己的Linux 0.11实验环境,可以尝试以下实验:

  1. mem_init 之后添加 printk("Hello from kernel!\n"),重新编译运行,观察输出。
  2. 修改第67行的16MB限制为一个更小的值(如4MB),观察系统如何对待超过限制的内存。
  3. 将第89行的 sti() 注释掉,观察系统行为——你会看到时钟不更新、键盘无响应,因为所有中断都被屏蔽了。
  4. if (!fork()) 前后添加 printk 调试信息,观察父子进程的不同执行路径。

终章:从第一个 C 函数到第一个 Shell

main.c 的旅程到此结束。我们已经从第1行的版权声明,一行行走到了第103行的 pause()。这个只有103行的文件,是 Linux 内核从汇编世界进入 C 语言世界的“入境章”,是后续所有内核服务的启动器,是任务0和任务1的共同祖先。

下一站,我们将跟随 init() 函数,走进 /etc/rc/bin/sh 的诞生,看第一个用户进程如何让系统真正“活”起来,如何从内核态的“独裁”走向用户态的“民主”。


交互指南

  • 如何运行这段代码? 需要完整的 Linux 0.11 内核源码和编译环境(GCC 1.40 或现代 GCC 加补丁),在 Bochs 或 QEMU 中运行编译出的内核镜像。
  • 如何验证结果? 在 Bochs 中启动后,观察屏幕上的 printk 输出(如 “Free mem: xxx bytes”)。如果出现 “Kernel panic: Unable to mount root fs”,说明根设备号或文件系统有问题。
  • 常见错误排查
    • 编译错误:检查是否定义了 __LIBRARY__(第7行),否则 _syscallN 宏不会被激活。
    • 链接错误:确保 kernel/ 下的各个子模块(sched.otraps.ohd.o 等)都已正确编译。
    • 启动卡死:可能是 sti() 之后某个未注册的中断被触发了。检查 trap_inithd_init 是否已正确执行。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐