Linux 冯诺依曼体系结构与进程理解
一.冯诺依曼体系结构
冯诺依曼体系结构是我们现代计算机中硬件的重要设计理念。在讲解操作系统相关方面前,我们先介绍冯氏体系结构,有助于我们更深层次理解计算机运行的原理。
1.基本部件功能
在这种体系中,我们是以存储器为中心的。其实说得更准确一点,存储器应该叫做内存。
tips:输入输出设备均是外设。
输入设备:磁盘,键盘,鼠标,话筒——负责向内存写数据,最终交给CPU进行处理数据的设备。
输出设备:磁盘,显示器,打印机——负责将CPU处理的数据存储或打印出让用户可视化的设备。
由此可窥见计算机的一二功能:接收数据——>处理数据——>输出数据。
现代计算机中,CPU集成度非常高,但基本可以写成CPU=运算器(数学和逻辑运算)+控制器(调控各种硬件的工作)。有内存就会有外存,就是我们上面提到的磁盘。
2.理解冯诺依曼体系结构
了解冯诺依曼体系结构之前,我们一定听过这些话:
存储程序(Stored-Program)
这是最革命性的思想。程序(指令)和数据以二进制形式不加区分地存储在同一个存储器中。这意味着计算机可以通过改变存储器中的程序来改变其功能,而无需重新设计硬件电路。计算机能成为“通用”机器,正是基于这一原则。程序顺序执行
控制器通过一个程序计数器(PC, Program Counter) 来指向下一条要执行的指令的地址。在默认情况下,指令一条接一条地顺序执行。只有当遇到跳转指令(如循环、条件判断)时,程序计数器的值才会被改变,从而实现程序的跳转。以运算器为中心(历史特点)
在最初的设计中,所有部件的操作都由运算器枢纽。数据在输入、输出和存储时,都必须经过运算器。这使得运算器在大部分时间里处于空闲状态,等待慢速的I/O设备,导致整体效率低下(这就是所谓的“冯·诺依曼瓶颈”)。现代计算机已发展为以存储器为中心,通过总线(Bus)结构连接各部件,大大提高了效率。
指令和数据同等对待
指令和数据在内存中都是二进制代码,CPU在取指令周期取出来的是指令,被送到控制器进行译码;在取数据周期取出来的是数据,被送到运算器进行运算。硬件通过不同的时间和空间来区分它们。
接下来我们用更通俗的话理解这些冯氏机的特点。
1.存储器为中心与存储程序
我们按数据信号角度理解:可以说cpu只和内存打交道,外设只和内存打交道。而程序在文件中(磁盘)cpu无法直接访问外设,所以必须把程序从外设加载到内存。输入设备input到存储器,存储器output到内存。而我们使用printf等函数时也是,我们只是把数据放在缓冲区,需要的时候再刷新到外设。
也就是说,可以看作数据的传输就是从一个设备拷贝到另一个设备。
那么我们可以得出一个结论:冯氏体系结构的效率是由各组件的拷贝效率和运算效率决定的。
那么你可能会有疑问:
为什么数据的IO都需要用内存当中介?没有多级缓存结构会怎样?
实际上各种设备之间的运算速度是不同的。如果没有多级缓存,就会被迫让CPU“迁就”这两个速度非常慢的设备,由短板效应可知,此时整个体系的效率会非常差。

2.理解数据流动
现实中的信息交流(发qq信息),只要是两台计算机,就用的是冯诺依曼体系结构。首先双方必须都启动qq——>qq应用程序被加载到内存——>键盘输入信息到内存——>由cpu解包加密后再写回内存——>输出到自己的输出设备(网卡).对方的输入设备(网卡)接收数据——>对方启动qq——>程序和数据被加载到内存——>然后信息被加载到输出设备(屏幕)。
传送文件也是如此,文件在传输之前是在磁盘中,当我们执行拖入文件发送时,文件一定会被加载到内存,宏观的看,传送文件就是把我磁盘中的数据拷贝到对方磁盘的过程。
二.操作系统
1.操作系统的定义
操作系统的定义:一个基本的程序集合,用于进行软硬件管理。
这样说想必各位没法完全理解操作系统是什么。接下来我们就一一解释操作系统的各种职能。
操作系统包括:
内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(函数库,shell程序)
我们之后的章节重点要讲解的就是操作系统的内核功能,而外壳程序shell,命令行,库等软件都是基于内核的开发,目的在更好的用户体验和更强大的功能扩展。
2.设计操作系统的目的
1.硬件角度:逻辑上按冯诺依曼体系结构组织,不同的IO设备,有各种驱动程序,因为在硬件层面,不同的设备有它们自己的个性化读写方式。
2.操作系统:向下要进行软硬件资源管理,但这不是目的,是一种手段;主要是为了向上给用户提供一个良好稳定的运行环境
3.高内聚(相同功能的代码放在同一模块),低耦合(不同功能的模块仅通过接口调用)的设计逻辑
4.操作系统只提供一系列系统调用(接口)来间接访问OS的各种核心功能。例如,你写的printf函数是直接对硬件操作的吗?不可能,本质是这些printf封装了系统调用接口,由此将数据交到硬件上的。我们从始至终的所有IO操作都必须贯穿操作系统。
3.理解操作系统
核心功能:在整个计算机软硬件架构中,操作系统是一款纯正的“管理性”软件。
那么应该如何理解管理?
我们可以把整个计算机软硬件系统执行用户的操作,看成一个学校的事务组织形式。
这三者之间的关系就好比:OS——>驱动——>硬件
管理者要管理被管理者,根本不需要见面
那么如何进行管理呢?管理者只需要知道被管理的数据就可以进行管理
那么不见面,如何拿到数据?由中间层(驱动)获取(辅导员)。
那么自然而然地我们就想到,可以把所有学生的信息放到一个表里管理。
此时校长对学生的管理,就转化为了对学生信息的excel管理。
此时我们也可以用一种数据类型把不同数据类型聚合——那就是类(结构体)
那么我们所有的学生都可以用这种结构来组织。
这样离散着管理学生不方便,于是我们想到可以用双链表将他们串联。
在这里我们用到一个十分重要的建模方法:先描述,再组织。这样我们就能理解面向对象语言以及其他新兴的语言设计类和STL等库的原因
用类描述现实世界对象,用数据结构组织对象,用各种算法管理对象。
而在操作系统中我们也如此,不管面对的是何种资源,一定是先描述再组织,接下来要讲的进程就是这样的一个实例。
三.进程初识
1.理解系统调用
操作系统虽然给我们提供了很多管理的方法,但是操作系统不会把这些方法直接暴露给任何用户。这其实是一个比较矛盾的点
又不让用户直接访问内核,还想为用户提供服务,要怎么做?
用银行做例子,银行会为所有用户提供对账户操作的服务,但是它不会让我们直接访问内部进行操作。于是它就为我们提供了一些服务窗口。
用户来进行一系列服务,不可能让用户亲自到金库里拿钱,这十分危险,不仅会使银行系统崩溃,还会让其他人的资产遭到破坏。
所以银行就在前台提供了一个个窗口,每个窗口都负责不同的业务。这些窗口就可以当作系统调用。
不管是在Linux,Windows还是macOS,这些操作系统的系统调用都是用C语言编写的。也就是说,系统调用可以看作是一系列C函数,系统调用的过程就是用户和操作系统之间的数据交互。
基于系统调用,我们可以进一步封装开发一些利于用户的应用,例如外壳程序shell和库。这就类似在银行系统前台安排了一个大堂经理,就算是一个对银行操作一窍不通的人也能顺利完成业务。
2.进程概念
谈起进程的概念,较为严谨的回答是:
• 课本概念:程序的⼀个执⾏实例,正在执⾏的程序等
• 内核观点:担当分配系统资源(CPU时间,内存)的实体。
但这样说大家肯定难以理解。我们先来讲讲程序是如何运行起来的。
在所有软件加载到内存之前,一定会是OS被加载到内存。那么有一堆代码和数据被加载到内存,如何管理,如何释放,如何分配......
这种东西对OS来说是难以管理的,但OS必然要对这些东西进行管理,那么应该怎么做?
先描述,再组织!!!
这里的PCB即程序控制块,它就是一个结构体,用于描述一个可执行程序的各种信息(包括pid,ppid等等内容)。每次将可执行程序加载到内存,都会把这个PCB填好对应的信息。一个PCB中可以查到它对应的代码和数据的一切信息(注意是一切)。
也就是说,进程=内核数据结构对象+代码和数据,并且每次将电脑开机前都会先把操作系统加载到内存。
3.PCB
在linux中,具体的这个PCB叫task_struct
进程的所有属性,注意是所有,都可以通过PCB找到。
对进程的管理,就变成了对链表的增删查改。
内容分类
• 标⽰符: 描述本进程的唯⼀标⽰符,⽤来区别其他进程。
• 状态: 任务状态,退出代码,退出信号等。
• 优先级: 相对于其他进程的优先级。
• 程序计数器: 程序中即将被执⾏的下⼀条指令的地址。
• 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
• 上下⽂数据: 进程执⾏时处理器的寄存器中的数据[休学例⼦,要加图CPU,寄存器]。
• I∕O状态信息: 包括显⽰的I/O请求,分配给进程的I∕O设备和被进程使⽤的⽂件列表。
• 记账信息: 可能包括处理器时间总和,使⽤的时钟数总和,时间限制,记账号等。
• 其他信息
一个例子理解PCB:
你在找工作?是你的简历在找工作。hr说你在排队?是你的简历再排队。
面试官就是cpu,那一份一份的简历,就是PCB队列。
一个可执行程序被加载到内存,最重要的是它的简历——PCB,而不是它本身。
进程的组织
可以在内核源代码⾥找到它。所有运⾏在系统⾥的进程都以 task_struct 双链表的形式存在内核
⾥
4.查看进程
为了演示进程的查看,我们先来写一个可执行程序并执行。
wujiahao@VM-12-14-ubuntu:~/process_test$ vim myprocess.c
wujiahao@VM-12-14-ubuntu:~/process_test$ make
gcc -o myprocess myprocess.c
wujiahao@VM-12-14-ubuntu:~/process_test$ ls
Makefile myprocess myprocess.c
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myprocess
这是一个进程!我的pid为:225913
这是一个进程!我的pid为:225913
方法1:用命令行方式查看进程。
ps ajx|grep myprocess
wujiahao@VM-12-14-ubuntu:~$ ps ajx|grep myprocess225100 225913 225913 225100 pts/0 225913 S+ 1002 0:00 ./myprocess226029 226085 226084 226029 pts/1 226084 S+ 1002 0:00 grep --color=auto myprocess
由于我们源文件写的是一个无限循环的语句,我们可以用以下方式杀掉进程。
Ctrl+c或者kill -9 pid
wujiahao@VM-12-14-ubuntu:~$ ps ajx|grep myprocess226029 226642 226641 226029 pts/1 226641 S+ 1002 0:00 grep --color=auto myprocess
可以看到我们的可执行程序已经被杀死了。
这里需要注意的是,因为grep指令也是一个进程,在查询的时候会自动包含这个指令本身。
方法2:通过文件方式查看进程。
Linux为我们提供了一个用文件查看进程的方式
ls /proc
需要注意的是,这里的proc文件夹是内存级进程的文件反映,跟磁盘中的文件没有关系。
这是一个进程!我的pid为:227472
只要我们杀掉进程,这个进程就会在proc文件夹中消失。
我们可以具体查看这个进程文件夹中的信息。
ls /proc/pid -l
来拓展两个知识。
exe:可执行程序,能知道从哪里来
那删掉这个来源,会有影响吗?
暂时不会,程序还会继续执行。因为程序已经被加载到内存,你删除的是磁盘上的文件。
cwd:指向该进程当前正在运行的工作目录。
进程会自动记录自己的路径,拿着当前路径和新文件名拼接为一个新路径。
故障排查:如果一个进程报告说“找不到文件”,但你确认文件确实存在,很可能是进程的工作目录设置得不对。查看
cwd
可以快速验证这一点。理解进程行为:特别是对于从源码编译并启动的程序,或者通过复杂脚本启动的守护进程,你可能不确定它最终是在哪个目录下执行的。
cwd
提供了明确的答案。安全性监控:系统管理员可以检查某些敏感进程的工作目录是否被篡改或设置到了异常的位置。
当然也可以在程序启动前修改路径。chdir函数,接着可以看到proc下当前进程的cwd已经改为我们修改的路径。
5.父进程与子进程
Linux系统的进程可以说是“单亲繁殖”。子进程由父进程创建,一个父进程可以由许多子进程,而一个子进程只能有一个父进程。根据之前提到的先描述再组织,可以很容易想象出用树组织进程之间的关系。
我们反复启动当前的程序,查看当前进程的pid和父进程的id。
可以看到,每次重启程序它的pid都会改变,而父进程始终不变。
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myprocess
这是一个进程!我的pid为:244607
,我的父进程id为:239443
^C
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myprocess
这是一个进程!我的pid为:244680
,我的父进程id为:239443
^C
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myprocess
这是一个进程!我的pid为:244739
,我的父进程id为:239443
我们不妨看看这个父进程是谁。
wujiahao@VM-12-14-ubuntu:~$ ps ajx|head && ps ajx|grep 239443|grep -v grepPPID PID PGID SID TTY TPGID STAT UID TIME COMMAND239442 239443 239443 239443 pts/0 245591 Ss 1002 0:00 -bash239443 245591 245591 239443 pts/0 245591 S+ 1002 0:00 ./myprocess
当前程序的父进程,居然是命令行解释器bash!
所以我们可以得出:命令行解释器bash,本质是一个启动的程序(进程),所有命令启动的父进程都是bash。
tips:操作系统会给每个登录用户分配一个bash进程。
6.创建子进程
上面可以说我们是用命令行启动程序的方式创建了bash的子进程,接下来我们就用代码的方式创建子进程。
fork()
fork()是一个系统调用,我们先来简单讲讲它的特点。
1. 调用一次,返回两次
这是 fork()
最独特也最重要的特点。一个进程(称为父进程)调用 fork()
后,会几乎一模一样地复制出一个新进程(称为子进程)。
在父进程中,
fork()
返回子进程的进程ID(PID)(一个大于0的数字)。在子进程中,
fork()
返回 0。如果创建失败(如系统资源不足),则返回 -1。
2. 复制而非重新开始
子进程不是从头开始运行一个全新的程序,而是获得父进程几乎所有资源的副本,包括:
代码段(Text segment)
数据段(Data segment)、堆(Heap)、栈(Stack)
文件描述符表(这意味着子进程可以操作父进程已经打开的文件)
环境变量、进程组信息等
3. 写时复制(Copy-On-Write, COW)
这是现代操作系统为了提升 fork()
效率而采用的关键优化技术。虽然逻辑上子进程复制了父进程的全部数据,但实际上在 fork()
刚完成的那一刻,父子进程的物理内存是共享的,并被标记为“只读”。
只有当父进程或子进程试图修改某一块数据时,操作系统才会真正地为修改方复制那一块内存页。这样就避免了不必要的复制,大大加快了 fork()
的速度并减少了内存开销。
4. 并发执行(Concurrent Execution)
fork()
成功后,父进程和子进程是相互独立的两个进程,它们会并发执行。操作系统负责调度它们,谁先谁后是不确定的(非确定性的)。你不能假设父进程一定先于子进程运行。
5. 共享文件描述符
子进程会复制父进程的文件描述符表。这意味着如果父进程打开了一个文件,子进程也能读写这个文件,并且它们共享文件的偏移指针。例如,如果父进程写入了一些内容,子进程会从父进程写入结束的位置继续写入。
接下来开始实操。
示例1:
执行流会分成两个,一个是原进程,另一个是子进程。第九行代码在视觉上会被执行两次
子进程会进行父进程的PCB拷贝,除了pid ppid不一样之外其他很多属性都一样。默认情况下,子进程的数据和代码也是指向父进程的,因为没有程序新加载。
那子进程为什么不执行fork之前的代码?它虽然能看见,但不会再去执行前面的代码。
示例2:
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myprocess
父进程开始运行,pid=250049
我是一个父进程,我的pid为:250049,我的父进程id为:239443
我是一个子进程,我的pid为:250050,我的父进程id为:250049
我是一个父进程,我的pid为:250049,我的父进程id为:239443
我是一个子进程,我的pid为:250050,我的父进程id为:250049
我们这里通过if-else语句同时展现出子进程与父进程的执行结果。
这里正是根据返回值的不同,让父子进程执行不同的逻辑。这里其实大家肯定会有一些疑问:
问题1:为什么一个函数会返回两次?
问题2:为什么fork()会给父子进程返回各自不同的返回值?
问题3:为什么else-if和else语句会同时执行?
这里我们先回答前两个问题
因为:父:子=1:n。任意一个父进程可以有很多子进程,而子进程只有一个父进程
父进程拿到子进程的pid,方便管理
一个函数执行到return,说明核心功能已经做完了
return也是语句。那么父也会执行return,子也会执行return。
现实中,一个进程挂掉,不会影响其他进程(包括父子进程),进程具有独立性
对于父子进程,默认数据和代码是共享的;一旦发生了企图修改对方代码的行为,把父子的任何一方,进行修改数据,OS把修改的属性在底层拷贝一份,让目标进程修改这个拷贝——这就是写时拷贝
我们进行验证。
出现了以下现象:子进程在不断修改他们的公共资源gval,但父进程显示的gval始终为初始值。这是因为它们发生了写时拷贝。
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myprocess
父进程开始运行,pid=255758
我是一个子进程,我的pid:255759,我的父进程id:255758
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
子进程修改了变量:100->110我是一个子进程,我的pid为:255759,我的父进程id为:255758
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
子进程修改了变量:110->120我是一个子进程,我的pid为:255759,我的父进程id为:255758
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
子进程修改了变量:120->130我是一个子进程,我的pid为:255759,我的父进程id为:255758
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
子进程修改了变量:130->140我是一个子进程,我的pid为:255759,我的父进程id为:255758
我是一个父进程,我的pid为:255758,我的父进程id为:239443,gval:100
将来只要谁先return,谁就先修改这个值。返回的本质就是在写入变量。