第三章 程序运行原理
学习目标
- 理解从源代码到可执行程序的全过程:编译、链接、加载
- 掌握进程和线程的概念,理解它们的区别和联系
- 理解并发与并行的区别
- 掌握操作系统在程序运行中的角色
- 了解系统调用的概念和常用系统调用
3.1 从源代码到可执行程序
3.1.1 程序的生命周期
当我们编写一个程序时,从键盘敲下第一行代码,到程序最终在计算机上运行,经历了多个阶段。这个过程可以概括为:
源代码 → 预处理器 → 编译器 → 汇编器 → 目标文件 → 链接器 → 可执行文件 → 加载器 → 进程
这个看似简单的流程,实际上涉及了多个复杂的转换步骤。理解这些转换过程,对于理解计算机系统的工作方式至关重要。
3.1.2 编译过程详解
编译(Compilation)是将高级语言(如C、C++、Rust)转换为机器能理解的形式的第一步。但"编译"这个术语实际上涵盖了多个子阶段。
1. 预处理(Preprocessing)
预处理是编译的第一个阶段,负责处理源代码中的预处理指令。
预处理指令以#开头,常见的包括:
- #include:包含头文件内容
- #define:定义宏
- #if、#ifdef、#endif:条件编译
例如,对于以下代码:
#define PI 3.14159
#include <stdio.h>
int main() {
printf("Hello, PI is %f\n", PI);
return 0;
}
预处理后,#define PI 3.14159会被替换为实际的数值,#include <stdio.h>会被stdio.h文件的内容替换。
2. 编译(Compiling)
编译阶段将预处理后的源代码转换为汇编语言。这一阶段是整个编译过程中最复杂的部分。
编译阶段主要包括: - 词法分析(Lexical Analysis):将源代码分割成一个个的标记(Token),如关键字、标识符、运算符等 - 语法分析(Syntax Analysis):将标记组织成语法树,检查语法是否正确 - 语义分析(Semantic Analysis):检查类型是否匹配、变量是否声明等语义问题 - 优化(Optimization):对代码进行各种优化,如常量折叠、死代码消除、循环优化等 - 代码生成(Code Generation):生成目标平台的汇编代码
3. 汇编(Assembly)
汇编阶段将汇编语言转换为机器码,生成目标文件(Object File)。
目标文件是二进制格式,包含: - 机器指令 - 程序数据 - 符号表(Symbol Table):记录程序中的变量和函数名及其地址 - 调试信息 - 重定位信息
不同平台的目标文件格式不同: - Linux/Unix:ELF(Executable and Linkable Format) - Windows:PE(Portable Executable) - macOS:Mach-O
3.1.3 链接过程
链接(Linking)是将多个目标文件合并成一个可执行文件的过程。
为什么需要链接?
现代程序通常由多个源文件组成,每个源文件独立编译成目标文件。链接器负责: - 合并所有目标文件 - 解析符号引用(一个文件中引用的函数/变量可能在另一个文件中定义) - 确定每个符号的最终地址 - 决定内存布局
链接的两种类型:
1. 静态链接(Static Linking)
静态链接在编译时完成,所有代码被合并到一个可执行文件中。
优点: - 可执行文件是独立的,不依赖外部库 - 加载速度快(无需在运行时加载库)
缺点: - 可执行文件体积较大 - 如果多个程序使用同一个库,每个程序都有一份库的副本 - 更新库需要重新编译所有程序
2. 动态链接(Dynamic Linking)
动态链接在程序加载或运行时完成,库代码不直接写入可执行文件,而是记录对库的引用。
优点: - 可执行文件体积较小 - 多个程序共享同一个库的代码,节省内存 - 更新库不需要重新编译使用该库的程序
缺点: - 加载速度稍慢(需要在运行时解析符号) - 如果库文件缺失或版本不匹配,程序无法运行
动态链接库: - Linux:.so文件(Shared Object) - Windows:.dll文件(Dynamic Link Library) - macOS:.dylib文件
符号解析(Symbol Resolution):
链接器需要解析各种符号引用:
- 外部符号(External Symbol):在一个目标文件中引用,但在另一个目标文件中定义
- 强符号(Strong Symbol):已初始化的全局变量和函数
- 弱符号(Weak Symbol):未初始化的全局变量
链接规则: - 每个符号只能有一个定义 - 如果有多个强符号,会产生链接错误(重复定义) - 弱符号可以被强符号覆盖
3.1.4 可执行文件格式
ELF(Executable and Linkable Format)是Linux和许多Unix系统使用的可执行文件格式。理解ELF格式有助于理解程序的加载和运行。
ELF文件结构:
┌─────────────────────┐
│ ELF Header │ ← 文件基本信息:入口点、段表位置等
├─────────────────────┤
│ Program Header Table│ ← 加载信息(运行时需要)
├─────────────────────┤
│ .text │ ← 代码段:机器指令
├─────────────────────┤
│ .rodata │ ← 只读数据段:常量字符串等
├─────────────────────┤
│ .data │ ← 已初始化数据段:全局变量等
├─────────────────────┤
│ .bss │ ← 未初始化数据段
├─────────────────────┤
│ ...其他段... │
├─────────────────────┤
│ Section Header Table│ ← 段表(链接时需要)
└─────────────────────┘
ELF Header包含: - Magic number:标识ELF文件 - 程序入口点(Entry Point) - Program Header Table的位置和大小 - Section Header Table的位置和大小 - 等等
Program Header Table告诉加载器如何加载文件: - 每个表项描述一个段(Segment) - 包括段的类型、文件中的偏移、虚拟地址、权限(读/写/执行)等
常见的段(Section):
- .text:存放可执行指令
- .data:存放已初始化的全局和静态变量
- .bss:存放未初始化的全局和静态变量(不占文件空间,只预留位置)
- .rodata:存放只读数据,如字符串常量
- .symtab:符号表
- .strtab:字符串表
- .debug:调试信息
3.1.5 加载可执行文件
当我们运行一个程序时,加载器(Loader)负责将可执行文件加载到内存中,并开始执行。
Linux下的加载过程:
- shell调用execve系统调用
- 内核检查可执行文件格式(如ELF)
- 读取ELF Header,获取入口点地址
- 根据Program Header Table,将文件的各个段映射到虚拟内存
- 创建栈、设置参数和环境变量
- 设置寄存器,将控制权交给入口点
内存布局:
程序加载后,在虚拟内存中的典型布局:
高位地址
┌────────────────────┐
│ 内核空间 │
├────────────────────┤ ← stack pointer初始位置
│ │
│ 栈 │ ← 向下增长,存储函数调用、局部变量
│ │
├────────────────────┤
│ 未使用 │
├────────────────────┤
│ 堆 │ ← 向上增长,动态分配内存(malloc/new)
│ │
├────────────────────┤
│ .data 和 .bss │ ← 全局/静态数据
├────────────────────┤
│ .text │ ← 代码段
├────────────────────┤
│ ELF Header等 │
└────────────────────┘
低位地址
本节小结
- 程序从源代码到可执行文件需要经过预处理、编译、汇编、链接四个阶段
- 预处理处理宏包含和条件编译,编译进行词法分析、语法分析、语义分析、优化和代码生成
- 链接将多个目标文件合并成可执行文件,解析符号引用
- 静态链接在编译时完成,动态链接在运行时完成
- ELF是Linux常用的可执行文件格式,包含ELF Header、Program Header Table和各种段
- 加载器负责将可执行文件加载到内存,并开始执行程序
3.2 进程与线程
3.2.1 进程的概念
进程(Process)是操作系统中一个非常重要的概念。它是程序执行的实例,是操作系统进行资源分配和调度的基本单位。
要理解进程,我们需要从两个角度来看: - 从用户角度看:进程是运行中的程序,是一个活动的实体 - 从操作系统角度看:进程是一个资源容器的数据结构,包含程序运行所需的所有资源
进程的组成:
每个进程都有自己独立的虚拟地址空间、寄存器状态、程序计数器、栈、堆等资源。
进程的数据结构(进程控制块/PCB)
├── PID(进程标识符)
├── 状态(运行、就绪、阻塞等)
├── 程序计数器
├── 寄存器状态
├── 内存管理信息(页表/段表)
├── 打开的文件列表
├── 信号处理方式
├── 资源使用信息
└── ...其他信息
进程的状态:
进程在生命周期内会处于不同的状态:
- 创建(New):进程正在被创建
- 就绪(Ready):进程已准备好运行,等待CPU分配
- 运行(Running):进程正在CPU上执行
- 阻塞(Blocked):进程因等待某个事件(如I/O完成)而暂停
- 终止(Terminated):进程执行完毕
状态转换: - 新进程创建 → 就绪 - 调度程序选择该进程 → 运行 - 时间片用完 → 就绪 - 进程请求I/O → 阻塞 - I/O完成 → 就绪 - 进程结束 → 终止
3.2.2 进程的创建
在Linux系统中,进程的创建通过fork系统调用完成。
fork的作用:
fork创建一个新的进程,称为子进程。调用fork的进程是父进程。
fork的特殊之处在于:它被调用一次,但返回两次——在父进程中返回子进程的PID,在子进程中返回0。
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("我是子进程,PID=%d\n", getpid());
} else {
printf("我是父进程,子进程PID=%d\n", pid);
}
return 0;
}
执行结果:
我是父进程,子进程PID=12345
我是子进程,PID=12346
fork之后发生了什么:
fork之后,父进程和子进程拥有: - 相同的代码段 - 相同的全局变量和堆(复制) - 独立的栈空间(复制) - 独立的寄存器状态和程序计数器
子进程是父进程的副本,但它们是独立的进程,有各自的虚拟地址空间(尽管内容相同)。对子进程的修改不会影响父进程,反之亦然。
3.2.3 进程的终止
进程可以通过多种方式终止:
正常终止: - main函数返回 - 调用exit()函数 - 调用_exit()系统调用
异常终止: - 收到信号(如SIGSEGV、SIGINT) - 调用abort()函数 - 未处理的异常
进程退出的资源回收:
当进程终止时,它并不会立即消失,而是变成一个僵尸进程(Zombie Process)。僵尸进程保留进程的退出状态等信息,等待父进程调用wait系统调用来收集这些信息。
如果父进程没有调用wait,子进程就会一直保持僵尸状态。但init进程(PID=1)会定期回收孤儿进程的僵尸。
孤儿进程(Orphan Process):
如果父进程先于子进程终止,子进程就会变成孤儿进程,被init进程(PID=1)收养。init会负责回收这些子进程。
3.2.4 线程的概念
线程(Thread)是进程内的执行单元,是CPU调度的基本单位。
在引入线程之前,进程是调度的基本单位。但进程有一个问题:同一进程内的多个任务无法共享地址空间,如果要共享数据,必须通过进程间通信(IPC)机制,效率较低。
线程的引入解决了这个问题:同一进程内的多个线程共享: - 地址空间 - 全局变量 - 堆 - 打开的文件 - 信号处理函数
但每个线程有自己独立的: - 程序计数器 - 寄存器状态 - 栈 - errno(错误码)
3.2.5 进程与线程的区别
| 特性 | 进程 | 线程 |
|---|---|---|
| 创建速度 | 较慢(需要复制整个地址空间) | 较快(可以共享进程资源) |
| 通信速度 | 较慢(需要IPC机制) | 较快(直接共享内存) |
| 隔离性 | 完全隔离,一个进程崩溃不影响其他进程 | 共享地址空间,一个线程崩溃可能导致整个进程崩溃 |
| 资源占用 | 较多(每个进程有独立地址空间) | 较少(共享大部分资源) |
| 同步复杂度 | 较低(独立地址空间天然隔离) | 较高(需要显式同步,避免竞态条件) |
3.2.6 线程的创建与同步
创建线程(以POSIX线程为例):
#include <pthread.h>
#include <stdio.h>
void* thread_function(void* arg) {
int* num = (int*)arg;
printf("线程运行,参数=%d\n", *num);
return NULL;
}
int main() {
pthread_t thread;
int arg = 42;
// 创建线程
pthread_create(&thread, NULL, thread_function, &arg);
// 等待线程结束
pthread_join(thread, NULL);
return 0;
}
线程同步:
由于多个线程共享地址空间,如果不加以控制,并发访问共享数据会导致竞态条件(Race Condition)。
常见的线程同步机制:
1. 互斥锁(Mutex)
互斥锁用于保护共享资源,一次只允许一个线程访问。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mutex);
// 访问共享资源
pthread_mutex_unlock(&mutex);
2. 信号量(Semaphore)
信号量是一个计数器,用于控制对共享资源的访问数量。
sem_t sem;
sem_init(&sem, 0, 1); // 初始值为1
sem_wait(&sem); // 减1,如果为0则阻塞
// 访问共享资源
sem_post(&sem); // 加1
3. 条件变量(Condition Variable)
条件变量用于线程间的等待和通知机制。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_lock(&mutex);
while (some_condition_is_false) {
pthread_cond_wait(&cond, &mutex); // 解锁mutex并等待
}
// 条件满足,处理
pthread_mutex_unlock(&mutex);
3.2.7 线程的状态
线程与进程类似,也有多种状态: - 创建:线程正在被创建 - 就绪:线程已准备好运行,等待CPU - 运行:线程正在CPU上执行 - 阻塞:线程因等待某个条件而暂停 - 终止:线程执行完毕
线程的创建和终止比进程快,因为线程不需要创建新的地址空间。
本节小结
- 进程是程序执行的实例,是操作系统资源分配的基本单位
- 进程有独立的地址空间,通过fork创建,通过exit/_exit终止
- 线程是进程内的执行单元,是CPU调度的基本单位
- 同一进程的多个线程共享地址空间,但有独立的栈和寄存器
- 线程同步机制包括互斥锁、信号量和条件变量,用于防止竞态条件
- 进程和线程各有优缺点,选择取决于具体应用场景
3.3 并发与并行
3.3.1 并发与并行的概念
并发(Concurrency)和并行(Parallelism)是两个容易混淆但含义不同的概念。
并发(Concurrency):
并发是指多个任务在同一时间段内交替执行。在单核CPU上,由于只有一个核心,操作系统通过快速切换任务,让人感觉多个任务在同时执行,但任意时刻只有一个任务在CPU上运行。
时间 →
┌────────┬────────┬────────┬────────┐
│ 任务A │ 任务B │ 任务A │ 任务B │
└────────┴────────┴────────┴────────┘
↑ ↑
切换 切换
并行(Parallelism):
并行是指多个任务在同一时刻同时执行。这需要多核CPU或多台计算机,每个处理单元同时执行不同的任务。
时间 →
┌────────┬────────┐
│ 任务A │ 任务B │ 同时执行
│ (核1) │ (核2) │
└────────┴────────┘
简单类比: - 并发就像一个厨师同时管理多个锅,通过切换注意力和时间来"同时"处理多个烹饪任务 - 并行就像多个厨师各自负责一个锅,真正同时地处理多个任务
3.3.2 并发的应用场景
并发不是为了加快执行速度,而是为了: - 提高资源利用率:当一个任务等待I/O时,CPU可以切换到另一个任务 - 提高系统响应性:如GUI程序中,后台任务不会阻塞用户界面 - 简化问题模型:某些问题天然适合分解为多个独立或半独立子任务
I/O密集型任务的并发:
对于涉及大量I/O操作的任务(如Web服务器),并发可以显著提高吞吐量。
单线程顺序处理:
请求1 → [等待I/O] → 处理 → 响应1
请求2 → [等待I/O] → 处理 → 响应2
...
总时间 = N × (I/O时间 + 处理时间)
多线程/异步并发处理:
请求1 → [等待I/O]
请求2 → [等待I/O] → 处理 → 响应2
请求3 → [等待I/O] → 处理 → 响应3
...
总时间 ≈ N × I/O时间 + 处理时间(重叠I/O等待)
3.3.3 并行的应用场景
并行主要用于计算密集型任务,通过多核同时计算来加速执行。
并行计算的例子:
1. 矩阵乘法
对于大型矩阵乘法,可以将矩阵分块,分配给不同CPU核心计算:
A B
┌───┬───┐ ┌───┬───┐
│ A11│ A12│ × │ B11│ B12│
├───┼───┤ ├───┼───┤
│ A21│ A22│ │ B21│ B22│
└───┴───┘ └───┴───┘
A11和B11的乘法可以分配给核心1,A12和B21的乘法可以分配给核心2,以此类推。
2. 图像处理
图像的每个像素处理通常相互独立,可以将图像分成多个区域,并行处理。
3. 科学计算
气候模拟、流体力学模拟、分子动力学等科学计算,需要大量浮点运算,并行化可以显著加速。
3.3.4 并发与并行的实现方式
多进程并发:
进程A ←→ 进程B ←→ 进程C
(通过IPC通信,如管道、消息队列、共享内存)
优点:隔离性好,一个进程崩溃不影响其他进程 缺点:通信开销大(需要复制数据)
多线程并发:
线程A ←→ 线程B ←→ 线程C
(直接共享内存地址空间)
优点:通信效率高(直接访问共享内存) 缺点:需要显式同步,一个线程崩溃可能导致整个进程崩溃
异步编程:
异步编程是一种单线程并发模型,通过事件循环和回调函数实现。
# Python异步IO示例
import asyncio
async def fetch(url):
# 发起网络请求,但不阻塞
response = await request.get(url)
return response
async def main():
# 并发发起多个请求
results = await asyncio.gather(
fetch("http://example.com/1"),
fetch("http://example.com/2"),
fetch("http://example.com/3"),
)
asyncio.run(main())
3.3.5 并发的问题与挑战
并发编程比顺序编程复杂得多,因为并发会引入新的问题:
1. 竞态条件(Race Condition)
当多个线程并发访问和修改共享数据时,最终结果取决于线程的执行顺序,就发生了竞态条件。
示例:
// 共享变量
int counter = 0;
// 线程A执行的函数
void* thread_a(void* arg) {
for (int i = 0; i < 1000000; i++) {
counter++; // 读取、+1、写回
}
return NULL;
}
// 线程B执行的函数
void* thread_b(void* arg) {
for (int i = 0; i < 1000000; i++) {
counter++; // 读取、+1、写回
}
return NULL;
}
期望结果:counter = 2000000 但由于竞态条件,实际结果往往小于2000000。
2. 死锁(Deadlock)
死锁是指两个或多个线程互相等待对方释放资源,导致都无法继续执行。
示例:
线程A:获取锁1,等待锁2
线程B:获取锁2,等待锁1
结果:两个线程都在等待对方,永远无法继续
死锁产生的四个必要条件(咖啡馆定律): - 互斥:资源每次只能被一个线程使用 - 持有并等待:线程持有资源的同时请求其他资源 - 不可抢占:资源不能被强制从线程中夺走 - 循环等待:存在一个线程循环等待链
避免死锁的方法: - 破坏其中一个条件 - 固定顺序获取锁 - 使用try_lock而非lock,设置超时
3. 活锁(Livelock)
活锁是指线程一直在运行,但无法取得进展。与死锁不同,活锁中的线程不是在等待,而是不断重复相同的操作。
例如:两辆车在狭窄的桥上相遇,双方都礼让对方,但由于礼让动作相同,双方都无法通过。
4. 饥饿(Starvation)
饥饿是指某些线程由于各种原因无法获得所需的资源,一直无法执行。
例如:如果系统总是调度高优先级任务,低优先级任务可能永远得不到CPU时间。
3.3.6 Amdahl定律
Amdahl定律是并行计算中的重要定律,用于计算并行化能达到的最大加速比。
假设程序中有P比例的部分可以并行化,(1-P)比例的部分必须串行执行。
- 单核执行时间:T
- 并行执行时间:\(T_{parallel} = T \times (1-P) + \frac{T \times P}{N}\)
其中N是核心数量。
加速比(Speedup):
举例:
如果90%的代码可以并行化(N→∞): $\(S = \frac{1}{0.1 + 0} = 10\)$
即使无限多的核心,加速比最多也只有10倍。
启示:
Amdahl定律告诉我们,并行化的收益是有限的。要提高加速比: - 尽量增加可并行化的比例P - 减少串行部分 - 但当P接近100%时,加速比主要受核心数量N限制
本节小结
- 并发是多个任务在同一时间段内交替执行(单核),并行是多个任务在同一时刻同时执行(多核)
- 并发提高资源利用率和系统响应性,并行提高计算速度
- 并发实现方式包括多进程、多线程和异步编程
- 并发编程面临竞态条件、死锁、活锁、饥饿等挑战
- Amdahl定律指出并行化的加速比受串行部分限制
3.4 操作系统与系统调用
3.4.1 操作系统的作用
操作系统(Operating System, OS)是计算机系统中最基础的系统软件,它是用户和硬件之间的桥梁。
操作系统的核心功能:
- 资源管理
- CPU调度:为多个进程分配CPU时间
- 内存管理:分配和回收内存,实现虚拟内存
- 设备管理:管理各种I/O设备
-
文件系统管理:组织和管理磁盘上的数据
-
提供服务
- 进程管理:创建、终止、调度进程
- 进程间通信:提供进程间通信机制
-
保护和安全:隔离进程,保护资源
-
抽象接口
- 将硬件抽象为更易用的接口
- 提供统一的系统调用接口
3.4.2 内核与用户态
现代操作系统通常采用微内核(Microkernel)或宏内核(Monolithic Kernel)设计。
宏内核(如Linux):
将大多数操作系统服务(进程管理、内存管理、文件系统、设备驱动等)都运行在内核态(Kernel Mode)。
优点: - 性能好(内核内部函数调用速度快) - 各组件配合紧密
缺点: - 内核臃肿(包含大量功能) - 一个组件的问题可能影响整个系统
微内核(如MINIX):
只将最核心的功能(调度、进程间通信)放在内核态,其他服务(文件系统、设备驱动)运行在用户态,作为独立进程。
优点: - 系统更稳定(一个服务崩溃不会影响其他服务) - 内核小巧
缺点: - 组件间通信(IPC)开销大 - 性能稍差
用户态与内核态:
CPU有特权级别(Privilege Level)或Ring(如Ring 0到Ring 3): - 内核态(Ring 0):可以执行特权指令,访问所有硬件 - 用户态(Ring 3):只能访问有限的资源,不能直接执行特权指令
用户程序运行在用户态,当需要操作系统服务时,通过系统调用进入内核态。
3.4.3 系统调用概述
系统调用(System Call)是用户程序请求操作系统服务的接口。
当用户程序需要: - 读取/写入文件 - 创建新进程 - 分配内存 - 进行网络通信 - 其他需要特权的操作
必须通过系统调用请求内核完成。
系统调用与普通函数调用的区别:
普通函数调用: - 用户代码 → 用户库函数 - 在同一地址空间执行 - 只是一次普通的函数跳转
系统调用: - 用户代码 → 用户库函数 → 触发软中断/syscall指令 - 切换到内核态 - 内核代码执行请求的操作 - 返回用户态 - 返回用户代码
3.4.4 常见系统调用
进程控制:
- fork():创建新进程
- execve():执行新程序
- exit():终止进程
- wait():等待子进程终止
- getpid():获取进程ID
- getppid():获取父进程ID
文件操作:
- open():打开文件
- read():读取文件
- write():写入文件
- close():关闭文件
- lseek():移动文件指针
- unlink():删除文件
内存管理:
- brk():改变数据段大小
- mmap():映射内存
- munmap():解除内存映射
进程通信:
- pipe():创建管道
- socket():创建套接字
- connect():连接套接字
- send()/recv():发送/接收数据
时间相关:
- time():获取当前时间
- alarm():设置闹钟
- sleep():进程睡眠
3.4.5 系统调用的实现
Linux系统调用过程:
- 用户程序调用C库函数(如read)
- C库准备系统调用参数(如文件描述符、缓冲区地址、字节数)
- C库执行syscall指令(或int 0x80软中断)
- CPU切换到内核态,跳转到内核设定的系统调用入口
- 内核根据系统调用号,调用相应的内核函数
- 内核执行操作,将结果返回
- CPU切换回用户态,返回到C库
- C库返回到用户程序
系统调用号:
每个系统调用都有一个唯一的编号(系统调用号),如: - Linux x86-64: - read = 0 - write = 1 - open = 2 - close = 3 - fork = 57 - execve = 59 - exit = 60 - getpid = 39
系统调用表:
内核维护一个系统调用表(sys_call_table),通过系统调用号索引,存储对应内核函数的地址。
3.4.6 C库与系统调用
大多数情况下,程序员不直接使用系统调用,而是通过C标准库(libc)来间接使用。
例如,printf函数内部会调用write系统调用来输出数据:
用户代码 → printf() → write() → syscall → 内核
使用C库的好处: - 接口更友好(比系统调用更易用) - 可移植性(不同操作系统的系统调用不同,但C库提供统一接口) - 功能更丰富(如printf的格式化功能是C库实现的,不是系统调用)
3.4.7 操作系统启动过程
理解操作系统启动过程有助于理解系统各组件如何配合工作。
BIOS/UEFI启动:
- 计算机上电,CPU开始执行BIOS/UEFI代码
- BIOS/UEFI进行POST(加电自检)
- BIOS/UEFI根据启动顺序,读取启动设备(通常是硬盘)的第一个扇区(MBR或EFI分区)
- 加载引导装载程序(Bootloader,如GRUB)
引导装载程序:
- 从磁盘读取操作系统内核
- 将控制权交给操作系统内核
Linux内核启动:
- 内核解压(如果是压缩内核)
- 初始化内核各个子系统(内存管理、进程调度等)
- 挂载根文件系统(root filesystem)
- 启动第一个用户空间进程(PID 1,通常是systemd/init)
- 系统进入多用户模式
init进程:
init进程是系统中的第一个用户空间进程,负责: - 启动系统服务(如网络服务、SSH服务) - 管理用户登录 - 回收孤儿进程 - 处理系统关机
3.4.8 应用程序的执行
当我们在shell中运行一个程序时:
- shell解析命令,如果是内置命令则直接执行,否则认为是可执行文件
- shell调用fork(),创建一个子进程
- 子进程调用execve(),将可执行文件加载到内存
- 新程序开始执行
- 父进程调用wait(),等待子进程结束
- 子进程结束后,父进程回收资源,继续等待下一个命令
// shell执行命令的简化逻辑
if (!builtin(command)) {
pid = fork();
if (pid == 0) {
// 子进程
execve(command, args, envp);
// 如果execve成功,不会到达这里
perror("execve");
exit(1);
} else {
// 父进程
wait(NULL);
}
}
本节小结
- 操作系统管理计算机资源,提供用户程序与硬件之间的接口
- 内核运行在特权模式(内核态),用户程序运行在用户态
- 系统调用是用户程序请求内核服务的接口,通过软中断或syscall指令触发
- 常见系统调用包括进程控制(fork/exec/exit)、文件操作(open/read/write)和内存管理(mmap)
- 程序员通常通过C库间接使用系统调用,C库提供更友好的接口
- 程序执行时,shell通过fork+execve创建新进程
本章小结
本章介绍了程序从源代码到运行的全过程,以及操作系统在程序运行中的角色。
从源代码到可执行文件: - 编译过程包括预处理、编译、汇编、链接四个阶段 - 预处理处理宏和头文件,编译进行词法分析、语法分析和代码生成 - 链接合并目标文件并解析符号引用,分为静态链接和动态链接 - ELF是Linux常用的可执行文件格式
进程与线程: - 进程是程序执行的实例,是资源分配的基本单位 - 线程是进程内的执行单元,是CPU调度的基本单位 - 同一进程的多个线程共享地址空间,但有独立的栈和寄存器 - 线程同步机制包括互斥锁、信号量和条件变量
并发与并行: - 并发是多个任务在同一时间段交替执行(单核),并行是多个任务同时执行(多核) - 并发提高资源利用率,并行提高计算速度 - 并发编程面临竞态条件、死锁、活锁、饥饿等挑战 - Amdahl定律指出并行化的加速比受串行部分限制
操作系统与系统调用: - 操作系统管理计算机资源,提供用户程序与硬件之间的接口 - 系统调用是用户程序请求内核服务的接口 - 常用系统调用包括进程控制(fork/exec)和文件操作(open/read/write) - 程序执行时,shell通过fork+execve创建新进程
理解程序运行原理,有助于编写正确的程序和诊断问题。
思考与练习
- 编译与链接
- 描述从源代码到可执行文件的完整过程,各个阶段分别完成什么任务?
- 静态链接和动态链接各有什么优缺点?
-
什么是符号解析?为什么链接时需要解析符号?
-
进程与线程
- fork()系统调用有什么特殊之处?为什么它返回两次?
- 进程和线程有什么区别?什么情况下应该使用多线程而不是多进程?
-
什么是竞态条件?请举例说明。
-
并发与并行
- 并发和并行的区别是什么?请各举一个例子。
- 什么是死锁?死锁产生的四个必要条件是什么?
-
如果程序中有50%的代码可以并行化,使用4核处理器,最大加速比是多少?
-
系统调用
- 为什么用户程序不能直接访问硬件,而必须通过系统调用?
- 描述一次read系统调用的完整过程。
-
open()和fopen()有什么区别?各自属于哪一层?
-
综合应用
- 假设你编写了一个Web服务器程序,分析它可能使用哪些系统调用?
- 解释为什么一个线程的崩溃可能导致整个进程崩溃,而进程的隔离性可以防止这种情况?
- 在多线程程序中,为什么需要同步机制?请分析如果不同步会发生什么问题?