一、计算机的基石:冯诺依曼体系结构
在我们日常使用的笔记本电脑或不常见的服务器中,大部分都遵循着冯诺依曼体系结构。这一结构奠定了现代计算机的基础。
- 核心组件:
- 输入设备:如键盘、鼠标,用于向计算机输入信息。
- 存储器:这里特指内存(RAM),用于存放数据和指令。
- 中央处理器(CPU):包含运算器和控制器,负责执行指令和处理数据。
- 输出设备:如显示器、打印机,用于展示处理结果。
- 关键特性:
- CPU 能且仅能直接对内存进行读写操作,不直接访问外设。
- 所有外设(输入输出设备)的数据交换也都必须通过内存进行。
- 简而言之,所有设备都只能直接和内存打交道。
想象一下你用QQ和朋友聊天,从你输入消息到对方接收到消息,这整个过程中的数据流动,都离不开这个体系结构中各个部件通过内存的协同工作。
二、操作系统的角色与定位
操作系统(OS)是一系列基本程序的集合,它在计算机系统中扮演着“管理者”的角色。
- 广义概念:操作系统不仅包括核心的内核(Kernel),还包括外壳程序(如Shell)、库(如glibc)、预装系统级软件等。
- 内核核心功能:进程/任务/线程管理、文件系统、内存管理、驱动管理等。
- 设计目的:
- 对下:与硬件交互,管理所有的软硬件资源。
- 对上:为用户程序(应用程序)提供一个良好的执行环境。
- 核心功能定位:操作系统是一款纯正的“搞管理”的软件。
如何理解“管理”?
就像学校里校长管理辅导员,辅导员管理学生一样。操作系统要管理硬件设备,首先需要“描述”这些设备(通常用结构体 struct),然后将它们“组织”起来(例如用链表等数据结构)。
- 系统调用 (System Call) 与库函数 (Library Function):
- 操作系统会暴露一部分接口供上层开发使用,这些接口称为系统调用。它们功能基础,但对用户要求较高。
- 开发者可以将部分系统调用进行封装,形成库函数,使得上层用户或开发者能更方便地进行二次开发。
三、进程:运行中程序的实体
当我们谈论操作系统如何进行管理时,进程管理是其核心之一。
- 基本概念:
- 课本定义:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(如CPU时间、内存)的实体。
- 描述进程 - 进程控制块 (PCB):
- 进程的信息被存放在一个叫做**进程控制块(Process Control Block, PCB)**的数据结构中。
- 在Linux操作系统下,PCB的具体实现是
task_struct。它会被加载到内存中,包含进程的各种属性。 task_struct内容分类:- 标示符:唯一的进程ID (PID),用于区别其他进程。
- 状态:任务状态、退出代码、退出信号等。
- 优先级:相对于其他进程的优先级。
- 程序计数器:指向下一条将被执行指令的地址。
- 内存指针:指向程序代码、进程数据及共享内存块的指针。
- 上下文数据:进程执行时CPU寄存器中的数据。
- I/O状态信息:I/O请求、分配的I/O设备、使用的文件列表等。
- 记账信息:CPU使用时间、时钟数总和等。
- 组织进程:内核中所有运行的进程通常以
task_struct链表的形式存在。 - 查看进程:
- 可以通过
/proc系统文件夹查看,例如/proc/1查看PID为1的进程信息。 - 用户级工具如
top和ps aux也可以获取进程信息。
- 可以通过
- 进程标示符:
getpid():获取当前进程的ID (PID)。getppid():获取当前进程的父进程ID (PPID)。
- 创建进程 -
fork()系统调用:fork()用于创建一个新的进程,即子进程。fork()调用一次,但返回两次:在父进程中返回子进程的PID,在子进程中返回0。- 父子进程共享代码段,但数据段各自独立(采用写时拷贝 Copy-on-Write技术,即只有在写入时才真正复制数据)。
- 通常使用
if/else if结构根据fork()的返回值来区分父进程和子进程的执行逻辑。
四、进程的生命周期:进程状态
一个进程在其生命周期中会经历多种状态。Linux内核中定义了以下主要状态:
- R (running) - 运行状态:进程要么正在CPU上运行,要么在运行队列中等待被调度。
- S (sleeping) - 睡眠状态:进程正在等待某个事件的完成(可中断睡眠)。
- D (disk sleep) - 磁盘休眠状态:不可中断睡眠,通常在等待I/O操作结束(如磁盘I/O)。
- T (stopped) - 停止状态:进程被信号(如SIGSTOP)暂停。可以通过SIGCONT信号让其继续运行。
- t (tracing stop) - 追踪停止状态:进程在被调试器追踪时进入此状态。
- X (dead) - 死亡状态:这是一个 transitory state,表示进程已经结束,但资源尚未完全释放。在任务列表中通常看不到。
- Z (zombie) - 僵尸状态:进程已终止,但其父进程尚未使用
wait()系统调用来读取其退出状态。
查看进程状态:可以使用ps aux或ps axj等命令。
1. 特殊状态:僵尸进程 (Zombie Process)
- 产生原因:子进程退出后,父进程没有及时调用
wait()或waitpid()来获取子进程的退出状态码。 - 特性:僵尸进程会以终止状态保留在进程表中,等待父进程读取其退出信息。
- 示例代码:可以编写一个子进程先退出,而父进程在一段时间后再处理的代码来观察僵尸进程。
- 危害:
- 虽然僵尸进程本身不占用太多内存(主要是PCB的开销),但如果大量产生且不被回收,会持续占用进程表中的条目。
- 这可能导致系统无法创建新的进程,因为它耗尽了进程ID或进程表资源,造成内存泄漏。
2. 特殊状态:孤儿进程 (Orphan Process)
- 产生原因:父进程在子进程结束之前就退出了。
- 处理机制:孤儿进程会被1号进程(init进程或systemd)领养。init进程会负责回收这些孤儿进程的资源(即调用
wait())。 - 示例代码:可以编写一个父进程先于子进程退出的代码来观察孤儿进程。
五、进程间的竞争与协作:进程优先级
系统中通常有多个进程需要使用CPU,操作系统需要决定哪个进程先运行,这就是进程优先级的作用。
- 基本概念:CPU资源分配的先后顺序。优先级高的进程有优先执行权。
- 查看进程信息:
ps -l命令可以显示进程的详细信息,包括:UID:执行者身份。PID:进程代号。PPID:父进程代号。PRI:进程可被执行的优先级,其值越小越早被执行。NI:Nice值,用于修正PRI。
- PRI (Priority) 和 NI (Nice value):
PRI是内核动态调整的,用户不能直接修改。NI是用户可以调整的,其范围是 -20 到 19(共40个级别)。- 新的优先级计算公式近似为:
PRI(new) = PRI(old) + nice。 - 负的nice值会使进程优先级变高(PRI值变小),从而更快被执行。
- 调整优先级:
top命令中按r键可以修改已存在进程的nice值。nice命令用于以指定nice值启动新进程。renice命令用于修改已运行进程的nice值。
- 相关概念:
- 竞争性:多进程竞争少量CPU资源,因此需要优先级。
- 独立性:多进程运行期间,各自拥有独立的资源,互不干扰。
- 并行 (Parallelism):多个进程在多个CPU核心上同时运行。
- 并发 (Concurrency):多个进程在一个CPU核心上通过快速切换(时间片轮转)的方式,在一段时间内都得以推进,给人同时运行的错觉。
六、进程的切换:CPU的舞蹈
为了实现并发,操作系统需要在不同进程之间切换CPU的执行权,这就是进程切换或上下文切换。
- CPU上下文切换 (Context Switch):
- 当内核决定运行另一个任务时,它会保存当前正在运行任务的状态(主要是CPU寄存器中的所有内容,如程序计数器、通用寄存器等)。
- 这些状态被保存在任务自己的进程控制块(PCB)或内核栈中。
- 然后,内核加载下一个将要运行任务的保存状态到CPU寄存器中,并开始执行该任务。
- 时间片 (Time Slice):
- 现代分时操作系统会给每个进程分配一个“时间片”(一个计数器)。当进程的时间片用完后,操作系统可能会将其从CPU上剥离,调度其他进程运行。
Linux 2.6 内核进程 O(1) 调度算法简介
Linux 2.6内核使用了一种被称为O(1)调度算法的机制,其核心思想是无论系统中进程数量多少,选择下一个要运行的进程的时间复杂度都是常数。
- 运行队列 (Runqueue):每个CPU拥有一个自己的运行队列。
- 优先级:分为普通优先级(100-139,对应nice值)和实时优先级(0-99)。
- 活动队列 (Active Queue):
- 包含所有时间片尚未用完的进程,按优先级组织。
- 使用一个位图(bitmap)来快速定位优先级最高的非空进程队列,从而高效选择下一个运行的进程。
- 过期队列 (Expired Queue):
- 包含所有时间片已经耗尽的进程。
- 指针交换:当活动队列中的所有进程都被处理完毕后,系统会重新计算过期队列中进程的时间片,并通过交换活动队列和过期队列的指针,使得过期队列成为新的活动队列。
这种设计确保了调度效率不会随着进程数量的增加而显著下降。
七、进程的“身份背景”:环境变量
环境变量是操作系统中用来指定运行环境的一些参数,它们通常具有全局特性,并可以被子进程继承。
- 基本概念:例如,我们编译C/C++代码时,链接器能找到所需的库文件,就是因为有相关的环境变量(如
LD_LIBRARY_PATH)指明了搜索路径。 - 常见环境变量:
PATH:指定命令的搜索路径。HOME:指定用户的主工作目录(登录时的默认目录)。SHELL:当前使用的Shell程序路径(通常是/bin/bash)。
- 查看与操作命令:
echo $NAME:显示名为NAME的环境变量的值。export NAME=value:设置一个新的环境变量(使其能被子进程继承)。env:显示所有环境变量。unset NAME:清除环境变量。set:显示本地定义的shell变量和环境变量。
- 在代码中获取环境变量:
- 通过
main函数的第三个参数char *env[]。 - 通过libc定义的全局变量
extern char **environ;。 - 使用系统调用
getenv("NAME")来获取特定环境变量的值。putenv()可以用来设置环境变量。
- 通过
- 继承性:父进程导出的环境变量会被子进程继承。
八、进程的“领地”:程序地址空间
我们学习C语言时,老师会画出内存布局图,包括栈区、堆区、全局/静态存储区、常量区、代码区。这实际上描述的是进程的虚拟地址空间。
- 虚拟地址 (Virtual Address):
- 通过一个实验可以很好地理解:用
fork()创建子进程,父子进程中访问同一个全局变量的地址,会发现打印出的地址值是相同的。但是,如果一个进程修改了这个变量的值,另一个进程读取到的值可能不同(因为发生了写时拷贝,数据在物理上分离了)。 - 结论:我们在C/C++代码中看到的地址(如通过指针获取的地址)都是虚拟地址。物理地址对用户是透明的,由操作系统统一管理。操作系统负责将虚拟地址转换为物理地址。
- 通过一个实验可以很好地理解:用
- 进程地址空间 (Process Address Space):
- 每个进程都拥有自己独立的、私有的虚拟地址空间。
- 这个虚拟地址空间从0到一个很大的值(例如32位系统是232,64位系统是264)。
- 操作系统通过页表 (Page Table) 将进程的虚拟地址映射到实际的物理内存地址。同一个虚拟地址在不同进程中可以映射到不同的物理地址。
Linux内核中的内存管理结构:
mm_struct(内存描述符):每个用户进程都有一个mm_struct结构,用于描述其整个用户虚拟地址空间。它在task_struct中被引用。mm_struct内部记录了代码段、数据段、堆、栈等的起始和结束虚拟地址。
vm_area_struct(VMA - 虚拟内存区域):mm_struct内部又通过vm_area_struct来描述不同类型的虚拟内存区域(如代码区、数据区、堆区、栈区、内存映射区等)。这些VMA可以组织成链表(mmap指针)或红黑树(mm_rb指针),以便高效查找和管理。
为什么需要虚拟地址空间?
如果程序直接操作物理内存,会带来很多问题:
- 安全风险:任何进程都可以访问任意物理内存,恶意程序可以轻易破坏系统或其他进程的数据。
- 地址不确定性:程序每次加载到内存的物理地址可能都不同,难以进行链接和寻址。
- 效率低下:当物理内存不足时,需要将整个进程完整地换出到磁盘(交换),效率很低。
虚拟地址空间带来的好处:
- 安全性与隔离性:操作系统控制虚拟地址到物理地址的映射,可以确保进程只能访问分配给自己的物理内存,从而保护了物理内存和进程的独立性。
- 地址空间的统一与简化:每个进程都认为自己拥有从0开始的连续地址空间,简化了程序的链接和运行。物理内存的分配和进程管理可以解耦。
- 内存使用效率:
- 延迟分配/按需分配 (Demand Paging):当进程通过
malloc等方式申请内存时,操作系统可能只在虚拟地址空间中分配,并不立即分配物理内存。只有当进程实际访问这些虚拟地址时,才会触发缺页中断,由操作系统分配物理内存并建立映射。 - 内存共享:可以将不同进程的虚拟地址映射到同一块物理内存(例如共享库的代码段),节省物理内存。
- 延迟分配/按需分配 (Demand Paging):当进程通过
- 程序加载灵活性:程序在物理内存中可以加载到任意位置,因为虚拟地址到物理地址的映射是由页表管理的。
九、总结
从冯诺依曼结构到复杂的进程管理和虚拟内存,我们今天一起探索了操作系统中关于“进程”的许多核心概念。理解这些机制,不仅能帮助我们更好地编写高效、稳定的程序,也能让我们对计算机系统的工作原理有更深刻的认识。希望这篇博客能为你打开一扇通往操作系统内部世界的大门!
4076

被折叠的 条评论
为什么被折叠?



