我们先不谈“操作系统有哪些功能”。今天只追一件事:在终端输入 ./hello,直到屏幕出现一行字,中间发生了什么?

这个过程会碰到操作系统最重要的几个概念:进程、内存、系统调用和 CPU 调度。把它看明白,后面的文章才有地方落脚。

本文的命令在 Linux 或 WSL 中运行。

先准备一个很小的程序

把下面的代码保存为 hello.c:

#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("我的进程号是 %ld\n", (long)getpid());
    fflush(stdout);
    sleep(10);
    puts("程序结束");
    return 0;
}

编译并运行:

gcc -o hello hello.c
./hello

它先打印进程号,等十秒,再打印“程序结束”。代码很短,但从输入命令到程序退出,电脑已经做了不少事。

第一步:终端里的命令,由 Shell 接收

终端是显示文字和接收输入的窗口;读懂 ./hello 这条命令的是 Shell,例如 Bash。

./hello 表示“运行当前目录下名为 hello 的文件”。Shell 首先得找到它,并确认它可以执行。随后,在通常情况下,Shell 会启动一个子进程,让子进程运行 hello,自己则等待它结束。

这里要分清两个动作:

  • 创建进程:出现一个新的运行单位。
  • 运行另一个程序:把这个进程原本要执行的程序换成 hello。

在 Linux 中,Shell 通常通过 fork 创建子进程,再由子进程调用 execve 加载新程序。execve 本身不会再创建一个进程:它更像是让已有的进程换一套程序代码和数据。我们明天会专门写代码验证这件事。

第二步:磁盘上的文件,变成内存中的进程

编译后的 hello 是磁盘上的一个可执行文件。只要不运行,它就只是文件,不会自己占用 CPU。

运行时,操作系统读取可执行文件中的信息,知道程序代码和数据应该怎样放进进程的地址空间,还会准备命令行参数、环境变量等内容。如果程序使用了共享库,动态加载器还需要把相关的库加载进来。准备完成后,程序才会运行到我们写的 main 函数。

所以,程序和进程不是一回事:

程序是存放在磁盘上的代码和数据;进程是程序的一次运行。

同一个 hello 文件可以运行两次,产生两个进程。它们执行同一份程序,却有不同的进程号,也各自拥有运行时的数据。

操作系统还会给进程安排自己的虚拟地址空间。程序使用内存地址时,看到的是这套属于自己的地址;地址怎样对应到实际内存,由操作系统和硬件共同处理。这也是两个程序通常不会因为使用了相同的地址数字,就改到对方数据的原因。

第三步:printf 为什么能把字显示出来?

运行进入 main 后,程序执行:

printf("我的进程号是 %ld\n", (long)getpid());

printf 是 C 标准库提供的函数。它负责把数字等内容整理成要输出的字符,但把字符送到终端,需要操作系统帮忙。代码里的 fflush(stdout) 让缓冲区中的文字立刻写出;最终可以看到类似 write 这样的系统调用。

系统调用,是程序向操作系统内核请求服务的入口。程序大部分时间运行在用户态;执行系统调用时,CPU 转入内核态,由内核处理请求,然后再返回程序。

为什么要绕这一步?因为普通程序不能随意控制设备,也不能想读哪块内存就读哪块。程序提出请求,内核检查并处理,才能让多个程序在同一台电脑上有序运行。

有个容易误解的地方:**操作系统并不是逐行代替程序执行代码。**像计算 1 + 1 这样的普通指令,CPU 可以直接执行。程序需要读文件、输出内容,或者等待某个事件时,才会通过相应机制与内核打交道。

第四步:等待十秒时,CPU 在干什么?

hello 接下来调用 sleep(10)。它要等十秒,但没有必要霸占 CPU,自己反复查看时间到了没有。

操作系统可以让这个进程暂时处于等待状态,把 CPU 留给浏览器、编辑器或其他任务。时间到了,再让它获得运行机会,继续执行 puts("程序结束")。

这就引出了调度:当多个任务都想运行时,操作系统要决定谁在什么时候使用 CPU。我们在第 3 天细讲。

最后,main 返回,进程结束。操作系统回收它占用的资源,并让等待它的 Shell 得知执行结果。Shell 随后重新显示命令提示符,准备接收下一条命令。

用两个命令亲眼看看

先观察程序向内核发出的部分请求:

strace -e trace=execve,openat,mmap,write ./hello

输出会因系统而异,但可以留意这几种调用:

  • execve:开始执行 hello。
  • openat、mmap:启动过程中打开、映射程序需要的内容,例如共享库。
  • write:把准备好的文字写到终端。

strace 展示的是系统调用,不是程序执行的每一行代码。因此,你能看到一次 write,却不会在输出里找到 main 中每一步计算。

还可以趁程序等待的十秒,看看它的地址空间。在一个终端运行 ./hello,记下打印的进程号;在另一个终端运行:

cat /proc/进程号/maps

把“进程号”换成实际数字。输出中能找到程序本身、共享库、栈等映射区域。这里显示的是虚拟地址空间的布局,不是“这段程序已占用了同样大小的物理内存”。

如果不熟悉这些输出,今天只需确认两件事:程序启动时确实会向内核请求服务;运行中的进程也确实有自己的地址空间。每一行输出的含义,后面讲内存和文件时再拆开。

把全过程连起来

输入 ./hello
    ↓
Shell 找到文件,启动子进程
    ↓
子进程加载 hello,准备地址空间
    ↓
程序进入 main
    ↓
通过系统调用输出文字
    ↓
等待十秒,其间 CPU 可以运行其他任务
    ↓
程序退出,Shell 重新接收命令

现在再看“操作系统管什么”,答案就具体多了:它让磁盘上的程序能够运行,给进程提供地址空间,为程序处理输出请求,并安排等待中的任务何时继续运行。

明天我们抓住其中最容易混淆的一步:Shell 创建子进程后,子进程是怎样变成另一个程序的?

Logo

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

更多推荐