跳转至

第三章 程序运行原理

学习目标

  • 理解从源代码到可执行程序的全过程:编译、链接、加载
  • 掌握进程和线程的概念,理解它们的区别和联系
  • 理解并发与并行的区别
  • 掌握操作系统在程序运行中的角色
  • 了解系统调用的概念和常用系统调用

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下的加载过程

  1. shell调用execve系统调用
  2. 内核检查可执行文件格式(如ELF)
  3. 读取ELF Header,获取入口点地址
  4. 根据Program Header Table,将文件的各个段映射到虚拟内存
  5. 创建栈、设置参数和环境变量
  6. 设置寄存器,将控制权交给入口点

内存布局

程序加载后,在虚拟内存中的典型布局:

高位地址
┌────────────────────┐
│       内核空间      │
├────────────────────┤ ← 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)

\[S = \frac{T}{T_{parallel}} = \frac{1}{(1-P) + \frac{P}{N}}\]

举例

如果90%的代码可以并行化(N→∞): $\(S = \frac{1}{0.1 + 0} = 10\)$

即使无限多的核心,加速比最多也只有10倍。

启示

Amdahl定律告诉我们,并行化的收益是有限的。要提高加速比: - 尽量增加可并行化的比例P - 减少串行部分 - 但当P接近100%时,加速比主要受核心数量N限制

本节小结

  • 并发是多个任务在同一时间段内交替执行(单核),并行是多个任务在同一时刻同时执行(多核)
  • 并发提高资源利用率和系统响应性,并行提高计算速度
  • 并发实现方式包括多进程、多线程和异步编程
  • 并发编程面临竞态条件、死锁、活锁、饥饿等挑战
  • Amdahl定律指出并行化的加速比受串行部分限制

3.4 操作系统与系统调用

3.4.1 操作系统的作用

操作系统(Operating System, OS)是计算机系统中最基础的系统软件,它是用户和硬件之间的桥梁。

操作系统的核心功能

  1. 资源管理
  2. CPU调度:为多个进程分配CPU时间
  3. 内存管理:分配和回收内存,实现虚拟内存
  4. 设备管理:管理各种I/O设备
  5. 文件系统管理:组织和管理磁盘上的数据

  6. 提供服务

  7. 进程管理:创建、终止、调度进程
  8. 进程间通信:提供进程间通信机制
  9. 保护和安全:隔离进程,保护资源

  10. 抽象接口

  11. 将硬件抽象为更易用的接口
  12. 提供统一的系统调用接口

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系统调用过程

  1. 用户程序调用C库函数(如read)
  2. C库准备系统调用参数(如文件描述符、缓冲区地址、字节数)
  3. C库执行syscall指令(或int 0x80软中断)
  4. CPU切换到内核态,跳转到内核设定的系统调用入口
  5. 内核根据系统调用号,调用相应的内核函数
  6. 内核执行操作,将结果返回
  7. CPU切换回用户态,返回到C库
  8. 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启动

  1. 计算机上电,CPU开始执行BIOS/UEFI代码
  2. BIOS/UEFI进行POST(加电自检)
  3. BIOS/UEFI根据启动顺序,读取启动设备(通常是硬盘)的第一个扇区(MBR或EFI分区)
  4. 加载引导装载程序(Bootloader,如GRUB)

引导装载程序

  1. 从磁盘读取操作系统内核
  2. 将控制权交给操作系统内核

Linux内核启动

  1. 内核解压(如果是压缩内核)
  2. 初始化内核各个子系统(内存管理、进程调度等)
  3. 挂载根文件系统(root filesystem)
  4. 启动第一个用户空间进程(PID 1,通常是systemd/init)
  5. 系统进入多用户模式

init进程

init进程是系统中的第一个用户空间进程,负责: - 启动系统服务(如网络服务、SSH服务) - 管理用户登录 - 回收孤儿进程 - 处理系统关机

3.4.8 应用程序的执行

当我们在shell中运行一个程序时:

  1. shell解析命令,如果是内置命令则直接执行,否则认为是可执行文件
  2. shell调用fork(),创建一个子进程
  3. 子进程调用execve(),将可执行文件加载到内存
  4. 新程序开始执行
  5. 父进程调用wait(),等待子进程结束
  6. 子进程结束后,父进程回收资源,继续等待下一个命令
// 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创建新进程

理解程序运行原理,有助于编写正确的程序和诊断问题。


思考与练习

  1. 编译与链接
  2. 描述从源代码到可执行文件的完整过程,各个阶段分别完成什么任务?
  3. 静态链接和动态链接各有什么优缺点?
  4. 什么是符号解析?为什么链接时需要解析符号?

  5. 进程与线程

  6. fork()系统调用有什么特殊之处?为什么它返回两次?
  7. 进程和线程有什么区别?什么情况下应该使用多线程而不是多进程?
  8. 什么是竞态条件?请举例说明。

  9. 并发与并行

  10. 并发和并行的区别是什么?请各举一个例子。
  11. 什么是死锁?死锁产生的四个必要条件是什么?
  12. 如果程序中有50%的代码可以并行化,使用4核处理器,最大加速比是多少?

  13. 系统调用

  14. 为什么用户程序不能直接访问硬件,而必须通过系统调用?
  15. 描述一次read系统调用的完整过程。
  16. open()和fopen()有什么区别?各自属于哪一层?

  17. 综合应用

  18. 假设你编写了一个Web服务器程序,分析它可能使用哪些系统调用?
  19. 解释为什么一个线程的崩溃可能导致整个进程崩溃,而进程的隔离性可以防止这种情况?
  20. 在多线程程序中,为什么需要同步机制?请分析如果不同步会发生什么问题?