目录
引言:
一、冯诺依曼体系结构
1.1 什么是冯·诺依曼体系结构
1.2 IO 的本质:站在内存的角度理解
1.3 为什么程序要运行必须先加载到内存
1.4 数据流动的本质
1.5 为什么需要内存作为中转
1.6 实例:QQ 聊天的数据流动
二、操作系统
2.1 概念
2.2 设计 OS 的目的
2.3 核心功能
2.4 如何理解"管理"
2.5 系统调用和库函数
3.进程
3.1 基本概念
3.2 描述进程:task_struct
3.3 查看进程
3.3.1 创建并运行一个进程
3.3.2 获取进程 ID:getpid
3.3.3 使用 ps 命令查看进程
3.3.4 终止进程
3.3.5 通过 /proc 目录查看进程
3.3.6 获取父进程 ID:getppid
3.4 通过代码创建子进程:fork
3.4.1 fork 初识
3.4.2 fork 的返回值
3.4.3 深入理解 fork 的三个问题
编辑
结语:
引言:
作为程序员,我们每天都在与代码打交道,但你是否真正理解代码背后的计算机是如何工作的?从按下电源键到程序运行,从键盘输入到屏幕输出,数据在计算机内部经历了怎样的旅程?本文将从最基础的冯·诺依曼体系结构出发,逐步深入到操作系统和进程管理,带你建立完整的计算机系统认知框架。无论你是初学者还是有一定经验的开发者,相信这篇文章都能帮助你打通软硬件之间的认知壁垒。
一、冯诺依曼体系结构
1.1 什么是冯·诺依曼体系结构
计算机由各种硬件设备组成,包括显示器、鼠标、键盘、显卡、网卡、磁盘等。这些硬件并非简单堆砌就能工作,而是按照特定的组织结构协同运行。现代计算机最普遍采用的组织结构就是冯·诺依曼体系结构。如下图
在冯·诺依曼体系结构中,输入设备和输出设备被统称为外设:
- 输入设备:键盘、鼠标、话筒、摄像头、网卡、磁盘等
- 输出设备:显示器、磁盘、网卡、打印机等
CPU 的核心组件包括运算器和控制器:
- 运算器:负责算术和逻辑运算
- 控制器:负责取指令、分析指令、执行指令
因此,CPU = 运算器 + 控制器,CPU 也被称为中央处理器。
这里的存储器指的是内存,而磁盘通常称为外存(外部存储),属于外设的一种。
1.2 IO 的本质:站在内存的角度理解
对于输入设备,我们需要将外设的数据读取到内存;对于输出设备,我们需要将内存中的数据输出到对应的外设。
有两个设备比较特殊——网卡和磁盘。以磁盘为例:读文件是将磁盘数据读到内存,写文件是将内存数据写到磁盘。这种读写操作称为IO(Input/Output)。因此,磁盘既是输入设备,又是输出设备。
核心观点:站在内存的角度理解 IO——外设把数据交给内存就是I(输入),内存把数据给输出设备就是O(输出)。
1.3 为什么程序要运行必须先加载到内存
软件要运行,必须先加载到内存。程序运行前存储在磁盘上,因为程序就是磁盘特定路径下的一个二进制文件。
为什么必须加载到内存?因为CPU 在数据层面上只和内存打交道。软件运行的本质是 CPU 执行代码、访问数据,所以程序必须先加载到内存,否则 CPU 无法获取。
把程序从磁盘加载到内存的本质就是IO——从外设加载到内存的本质就是I。
程序运行时,printf或cout并非直接把数据输出到显示器,而是先把数据放入缓冲区,需要时再刷新到外设。这也是冯·诺依曼体系结构的规定——CPU 执行代码不能直接把数据输出到外设。
1.4 数据流动的本质
在计算机体系结构中,数据流动方向永远是:
外设 → 内存 → CPU → 内存 → 外设数据搬来搬去的过程,本质上就是从一个设备拷贝到另一个设备。因此,体系结构的效率由设备的"拷贝"效率决定。
核心结论:CPU 在数据层面只和内存打交道,外设也只和内存打交道。
1.5 为什么需要内存作为中转
为什么不直接让输入设备 → CPU → 输出设备,而需要内存作为中转?这与计算机存储分级有关。如下图
外设的运算效率极低。如果外设直接把数据交给 CPU,CPU 快速处理后再交给输出设备,此时 CPU 必须等待慢速的输出设备;处理完后还要等待输入设备传数据。没有内存做中转,整个体系结构的效率将完全依赖外设,变得非常低下。
为了在效率和价格之间找平衡,计算机体系结构引入了内存:
- 输入设备的数据可以提前放入内存(由操作系统操作)
- 内存适配了 CPU 和外设之间的速度不匹配
- 内存与 CPU 间的拷贝效率明显优于外设与 CPU 间的拷贝效率
现代计算机本质上是性价比的产物。
1.6 实例:QQ 聊天的数据流动
对冯·诺依曼的理解不能停留在概念上,要深入到软件数据流。以登录 QQ 并与朋友聊天为例,数据的流动过程是怎样的?
两台电脑的交互本质上是两台冯·诺依曼体系结构的交互。以 A 给 B 发送消息为例:
- A 的输入阶段:A 通过键盘输入消息,QQ 程序(已加载到内存)接收输入
- A 的处理阶段:消息经过 CPU 处理,返回内存
- A 的输出阶段:内存将数据传输到输出设备——网卡
- 网络传输:数据通过网络到达 B 的网卡
- B 的输入阶段:B 的网卡(输入设备)接收数据,传给内存中的 QQ
- B 的处理阶段:QQ 将数据传给 CPU 处理,再传回 QQ
- B 的输出阶段:QQ 将数据传输到显示器(输出设备),B 看到消息
发送文件的过程类似,只是数据量更大,可能经过磁盘读写,但核心流程不变:数据始终在外设、内存、CPU 之间流动。
我们所用的软件本质上都在处理存储器和 CPU 之间的关系,数据流动的本质就是在冯·诺依曼体系结构中进行数据拷贝。
二、操作系统
2.1 概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。广义上,操作系统包括:
- 内核(进程管理、内存管理、文件管理、驱动管理)
- 其他程序(例如函数库、Shell 程序等)
操作系统是一款进行软硬件管理的软件。
- 狭义操作系统:单纯的操作系统内核
- 广义操作系统:内核 + 其他程序
本文后续不加特殊说明时,均指狭义的操作系统(内核)。
2.2 设计 OS 的目的
对上:为用户程序(应用程序)提供一个良好的执行环境(这是目的)
对下:与硬件交互,管理所有的软硬件资源(这不是目的,是手段)
访问不同硬件需要配备不同的驱动程序。
两个重要设计原则:
层状结构:计算机软硬件体系结构被设计成从硬件到用户的层状结构,符合高内聚低耦合的设计思想。
系统调用:操作系统不允许用户直接访问,必须通过系统调用(C 语言封装的接口)——本质就是函数,只不过是操作系统提供的。
以printf为例:它的本质是把数据写到硬件(显示器)。但程序不能直接把数据写到硬件,因为计算机体系是层状的,不可能绕过操作系统直接访问硬件。因此,printf这个库函数的底层一定封装了系统调用,通过操作系统对驱动进行访问。
由此得出两个重要结论:
- 只要程序访问了硬件,它必须贯穿整个软硬件体系结构
- 库函数在底层封装了系统调用
2.3 核心功能
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的"搞管理"的软件。
2.4 如何理解"管理"
用学生、辅导员、校长的例子来理解管理:
- 校长:管理者,有决策权
- 学生:被管理者
- 辅导员:中间层,有执行权
管理的三个要点:
- 管理者和被管理者不需要见面
- 管理依据是"数据"
- 数据由中间层获取
对应到计算机系统:
- 操作系统→ 校长
- 驱动程序→ 辅导员
- 底层硬件→ 学生
操作系统基于数据管理硬件,数据通过驱动程序获取。
现实中校长用 Excel 表管理学生信息,类似于顺序表。本质上,管理数据就是增删查改。在操作系统中,通过结构体封装硬件属性,再通过数据结构进行存储和增删查改。对进程的管理也是如此。
不管是现实还是计算机,都绕不开这六个字——先描述,再组织。这对应了面向对象语言(如 C++ 的类和 STL),操作系统管理数据也是靠这个逻辑。
因此,操作系统内部必然包含大量的数据结构和算法。
2.5 系统调用和库函数
操作系统管理着所有软硬件,但操作系统不相信任何用户。然而操作系统又要向上提供服务(访问硬件的能力)。
这类似于银行:银行提供理财、取款、贷款、存钱服务,但不让你进金库。为了既拦住你又给你服务,银行设置了窗口。
操作系统也是如此:对外表现为一个整体,但暴露部分接口供上层开发使用。这部分由操作系统提供的接口,叫做系统调用。
系统调用功能比较基础,对用户要求较高。因此,开发者可以对部分系统调用进行适度封装,形成库。有了库,更上层用户或开发者可以更方便地进行二次开发。
系统调用的本质:用户可以通过系统调用与操作系统进行数据交互,降低了操作系统和用户之间的耦合度。
我们之前使用的库、指令、外壳程序,如果涉及硬件,底层都有系统调用。库函数和系统调用属于上下层关系。
三、进程
3.1 基本概念
课本概念:程序的一个执行实例,正在执行的程序等。
内核观点:担当分配系统资源(CPU 时间、内存)的实体。
当前理解:进程 = 内核数据结构(task_struct)+ 自己的程序代码和数据
程序代码和数据存储在磁盘上。要运行程序,需要先把代码和数据移动到内存中。但从磁盘移动到内存的过程本身不是进程。
操作系统一开始就加载到内存中(电脑开机时的等待时间就是在加载操作系统)。操作系统要管理加载到内存的文件,需要创建结构体来存储每个文件的属性。
因此:
进程 = PCB(task_struct)+ 自己的代码和数据
PCB(Process Control Block,进程控制块)是管理进程的结构体,在 Linux 中具体实现为task_struct。这二者的关系就像 Shell 和 Bash 的关系。
至此,对进程的管理,本质上就是在操作系统中对 PCB 所构建的数据结构进行增删查改。
3.2 描述进程:task_struct
内容分类:
- 标识符:描述本进程的唯一标识符,用来区别其他进程
- 状态:任务状态、退出代码、退出信号等
- 优先级:相对于其他进程的优先级
- 程序计数器:程序中即将被执行的下一条指令的地址
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据:进程执行时处理器的寄存器中的数据
- I/O 状态信息:包括显示的 I/O 请求、分配给进程的 I/O 设备和被进程使用的文件列表
- 记账信息:可能包括处理器时间总和、使用的时钟数总和、时间限制、记账号等
- 其他信息
组织进程:
所有运行在系统里的进程都以task_struct双链表的形式存在于内核中。
3.3 查看进程
调度一个进程,本质就是在当前列表中选择一个优先级最高的进程给 CPU,因为 PCB 能找到进程的所有属性,自然能找到对应的代码和数据喂给 CPU。
3.3.1 创建并运行一个进程
#include <iostream> #include <unistd.h> using namespace std; int main() { while (1) { cout << "我是一个进程" << endl; sleep(1); } return 0; }编译运行:
$ make $ ./test1 我是一个进程 我是一个进程 ...程序运行起来就是一个进程。我们执行的所有指令、工具、程序,全都是进程!
在系统里能处理任务都是通过进程来执行的,我们操作手机打开APP什么的其实都是在执行进程,在Linux系统里,我们用户也是以进程的方式来访问操作系统的。进程我们也叫任务,所以进程在Linux里叫task
3.3.2 获取进程 ID:getpid
getpid是系统调用(查看二号手册,三号手册是库调用)。
#include <iostream> #include <unistd.h> #include <sys/types.h> using namespace std; int main() { while (1) { cout << "我是一个进程, 进程ID为" << getpid() << endl; sleep(1); } return 0; }运行结果:
$ ./test1 我是一个进程, 进程ID为8625 我是一个进程, 进程ID为8625 ...调用getpid的本质是让操作系统从当前进程的 PCB 中把 pid 拷贝出来,让用户看到当前进程的 ID。
至此就能进一步证明我们先前所写的所有程序运行起来都是进程
3.3.3 使用 ps 命令查看进程
# 查看所有进程 $ ps axj # 筛选特定进程 $ ps axj | grep test1 # 显示表头并筛选 $ ps axj | head -1; ps axj | grep test1 # 排除 grep 自身 $ ps axj | head -1 && ps axj | grep test1 | grep -v grep同时执行多条命令:用
;或&&隔开。
grep也会创建进程,所以筛选时会把自己也查出来。这进一步验证了所有指令都是进程。
3.3.4 终止进程
Ctrl+C强制退出的本质就是杀死进程。
专门的杀死进程指令:kill
# 查看进程 $ ps axj | head -1 && ps axj | grep test1 | grep -v grep PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 2587 15061 15061 2587 pts/0 15061 S+ 1001 0:00 ./test1 # 杀死进程(-9 是信号编号) $ kill -9 150613.3.5 通过 /proc 目录查看进程
/proc是内存级的文件系统,与磁盘无关,所有数据都是内存里的数据。
/proc下每个数字目录对应一个进程的 pid,目录内容包含进程运行时的动态属性。
重点属性:
exe:进程对应的可执行文件的绝对路径cwd:当前工作路径
示例:
$ ll /proc/10339 total 0 lrwxrwxrwx 1 qyh qyh 0 Oct 10 19:28 cwd -> /home/qyh/linux-sys-prog lrwxrwxrwx 1 qyh qyh 0 Oct 10 19:28 exe -> /home/qyh/linux-sys-prog/test1删除可执行文件后进程仍在运行:
删除磁盘上的文件不会影响正在运行的进程,因为程序已经拷贝到内存中,我们删掉的只是磁盘里上的文件。但再次查看时,exe会显示为(deleted):
lrwxrwxrwx 1 qyh qyh 0 Oct 10 19:28 exe -> /home/qyh/linux-sys-prog/test1 (deleted)cwd 的作用:
fopen直接写文件名时,默认在当前工作目录下新建文件,就是因为进程启动时记录了cwd。fopen内部会获取当前工作路径,拼接到文件名前。
修改 cwd:chdir 系统调用
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <stdio.h> using namespace std; int main() { chdir("/home/qyh"); fopen("xixi", "w"); while (1) { cout << "我是一个进程, 进程ID为" << getpid() << endl; sleep(1); } return 0; }运行后,cwd被修改为/home/qyh,fopen创建的文件也在该目录下。
查看进程的两个方法:
ps命令 和/proc目录。
3.3.6 获取父进程 ID:getppid
#include <iostream> #include <unistd.h> #include <sys/types.h> using namespace std; int main() { while (1) { cout << "我是一个进程, 进程pid为:" << getpid() << " 父进程pid为:" << getppid() << endl; sleep(1); } return 0; }多次运行会发现:进程的 pid 一直在变,但父进程的 pid 始终不变。
查看父进程:
$ ps axj | head -1 && ps axj | grep 7528 | grep -v grep PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 7527 7528 7528 7528 pts/0 7528 Ss+ 1001 0:00 -bash父进程是bash——命令行解释器(所以命令行解释器本质也是进程)。而这个命令行解释器,我在Linux 基础常用指令万字详解(下)|一切皆文件:重定向、管道、日志 与 Shell 原理-CSDN博客
中就以王婆说媒的例子讲过了
bash 是所有进程的起源进程,OS 为每个登录用户分配一个 bash 进程(名前带-表示远程登录)。
命令行的本质:bash 打印出的一个字符串。输入的命令以字符串形式交给 bash,bash 进行分析处理。
命令行上我们自己启动的命令(如
ls、pwd、cd等),父进程都是 bash。
3.4 通过代码创建子进程:fork
3.4.1 fork 初识
我们可以通过fork来创建子进程
FORK(2) Linux Programmer's Manual FORK(2) NAME fork - create a child process SYNOPSIS #include <unistd.h> pid_t fork(void);#include <iostream> #include <unistd.h> #include <sys/types.h> using namespace std; int main() { cout << "父进程开始执行:" << getpid() << endl; fork(); cout << "进程开始执行:" << getpid() << endl; return 0; }运行结果:
$ ./test1 父进程开始执行:22792 进程开始执行:22792 进程开始执行:22793验证:fork()后变成两个执行流,都会执行后续代码。
原理:进程 = PCB + 代码和数据。fork创建子进程时,将父进程 PCB 的属性拷贝给子进程 PCB,父子进程指向同一份代码和数据(pid、ppid 等属性不同)。因此子进程被调度时会执行父进程之后的代码。
3.4.2 fork 的返回值
RETURN VALUE On success, the PID of the child process is returned in the parent, and 0 is returned in the child. On failure, -1 is returned in the parent, no child process is created.fork创建子进程成功会返回俩个值
- 父进程:返回子进程的 pid
- 子进程:返回 0
- 失败:返回 -1
利用返回值让父子进程执行不同逻辑:
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <stdio.h> using namespace std; int main() { cout<<"父进程开始执行:"<<getpid()<<endl; pid_t id = fork(); try { if(id<0) throw(1); if(id>0) { while(1) { sleep(1); cout<<"父进程开始执行:"<<getpid()<<endl; } } else { while(1) { sleep(1); cout<<"子进程开始执行:"<<getpid()<<endl; } } return 0; } catch(int) { cout<<"task c:error"<<endl; return 1; } } 运行结果:
父进程开始执行:7964 父进程开始执行:7964 子进程开始执行:7965 父进程开始执行:7964 子进程开始执行:7965 ...3.4.3 深入理解 fork 的三个问题
问题 1:为什么 fork 给父子返回不同的返回值?
Linux 系统中,父进程与子进程是1:n的关系。父进程可能有多个子进程,需要通过不同的 pid 区分;子进程只有一个父进程,可以通过getppid()获取,所以只需返回 0 表示成功。
问题 2:为什么一个函数会返回两次?
fork是系统调用,执行到return时核心工作已完成——子进程已被创建并放入调度队列。return是语句,父子进程都会执行,因此返回两次。
问题 3:为什么一个变量既 ==0 又 >0,导致 if 和 else 同时成立?
进程具有独立性——一个软件挂了不会影响其他软件。
- 内核数据结构:父子进程 PCB 互不影响
- 代码:只读,共享但不互相影响
- 数据:默认共享,但任何一方尝试修改时,系统会拷贝一份数据让目标进程修改——这就是写时拷贝(Copy-On-Write)
验证写时拷贝:
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <stdio.h> using namespace std; int tmp = 200; int main() { cout<<"父进程开始执行:"<<getpid()<<endl; pid_t id = fork(); try { if(id<0) throw(1); if(id>0) { while(1) { sleep(1); cout<<"父进程开始执行:"<<getpid()<<" ppid="<<getppid()<<" tmp="<<tmp<<endl; } } else { sleep(5); while(1) { sleep(1); tmp+=10; cout<<"子进程开始执行(tmp+10):"<<getpid()<<" ppid="<<getppid()<<" tmp="<<tmp<<endl; } } return 0; } catch(int) { cout<<"task c:error"<<endl; return 1; } }运行结果:
父进程开始执行:25431 父进程开始执行:25431 ppid=7528 tmp=200 ... 子进程开始执行(tmp+10):25432 ppid=25431 tmp=210 父进程开始执行:25431 ppid=7528 tmp=200 子进程开始执行(tmp+10):25432 ppid=25431 tmp=220 ...父子进程的tmp互不影响,证明了写时拷贝机制。
id变量看似只有一个,但通过写时拷贝,父子进程各自拥有独立的副本,因此可以"同时"满足 >0 和 ==0 的条件
结语:
通过本文,我们从冯·诺依曼体系结构出发,理解了计算机硬件之间的数据流动本质;深入操作系统层面,明白了"管理"的含义以及系统调用的作用;最后聚焦到进程,掌握了进程的构成、查看方法以及通过fork创建子进程的原理。
核心要点回顾:
- 冯·诺依曼体系结构:CPU 只和内存打交道,外设也只和内存打交道,数据流动的本质是设备间的拷贝
- 操作系统:先描述再组织,通过系统调用向上提供服务,向下管理硬件
- 进程:进程 = PCB + 代码和数据,进程管理本质是对数据结构的增删查改
- fork 机制:父子进程代码共享、数据写时拷贝,通过返回值区分执行流
这些知识是理解 Linux 系统编程的基石。当你真正理解了"计算机体系中数据的流动"和"操作系统如何管理资源",面对更复杂的并发编程、内存管理、文件系统时,你就能从底层原理出发,建立起清晰的知识脉络。希望这篇文章能为你的系统编程学习之路打下坚实的基础。