第 1 天:敲下 `./hello` 后,程序是怎么跑起来的?
我们先不谈“操作系统有哪些功能”。今天只追一件事:在终端输入 ./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 创建子进程后,子进程是怎样变成另一个程序的?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)