引子:当内核完成初始化,一个“空壳”系统是如何变成可用的交互环境的?

在前面的代码分析中,我们见证了 Linux 0.11 内核如何从冰冷的硬件端口读取时间,如何利用 fork()exec() 创建进程,并通过 setsid() 建立全新的会话期。然而,在 init/main.c 的最后,我们看到的是这样一个简单的动作:exit(execve("/bin/sh", argv, envp));

你或许会问,难道这就是 Linux 的全部初始化吗? 直接启动一个 Shell 外壳,剩下的都交给用户自己折腾?

事实上,Linux 0.11 这种直截了当的方式,更像是一个**“为了演示内核已正常工作的技术展示”。在真实的生产环境和现代 Linux 系统中,从内核态切入到用户态交互,有一场漫长的“接力赛”**。参与这场接力的选手包括:init 进程、getty 程序、login 程序以及最终的 shell。今天,我们就来深入探讨这个从“内核向用户移交控制权”的经典流程。

一、 内核的“终极任务”与 PID 1 的诞生

init/main.c 中,内核完成了硬件初始化、中断设置、内存分页管理和进程调度器的初始化。当 move_to_user_mode() 执行完毕时,系统实际上已经运行在第一个用户进程——进程 0(idle 进程) 的上下文中了。
随后,进程 0 调用了 fork(),诞生了 进程 1(init 进程)。本书中的 Linux 0.11 让进程 1 直接执行 execve 去加载 /bin/sh,这是非常粗糙的做法。
在真实的类 UNIX 操作系统中,PID 1(Init 进程)是系统所有用户进程的“始祖”。它肩负着一项极为神圣且繁重的使命:统筹管理系统中所有(除内核线程外)的用户空间进程

这意味着,PID 1 绝对不应该是一个简单的 Shell。在典型的 Linux 发行版中,PID 1 通常是著名的 /sbin/init 程序(在现代 Linux 中,可能会被 systemd 代替,但核心功能一致)。这个 init 程序就是用户态环境初始化的“总指挥”。

渲染错误: Mermaid 渲染失败: Parse error on line 4: ...核] --> Fork1[创建进程 1 (PID=1)] For -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

观察上面的流程图:
内核只负责“生”出进程 1。而进程 1 的子进程们通过层层替换(execve),最终才演化成了用户面前的命令行环境。这就是现代 Linux 登录流程的核心骨架。

二、 司令官的职责:Init 进程如何管理终端与孤儿进程?

书中的描述非常精彩,它说明了 init 进程需要承担以下几个关键角色:

2.1 为每个终端“种下”一个守护进程

在 Linux 0.11 那个年代,计算机往往连接着多个物理串口终端(TTY)。init 进程会读取位于 /etc/inittab 的配置文件。这个文件告诉 init:“在这个系统上,哪个终端(例如 /dev/tty1, /dev/tty2 或串口 ttyS0)是允许用户登录的。”
对于每一个允许登录的终端,init 都会进行一次 fork(),在子进程中调用 execve 启动 getty(或 agetty 程序。而 init 本身,则在一个无限循环中调用 wait() 系统调用,挂起自己,随时等待子进程的“死讯”。

2.2 孤儿进程的“灵魂归宿”

书中特别强调了一个概念:孤儿进程

“在 Linux 中所有的进程必须属于单棵进程树,所以孤立进程必须被收取。”

这个设定非常精妙。试想一个场景:

  1. 你在终端启动了 A 进程,A 进程又 fork() 出了 B 进程,然后 A 进程突然因为某种原因崩溃退出了。
  2. 此时,B 进程的父进程(A)已经消失,B 变成了“无父无母”的孤儿进程。
  3. 如果内核不干预,B 进程退出时将无人替它“收尸”(回收其 PCB),这就是“僵尸进程”的隐患。
  4. Linux 内核的解决方案是: 当一个进程的父进程先于它退出,内核会立刻将这个孤儿进程的“养父”修改为 init 进程(PID 1)
  5. 因此,只要 init 进程一直活着,系统里就不会有真正无法被回收的进程。每当这些“过继”过来的孤儿进程终止,initwait() 就会感知到,并回收它们占用的资源,维持操作系统的稳态。

2.3 系统关闭的“清道夫”

当管理员执行 shutdown 命令时,实际上是通过向 init 发送特定的信号。init 接收到关机信号后,会反序遍历它的子进程树,向所有属于它的子孙进程发送 SIGTERM 信号,要求它们优雅地终止。在确保所有用户进程都已退出后,它会卸载所有文件系统,并通知内核停止 CPU 的运行。

三、 迎宾员:getty 的使命

init 为某个终端 fork() 了一个子进程,并且 execve 加载了 getty 之后,getty 就开始上岗了。

getty 的核心职责有三项:

  1. 硬件驯化:它需要设置串口(或者虚拟控制台)的通信参数,例如波特率(对于串口终端)、数据位、停止位等,确保物理链路通畅。
  2. 武装亮相getty 会读取 /etc/issue 文件(这个文件里通常写着 Linux 发行版的名称和版本号,如 "Welcome to Linux 0.11"),并将其打印在终端屏幕上。
  3. 抛砖引玉:它打印出最重要的提示信息:"login: "。此时,getty 进程会阻塞在 read() 系统调用上,等待键盘的输入。

一旦用户输入了用户名(例如 root)并按下了回车键,getty 的任务就圆满结束了。它立刻调用 execve("/bin/login", ...),将自己完全替换为 login 程序。

💡 关键点: getty 进程并没有退出,而是直接通过 execve 变成了 login 进程。进程的 PID 没变,但程序的“灵魂”被替换了。这种方法避免了不必要的进程销毁和创建开销。

四、 验票员:login 程序的检查与切换

login 接手过来后,它绝对不会让用户直接进入系统。它要做一次非常严格的“验票”。

4.1 密码验证

login 会提示用户输入密码。为了安全,它通常不会直接把明文密码传给内核,而是调用 getpass() 函数屏蔽终端的回显(用户敲键盘时屏幕上不显示任何字符)。
随后,login 会打开 /etc/passwd 文件,根据刚才 getty 传来的用户名,查找到该用户的记录。它把用户输入的明文密码,通过某种单向哈希加密算法(早期 Linux 使用 DES,现代使用 SHA-512,Linux 0.11 时代可能使用传统的 crypt 函数)进行加密,然后与 /etc/passwd 文件中存储的加密密文进行比对。
如果解密失败或者密码不匹配,login 会退出(返回错误码 1)。此时,父进程 initwait() 捕获到退出信号,会再次 fork() 一个子进程,再次运行 getty,让用户重新输入用户名和密码,形成一个循环。

4.2 构建用户环境

一旦验证通过,login 会立刻开始为用户构建专属的环境:

  • 切换目录:把当前进程的工作目录切换到用户在 /etc/passwd 中指定的家目录(例如 /home/root)。
  • 设置权限:根据口令文件设置进程的组 ID 和用户 ID。
  • 设置环境变量:它会初始化最基本的变量,例如:
    • HOME=/home/root (家目录)
    • SHELL=/bin/bash (用户默认使用的 Shell)
    • USER=root (用户名)
    • PATH=/bin:/usr/bin (查找命令的路径)
  • “欢迎信息”:它会在屏幕上打印 /etc/motd(每日消息,Message Of The Day)的内容,并检查 /var/spool/mail/root 文件,告诉用户是否有未读的新邮件。

4.3 执行登录 Shell

当环境准备好后,login 会调用 execve 执行口令文件中指定的 Shell(如果没有指定,就会使用默认的 /bin/sh)。它传递的参数 argv 中有一个非常重要的细节:argv[0] 的第一个字符必须是 -

五、 最终的主角:登录 Shell 与配置文件

当 Shell 程序启动后,它会首先检查参数 argv[0][0]。如果发现它是一个减号 -,Shell 就会知道自己是被作为一个 “登录 Shell” 运行的。这与普通用户在图形终端里打开一个 xterm 运行的 Shell 不同,登录 Shell 肩负着“初始化整个交互环境”的重任。

5.1 脚本加载链

登录 Shell 会先后执行以下脚本:

  1. /etc/profile:这是系统级别的全局配置文件。在这里,系统管理员可以设置对所有用户生效的环境变量(如 PATHumask 等)。
  2. ~/.profile:这是当前用户在自己家目录下的个人配置文件。用户可以将自己的个性化别名、自定义 PATH 配置写在这里。
  3. ENV 环境变量配置:如果在 ~/.profile 中定义了 ENV 环境变量,Shell 下一步会加载该变量指定的配置文件。

5.2 用户交互的起点

一旦这些初始化脚本运行完毕,Shell 就会打印出命令提示符,如 [plinux root]# _
从此,用户与计算机的正式对话开始了。用户在终端上输入的每一条命令(例如 ls -lgrep),实际上都是 Shell 通过 fork()execve() 创建出一个子进程并等待其完成的过程。

六、 流程总览:Mermaid 图解登录接力赛

为了直观地展示从 init 到 Shell 的整个执行流,我们将书中图 4-5 进行重构,结合上面分析的进程替换与状态转移,绘制出如下 Mermaid 流程图:

渲染错误: Mermaid 渲染失败: Parse error on line 3: ...rkIdle[任务0 fork出 任务1(PID=1)] --> Exe -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

结语:从内核到用户的漫长接力

在 Linux 0.11 中,虽然为了精简,作者让 init 进程直接 execve/bin/sh,但这仅仅是为了演示目的。在真实的操作系统中,必须经历 init -> getty -> login -> shell 这样层层递进的“接力赛”,才能在保证系统安全性(密码验证)隔离性(进程组与控制终端)、**可配置性(/etc/inittab 和配置文件)**的基础上,为用户提供一整套完整的交互环境。

这一层层的守护机制,让 Linux 能够从一个纯粹的内核态程序,顺滑地过渡到支持多用户、多终端、安全隔离的通用操作系统。现在,我们的 Linux 0.11 试验场终于正式向用户开放了!

Logo

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

更多推荐