《深入理解计算机系统》读书笔记

从底层原理到上层应用,全面理解计算机系统

Posted by 广陵观涛 on March 15, 2024

最近读完了 Randal E. Bryant 和 David R. O’Hallaron 合著的《深入理解计算机系统》(CS:APP),这本书让我对计算机系统有了全面而深入的理解。作为一名软件开发人员,了解计算机系统的底层原理对于写出高效、可靠的代码至关重要。在这篇笔记中,我会选择几个核心方面进行详细分析。

一、计算机系统概述

在阅读这本书之前,我对计算机系统的理解非常碎片化,只知道一些零散的概念。但通过阅读,我明白了计算机系统是一个复杂而协调的整体,各个层次之间通过”抽象”和”接口”相互交互。

1.1 计算机系统的层次结构

计算机系统从底层到上层可以分为多个层次,每一层都通过”抽象”和”接口”与相邻层次交互:

硬件层:包括CPU、内存、磁盘、I/O设备等物理组件

  • CPU:执行指令,包含寄存器、运算器、控制器等
  • 内存(RAM):临时存储数据,访问时间约为ns级
  • 磁盘:持久存储,访问时间约为ms级(比内存慢1000倍)
  • 总线:连接各组件,传输数据和控制信号

微架构层:CPU内部的执行机制(如流水线、超标量、乱序执行)

  • 流水线:将指令执行分为取指、译码、执行、访存、写回等阶段
  • 超标量:同时执行多条指令
  • 乱序执行:不按程序顺序执行指令以提高效率

指令集架构(ISA)层:硬件与软件的接口

  • 定义了CPU能执行的指令集(如x86-64)
  • 包括寄存器、寻址方式、指令格式等
  • 是编译器和操作系统的目标平台

操作系统层:管理硬件资源,提供抽象接口

  • 进程管理:调度、同步、通信
  • 内存管理:虚拟内存、分页、分段
  • 文件系统:持久化存储抽象
  • I/O管理:设备驱动、中断处理

系统调用层:用户程序与操作系统交互的接口(如POSIX标准)

  • 文件操作:open、read、write、close
  • 进程控制:fork、exec、exit
  • 内存管理:malloc(底层通过brk/sbrk实现)
  • 通信:pipe、socket

库函数层:对系统调用的封装(如C标准库)

  • printf → write系统调用
  • fopen → open系统调用
  • malloc → brk/sbrk + 内存池管理

应用程序层:用户直接使用的软件

  • 浏览器、办公软件等
  • 使用高级语言(Java、Python、Go)编写

用户层:用户直接与应用程序交互,完成各种任务。

小提示:中级程序员需要理解各层次之间的”开销”和”权衡”,例如:

  • 用户态→内核态切换开销:约1000-10000个CPU周期
  • 内存访问vs磁盘访问:速度相差3-4个数量级
  • 系统调用vs库函数:库函数通常是用户态实现,避免了内核切换开销

1.2 现代计算机系统架构

1.2.1 异构计算架构

现代计算机系统越来越多地采用异构计算架构,结合了不同类型的处理器:

CPU+GPU:CPU负责通用计算,GPU负责并行计算(如深度学习、图形渲染) CPU+FPGA:CPU负责通用计算,FPGA负责特定领域的加速(如网络处理、视频编码) ARM big.LITTLE:结合高性能核心和低功耗核心,实现能效平衡

异构计算编程示例

// 使用OpenMP进行CPU+GPU混合编程
#include <omp.h>

void hybrid_compute(float* data, int n) {
    // CPU部分:处理控制逻辑
    #pragma omp parallel for
    for (int i = 0; i < n; i++) {
        if (data[i] < 0) {
            data[i] = 0;
        }
    }

    // GPU部分:处理并行计算
    #pragma omp target teams distribute parallel for
    for (int i = 0; i < n; i++) {
        data[i] = sqrt(data[i]);
    }
}

1.2.2 NUMA架构

NUMA(Non-Uniform Memory Access)架构是现代多处理器系统的常见架构:

  • 每个CPU节点有自己的本地内存
  • 访问本地内存比访问远程内存快
  • 需要优化内存访问模式以提高性能

NUMA优化示例

// 绑定线程到特定CPU节点
#include <numa.h>

void bind_thread_to_node(int node) {
    cpu_set_t mask;
    CPU_ZERO(&mask);

    // 获取节点的CPU列表
    struct bitmask* cpumask = numa_node_to_cpus(node);
    for (int i = 0; i < numa_num_configured_cpus(); i++) {
        if (numa_bitmask_isbitset(cpumask, i)) {
            CPU_SET(i, &mask);
            break;  // 绑定到节点的第一个CPU
        }
    }

    numa_bitmask_free(cpumask);

    if (sched_setaffinity(0, sizeof(cpu_set_t), &mask) < 0) {
        perror("sched_setaffinity failed");
    }
}

1.2.3 现代存储技术

3D XPoint:非易失性内存,速度接近DRAM,容量接近SSD NVMe SSD:基于PCIe接口的SSD,速度比传统SATA SSD快10倍 Persistent Memory:持久化内存,掉电后数据不丢失

现代存储技术的应用

// 使用持久化内存
#include <pmdk.h>

void persistent_memory_example() {
    const char* path = "/mnt/pmem0/data";
    size_t size = 1024 * 1024;

    // 创建或打开持久化内存文件
    int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open failed");
        return;
    }

    // 扩展文件大小
    if (ftruncate(fd, size) < 0) {
        perror("ftruncate failed");
        close(fd);
        return;
    }

    // 映射到持久化内存
    void* pmaddr = pmem_map_file(path, size, PMEM_FILE_CREATE, 0666, NULL, NULL);
    if (pmaddr == NULL) {
        perror("pmem_map_file failed");
        close(fd);
        return;
    }

    // 写入数据
    memset(pmaddr, 0, size);
    pmem_persist(pmaddr, size);

    // 使用数据
    int* data = (int*)pmaddr;
    data[0] = 42;
    pmem_persist(data, sizeof(int));

    // 释放资源
    pmem_unmap(pmaddr, size);
    close(fd);
}

1.2 为什么需要理解计算机系统?

理解计算机系统的底层原理有以下几个重要原因:

1.2.1 写出高效的代码:了解计算机系统的工作原理可以帮助我们写出更高效的代码。例如,了解缓存机制可以帮助我们优化内存访问模式,提高程序的执行速度。

1.2.2 调试和优化程序:当程序出现错误或性能问题时,理解计算机系统的底层原理可以帮助我们快速定位问题所在。

1.2.3 设计可靠的系统:了解计算机系统的局限性可以帮助我们设计更可靠的系统。例如,了解并发编程的原理可以帮助我们避免死锁和竞争条件等问题。

1.2.4 跨平台开发:了解计算机系统的通用原理可以帮助我们更好地进行跨平台开发。

二、信息的表示和处理

计算机系统使用二进制来表示和处理信息。了解信息的表示和处理方式对于理解计算机系统的工作原理至关重要。

2.1 数的表示

计算机系统使用二进制来表示整数和浮点数。

2.1.1 整数的表示和编码

无符号整数

  • 编码范围:0 ≤ x ≤ 2^n - 1
  • 运算特性:模运算(溢出时自动取模)

有符号整数(补码)

  • 编码范围:-2^(n-1) ≤ x ≤ 2^(n-1) - 1
  • 符号位扩展:
    • 正数:高位补0
    • 负数:高位补1
  • 运算特性:
    • 加法:同模运算,溢出时结果错误(需检测)
    • 乘法:高n位截断,结果可能错误(需检测)

补码的优化设计

  • 补码的设计目标是让加法和减法可以用同一套硬件实现
  • 补码的负数表示避免了”负零”问题

编码转换示例

// 32位整数转换示例
unsigned int u = 0x80000000;  // 2^31
int s = (int)u;              // -2^31(补码表示)

printf("u = %u\n", u);       // 2147483648
printf("s = %d\n", s);       // -2147483648

2.1.2 浮点数的表示(IEEE 754标准)

单精度(32位)浮点数

  • 符号位(1位):s
  • 指数位(8位):exp(偏移值127)
  • 尾数位(23位):frac(隐含第一位1)

双精度(64位)浮点数

  • 符号位(1位):s
  • 指数位(11位):exp(偏移值1023)
  • 尾数位(52位):frac(隐含第一位1)

浮点数的表示范围

  • 规格化数:exp ∈ [1, 254](单精度),表示 ±(1.frac)×2^(exp-127)
  • 非规格化数:exp = 0,表示 ±(0.frac)×2^(-126)(处理0和非常小的数)
  • 无穷大:exp = 255,frac = 0,表示 ±∞
  • NaN(Not a Number):exp = 255,frac ≠ 0,表示无效运算结果

浮点数的精度问题

// 浮点数精度示例
float a = 0.1;
float sum = 0.0;
for (int i = 0; i < 10; i++) {
    sum += a;
}

printf("sum = %.10f\n", sum);       // 1.0000001490
printf("sum == 1.0 ? %d\n", sum == 1.0);  // 0(不相等)

小提示:中级程序员在处理浮点数时需要注意:

  • 避免直接比较浮点数是否相等(使用阈值)
  • 了解浮点数的精度限制(如0.1无法精确表示)
  • 考虑使用定点数或decimal类型处理精确计算

2.1.3 位运算的高级应用

位运算优化示例

// 判断奇偶(比%2更高效)
#define IS_ODD(x) ((x) & 1)

// 交换两个数(不使用临时变量)
void swap(int *a, int *b) {
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

// 计算绝对值(避免分支)
int abs_val(int x) {
    int sign = x >> 31;
    return (x ^ sign) - sign;
}

// 取较小值(避免分支)
int min_val(int a, int b) {
    int mask = (a - b) >> 31;
    return b + (a - b) & mask;
}

// 位掩码在权限管理中的应用
typedef enum {
    READ = 1 << 0,
    WRITE = 1 << 1,
    EXECUTE = 1 << 2
} Permission;

void set_permission(int* permissions, Permission perm) {
    *permissions |= perm;
}

void clear_permission(int* permissions, Permission perm) {
    *permissions &= ~perm;
}

int has_permission(int permissions, Permission perm) {
    return permissions & perm;
}

// 硬件寄存器操作示例
#define REGISTER_BASE 0x1000
#define STATUS_REG (REGISTER_BASE + 0x00)
#define CONTROL_REG (REGISTER_BASE + 0x04)

void set_register_bit(int reg, int bit) {
    volatile int* addr = (volatile int*)reg;
    *addr |= (1 << bit);
}

void clear_register_bit(int reg, int bit) {
    volatile int* addr = (volatile int*)reg;
    *addr &= ~(1 << bit);
}

int get_register_bit(int reg, int bit) {
    volatile int* addr = (volatile int*)reg;
    return (*addr >> bit) & 1;
}

2.2 信息的处理

计算机系统使用逻辑运算和算术运算来处理信息。

2.2.1 逻辑运算

逻辑运算包括与、或、非、异或等运算。这些运算可以用来处理二进制数据。

2.2.2 算术运算

算术运算包括加法、减法、乘法和除法等运算。对于有符号整数,计算机系统使用补码来简化运算。

2.3 我的实践经验

在阅读这部分内容之前,我对整数和浮点数的表示方式了解甚少。我经常会遇到一些奇怪的错误,例如整数溢出和浮点数精度问题。

现在,我在写代码时会更加注意整数和浮点数的表示方式。我会避免使用过大或过小的整数,以免导致溢出。我也会尽量避免使用浮点数进行精确计算,而是使用整数或 decimal 类型。

三、程序的机器级表示

程序的机器级表示是计算机系统的核心部分。了解程序的机器级表示可以帮助我们写出更高效的代码。

3.1 汇编语言

汇编语言是计算机系统的低级语言,直接对应机器指令。每一条汇编指令都对应一条机器码。

3.1.1 寄存器

CPU 有多个寄存器,用于存储临时数据。常用的寄存器包括:

  • 通用寄存器:用于存储数据和地址,比如 eax、ebx、ecx、edx 等。
  • 程序计数器:存储下一条指令的地址,CPU 会根据这个地址找到下一条要执行的指令。
  • 栈指针:存储栈顶的地址,管理函数调用时的参数和返回地址。

3.1.2 简单汇编代码示例

; 一个简单的加法程序
section .data
    message db 'Sum: ', 0
section .text
    global _start

_start:
    ; 初始化两个数
    mov eax, 5    ; 把 5 放到 eax 寄存器
    mov ebx, 3    ; 把 3 放到 ebx 寄存器

    ; 相加
    add eax, ebx  ; eax = eax + ebx

    ; 程序结束
    mov eax, 1    ; 系统调用号:退出程序
    int 0x80      ; 触发系统调用

指令解释

  • mov:数据传送指令,将数据从一个地方搬到另一个地方
  • add:加法指令,将两个数相加
  • int 0x80:触发系统调用

小提示:初级程序员不需要精通汇编,但了解一点汇编知识能帮助你理解程序的底层运行机制。

3.2 程序的机器级表示与优化

3.2.1 函数调用的堆栈结构

x86-64架构的堆栈帧

; 函数调用时的堆栈变化
; Caller:
;   push rbp
;   mov rbp, rsp
;   sub rsp, 32        ; 分配局部变量空间
;   mov [rbp-8], rdi   ; 保存第一个参数
;   mov [rbp-16], rsi  ; 保存第二个参数

; Callee:
;   push rbx           ; 保存被调用者保存寄存器
;   push r12
;   ...
;   mov rax, [rbp+16]  ; 访问第一个参数(返回地址在[rbp+8])
;   ...
;   pop r12
;   pop rbx
;   leave
;   ret

函数参数传递规则(x86-64)

  • 前6个参数:通过rdi, rsi, rdx, rcx, r8, r9传递
  • 多于6个参数:通过栈传递
  • 返回值:通过rax传递

3.2.2 编译器优化技术

常见优化级别

  • O0(无优化):保留所有调试信息,代码最慢
  • O1(基本优化):去除冗余代码,内联简单函数
  • O2(高级优化):启用大多数优化(常用)
  • O3(极致优化):包含循环展开、向量化等激进优化
  • Os(空间优化):优化代码大小

优化示例

// 未优化的代码
int sum(int n) {
    int s = 0;
    for (int i = 0; i <= n; i++) {
        s += i;
    }
    return s;
}

// O2优化后的汇编(简化版)
sum:
    lea rax, [rdi+1]    ; rax = n + 1
    imul rax, rdi       ; rax = n * (n + 1)
    sar rax, 1          ; rax = rax / 2(使用位移代替除法)
    ret

小提示:中级程序员需要了解:

  • 查看编译后的汇编代码:gcc -O2 -S filename.c
  • 使用objdump反汇编可执行文件:objdump -d executable
  • 优化可能改变代码的执行顺序,影响调试(需要调试信息)

3.2.3 性能优化技巧

1. 循环优化

// 未优化的循环
void init_array(int *arr, int n) {
    for (int i = 0; i < n; i++) {
        arr[i] = i * 2;
    }
}

// 优化后的循环(使用寄存器变量)
void init_array_optimized(int *arr, int n) {
    register int i;
    for (i = 0; i < n; i++) {
        arr[i] = i << 1;  // 左移代替乘法
    }
}

2. 内存访问优化

// 未优化的内存访问
struct Point {
    int x, y, z;
};

int sum_z(struct Point *points, int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += points[i].z;  // 跳跃访问内存
    }
    return sum;
}

// 优化后的内存访问
int sum_z_optimized(struct Point *points, int n) {
    int sum = 0;
    struct Point *p = points;
    struct Point *end = points + n;
    for (; p < end; p++) {
        sum += p->z;  // 连续访问内存
    }
    return sum;
}

3.2.4 现代指令集优化

AVX2指令优化示例

#include <immintrin.h>

void matrix_multiply_avx2(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j += 8) {
            __m256 sum = _mm256_setzero_ps();
            for (int k = 0; k < n; k++) {
                __m256 va = _mm256_broadcast_ss(&a[i * n + k]);
                __m256 vb = _mm256_loadu_ps(&b[k * n + j]);
                sum = _mm256_add_ps(sum, _mm256_mul_ps(va, vb));
            }
            _mm256_storeu_ps(&c[i * n + j], sum);
        }
    }
}

3.2.5 二进制分析工具的高级用法

# 使用objdump查看详细的汇编信息
objdump -d -M intel program.o

# 使用readelf查看ELF文件信息
readelf -h program  # 查看文件头
readelf -l program  # 查看程序头
readelf -S program  # 查看节头
readelf -r program  # 查看重定位信息

# 使用nm查看符号表
nm -D program  # 查看动态符号表
nm -C program  # 查看C++符号(demangle)

# 使用addr2line定位程序崩溃位置
addr2line -e program 0x400523 -f  # 查看地址对应的函数和文件

3.3 程序的执行过程

程序的执行过程包括以下几个步骤:

  1. 编译:将源代码编译成汇编语言。
  2. 汇编:将汇编语言汇编成机器指令。
  3. 链接:将多个目标文件链接成可执行文件。
  4. 执行:加载可执行文件到内存中,然后执行。

3.3 优化程序性能

了解程序的机器级表示可以帮助我们优化程序的性能。例如,我们可以通过优化内存访问模式来提高缓存命中率,从而提高程序的执行速度。

3.4 我的实践经验

在阅读这部分内容之前,我对汇编语言了解甚少。我经常会遇到一些性能问题,但不知道如何优化。

现在,我在写代码时会更加注意程序的机器级表示。我会避免使用过多的内存访问,以免降低缓存命中率。我也会尽量使用高效的算法和数据结构,提高程序的执行速度。

四、存储器层次结构

计算机系统的存储器层次结构是影响程序性能的重要因素。了解存储器层次结构可以帮助我们优化程序的性能。

4.1 存储器层次结构概述

计算机系统的存储器层次结构从快到慢、容量从小到大可以分为多个层次:

寄存器:CPU 内部的寄存器,速度最快,容量最小。

L1 缓存:CPU 内部的缓存,速度比寄存器慢,但容量比寄存器大。

L2 缓存:CPU 内部或外部的缓存,速度比 L1 缓存慢,但容量比 L1 缓存大。

L3 缓存:多个 CPU 共享的缓存,速度比 L2 缓存慢,但容量比 L2 缓存大。

主内存:计算机系统的主内存,速度比缓存慢,但容量比缓存大。

磁盘:计算机系统的外部存储设备,速度最慢,但容量最大。

4.2 缓存机制

缓存机制是存储器层次结构的核心部分。缓存的作用是存储最近访问的数据,以便下次访问时能够快速获取。

比喻理解:想象你在图书馆找书,最近用过的书你会放在书桌上(缓存),而不是每次都放回书架(内存),这样下次找书会快很多。

4.2.1 缓存命中率

缓存命中率是指缓存中找到所需数据的概率。缓存命中率越高,程序的执行速度越快。

命中率计算:命中率 = 命中次数 / 总访问次数

4.2.2 缓存替换策略

当缓存满时,需要替换掉一些数据。常用的缓存替换策略包括:

  • 随机替换:随机选择一个数据块进行替换。
  • 先进先出:替换最早进入缓存的数据块,就像排队一样。
  • 最近最少使用:替换最近最少使用的数据块,这个策略最常用。

4.2.3 缓存优化的代码示例

// 优化前:跳跃式访问数组,缓存命中率低
int sum1(int arr[], int n) {
    int sum = 0;
    for (int i = 0; i < n; i += 2) {
        sum += arr[i];
    }
    return sum;
}

// 优化后:连续访问数组,缓存命中率高
int sum2(int arr[], int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[i];
    }
    return sum;
}

性能差异:在大数组上,sum2 比 sum1 快很多,因为它使用了连续的内存访问模式。

4.3 高级存储器优化

4.3.1 缓存机制的详细分析

缓存的工作原理

  • 缓存块(Cache Line):内存与缓存之间传输的最小单位(通常64字节)
  • 映射方式:直接映射、组相联、全相联
  • 替换策略:LRU、LFU、随机替换

缓存未命中(Miss)类型

  1. 强制性未命中:第一次访问数据时发生
  2. 冲突未命中:不同内存地址映射到同一缓存块
  3. 容量未命中:缓存容量不足

减少缓存未命中的方法

// 未优化的代码(缓存未命中率高)
int sum_array(int arr[100][100]) {
    int sum = 0;
    for (int j = 0; j < 100; j++) {
        for (int i = 0; i < 100; i++) {
            sum += arr[i][j];  // 列优先访问,不连续
        }
    }
    return sum;
}

// 优化后的代码(缓存友好)
int sum_array_optimized(int arr[100][100]) {
    int sum = 0;
    for (int i = 0; i < 100; i++) {
        for (int j = 0; j < 100; j++) {
            sum += arr[i][j];  // 行优先访问,连续内存
        }
    }
    return sum;
}

性能测试结果(在现代CPU上)

  • 未优化版本:约100 ns/元素
  • 优化版本:约1 ns/元素(速度提升100倍)

4.3.2 虚拟内存技术

虚拟内存的作用

  • 提供比物理内存更大的地址空间
  • 保护进程地址空间(各进程地址空间独立)
  • 简化内存管理(分页、分段)

页表和TLB(Translation Lookaside Buffer)

  • 页表:将虚拟地址映射到物理地址
  • TLB:页表项的缓存(访问时间约为CPU周期级)
  • TLB未命中:需要访问页表(时间增加几十倍)

工作集(Working Set)

  • 进程在一段时间内频繁访问的内存页集合
  • 工作集大小应小于物理内存,否则会发生”颠簸”(Thrashing)

内存优化示例

// 分配大数组时的内存优化
#define SIZE 100000000

// 未优化(可能导致TLB未命中和缺页中断)
void process_data1() {
    int *arr = (int *)malloc(SIZE * sizeof(int));
    for (int i = 0; i < SIZE; i += 4096/sizeof(int)) {
        arr[i] = i;  // 跳跃访问,不连续页
    }
    free(arr);
}

// 优化(连续访问,TLB友好)
void process_data2() {
    int *arr = (int *)malloc(SIZE * sizeof(int));
    for (int i = 0; i < SIZE; i++) {
        arr[i] = i;  // 连续访问内存页
    }
    free(arr);
}

4.3.3 大页优化

大页的优势

  • 减少TLB未命中率
  • 提高内存访问速度
  • 适合处理大内存的应用(如数据库、虚拟机)

使用大页的示例

// 使用大页优化内存访问
#define HPAGE_SIZE (2 * 1024 * 1024)

void* allocate_hugepage_memory(size_t size) {
    int fd = open("/dev/hugepages/mymemory", O_RDWR | O_CREAT | O_EXCL, 0755);
    if (fd < 0) return NULL;

    if (ftruncate(fd, size) < 0) {
        close(fd);
        unlink("/dev/hugepages/mymemory");
        return NULL;
    }

    void* ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    close(fd);
    if (ptr == MAP_FAILED) {
        unlink("/dev/hugepages/mymemory");
        return NULL;
    }

    return ptr;
}

void free_hugepage_memory(void* ptr, size_t size) {
    munmap(ptr, size);
    unlink("/dev/hugepages/mymemory");
}

4.4 我的实践经验

在阅读这部分内容之前,我对存储器层次结构了解甚少。我经常会遇到一些性能问题,但不知道如何优化。

现在,我在写代码时会更加注意内存访问模式。我会尽量使用连续的内存访问模式,避免跳跃式的内存访问。我也会尽量减少内存的使用,避免频繁的内存分配和释放。

五、链接

链接是将多个目标文件和库文件链接成可执行文件的过程。了解链接过程可以帮助我们更好地理解程序的结构和调试程序。

5.1 链接的过程

链接的过程可以分为以下几个步骤:

  1. 符号解析:解析目标文件和库文件中的符号。
  2. 重定位:将符号引用替换为实际的地址。
  3. 合并:将多个目标文件和库文件合并成一个可执行文件。

5.2 静态链接和动态链接

链接可以分为静态链接和动态链接。

静态链接:在编译时将库文件的内容链接到可执行文件中。静态链接的可执行文件比较大,但可以独立运行。

动态链接:在运行时动态加载库文件。动态链接的可执行文件比较小,但需要依赖库文件。

5.3 链接的高级话题

5.3.1 符号解析机制

符号类型

  • 全局符号:非static的函数和变量
  • 本地符号:static的函数和变量(仅在编译单元内可见)
  • 外部符号:来自其他编译单元的符号

符号解析规则

  1. 相同编译单元内:本地符号优先级高于全局符号
  2. 不同编译单元间:强符号(已定义)优先级高于弱符号(声明)

弱符号示例

// file1.c(强符号)
int foo() {
    return 42;
}

// file2.c(弱符号)
__attribute__((weak)) int foo() {
    return 0;
}

// 链接时file1.c的foo会覆盖file2.c的foo

5.3.2 共享库的高级特性

位置无关代码(PIC)

  • 共享库的代码可以加载到任意内存地址
  • 使用相对寻址,避免硬编码地址

动态链接器的工作过程

  1. 加载共享库到内存
  2. 重定位符号引用
  3. 初始化共享库的全局变量
  4. 调用初始化函数(如构造函数)

共享库优化技术

  • 延迟绑定:符号解析延迟到第一次使用(提高启动速度)
  • 预绑定:编译时解析符号(提高运行速度)

共享库调试示例

# 查看可执行文件的共享库依赖
ldd executable

# 查看共享库的符号表
nm libmylib.so

# 查看共享库的加载地址
cat /proc/[pid]/maps

5.3.3 常见链接错误分析

未定义符号错误

# 错误信息
undefined reference to `foo'
# 可能原因
- foo函数未定义
- 未链接包含foo的目标文件
- 符号名拼写错误(区分大小写)

# 解决方法
gcc main.o foo.o -o executable

符号重复定义

# 错误信息
multiple definition of `foo'
# 可能原因
- foo在多个编译单元中定义
- 头文件中定义了全局变量(应声明为extern)

# 解决方法
- 使用static关键字限制作用域
- 将定义移到.c文件中

5.4 我的实践经验

在阅读这部分内容之前,我对链接过程了解甚少。我经常会遇到一些链接错误,但不知道如何解决。

现在,我在写代码时会更加注意链接过程。我会避免定义重复的符号,确保所有引用的符号都被正确定义。我也会尽量使用动态链接,减少可执行文件的大小。

六、异常控制流

异常控制流是计算机系统中处理异常情况的机制。了解异常控制流可以帮助我们更好地理解程序的执行过程和调试程序。

6.1 异常控制流概述

异常控制流可以分为以下几种类型:

  • 中断:由外部设备触发的异常,如键盘输入、磁盘读写完成等。
  • 陷阱:由程序主动触发的异常,如系统调用。
  • 故障:由程序错误引起的异常,如访问无效内存、除零错误等。
  • 终止:由严重错误引起的异常,如硬件故障。

6.2 系统调用

系统调用是程序与操作系统交互的方式。程序通过系统调用来请求操作系统提供服务,如文件读写、进程创建等。

6.3 进程和线程

进程是计算机系统中独立运行的程序。线程是进程内部的执行单元,一个进程可以有多个线程。

6.3.1 进程管理

操作系统负责管理进程的创建、调度和终止。

6.3.2 线程管理

线程管理包括线程的创建、调度和同步。线程同步可以通过互斥锁、条件变量等机制实现。

6.4 进程和线程的深度分析

6.4.1 进程控制块(PCB)

进程在操作系统中由PCB表示,包含:

  • 进程ID(PID):唯一标识符
  • 状态:就绪、运行、阻塞、终止
  • 寄存器上下文:保存CPU寄存器状态(用于上下文切换)
  • 内存映射:虚拟地址空间布局
  • 打开文件描述符:文件、socket等
  • 信号处理信息:信号处理器、信号屏蔽字

进程创建过程(fork)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("Child process: PID=%d, PPID=%d\n", getpid(), getppid());
        execlp("/bin/ls", "ls", "-l", NULL);
        perror("execlp failed");
        return 1;
    } else {
        // 父进程
        printf("Parent process: PID=%d, Child PID=%d\n", getpid(), pid);
        int status;
        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) {
            printf("Child exited with code=%d\n", WEXITSTATUS(status));
        }
    }

    return 0;
}

6.4.4 现代并发模型

无锁数据结构

// 简单的无锁链表
struct Node {
    int data;
    struct Node* next;
};

struct Node* head = NULL;

void push(int data) {
    struct Node* new_node = malloc(sizeof(struct Node));
    new_node->data = data;

    do {
        new_node->next = head;
    } while (!__sync_bool_compare_and_swap(&head, new_node->next, new_node));
}

int pop() {
    struct Node* old_head;

    do {
        old_head = head;
        if (old_head == NULL) return -1;
    } while (!__sync_bool_compare_and_swap(&head, old_head, old_head->next));

    int data = old_head->data;
    free(old_head);
    return data;
}

CSP模型(使用Go语言)

// 使用CSP模型的并发编程
package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("Worker %d processing job %d\n", id, j)
		results <- j * 2
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	// 创建3个工作线程
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// 发送5个任务
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)

	// 收集结果
	for a := 1; a <= 5; a++ {
		<-results
	}
}

6.4.2 线程同步机制

互斥锁的实现原理

// 简单的自旋锁实现(忙等待)
typedef struct {
    int locked;
} spinlock_t;

void spin_lock(spinlock_t *lock) {
    while (__sync_lock_test_and_set(&lock->locked, 1)) {
        // 锁已被占用,忙等待
        __builtin_ia32_pause();  // 减少CPU消耗
    }
}

void spin_unlock(spinlock_t *lock) {
    __sync_lock_release(&lock->locked);
}

条件变量的使用

pthread_mutex_t mutex;
pthread_cond_t cond;
int ready = 0;

void *thread_func(void *arg) {
    pthread_mutex_lock(&mutex);
    while (!ready) {
        pthread_cond_wait(&cond, &mutex);  // 释放锁并等待
    }
    printf("Thread: Work started\n");
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    pthread_t tid;
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond, NULL);

    pthread_create(&tid, NULL, thread_func, NULL);

    // 做一些准备工作...
    sleep(1);

    pthread_mutex_lock(&mutex);
    ready = 1;
    pthread_cond_signal(&cond);  // 唤醒一个等待的线程
    pthread_mutex_unlock(&mutex);

    pthread_join(tid, NULL);

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);

    return 0;
}

6.4.3 并发编程的常见错误

1. 竞争条件

// 错误代码(未同步)
int counter = 0;

void *increment(void *arg) {
    for (int i = 0; i < 100000; i++) {
        counter++;  // 不是原子操作
    }
    return NULL;
}

// 输出结果会小于200000

// 正确代码(使用互斥锁)
pthread_mutex_t mutex;
int counter = 0;

void *increment_safe(void *arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&mutex);
        counter++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

// 输出结果等于200000

2. 死锁

// 死锁代码
pthread_mutex_t mutex1;
pthread_mutex_t mutex2;

void *thread1(void *arg) {
    pthread_mutex_lock(&mutex1);
    sleep(1);
    pthread_mutex_lock(&mutex2);  // 等待mutex2,已持mutex1
    // 做一些操作
    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);
    return NULL;
}

void *thread2(void *arg) {
    pthread_mutex_lock(&mutex2);
    sleep(1);
    pthread_mutex_lock(&mutex1);  // 等待mutex1,已持mutex2
    // 做一些操作
    pthread_mutex_unlock(&mutex1);
    pthread_mutex_unlock(&mutex2);
    return NULL;
}

// 解决方法:始终按相同的顺序获取锁
void *thread_safe(void *arg) {
    pthread_mutex_lock(&mutex1);  // 始终先锁mutex1
    pthread_mutex_lock(&mutex2);
    // 做一些操作
    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);
    return NULL;
}

6.5 并发编程

并发编程是指多个线程同时执行。并发编程可以提高程序的执行效率,但也会带来一些问题,如死锁和竞争条件等。

6.6 我的实践经验

在阅读这部分内容之前,我对异常控制流了解甚少。我经常会遇到一些程序崩溃的问题,但不知道如何调试。

现在,我在写代码时会更加注意异常控制流。我会使用异常处理机制来处理程序错误,避免程序崩溃。我也会尽量避免使用并发编程,如果必须使用,我会使用互斥锁和条件变量等机制来避免死锁和竞争条件等问题。

七、总结

通过阅读《深入理解计算机系统》,我对计算机系统有了全面而深入的理解。这本书让我明白了计算机系统是一个复杂而协调的整体,各个部分之间相互配合,完成各种任务。

7.1 理解计算机系统的重要性

理解计算机系统的底层原理对于写出高效、可靠的代码至关重要。了解计算机系统的局限性可以帮助我们设计更可靠的系统。

7.2 我的行动指南

读完这本书后,我制定了以下行动指南:

  1. 优化内存访问模式:尽量使用连续的内存访问模式,避免跳跃式的内存访问。
  2. 避免使用浮点数进行精确计算:尽量使用整数或 decimal 类型进行精确计算。
  3. 使用高效的算法和数据结构:尽量使用高效的算法和数据结构,提高程序的执行速度。
  4. 避免并发编程:尽量避免使用并发编程,如果必须使用,使用互斥锁和条件变量等机制来避免死锁和竞争条件等问题。
  5. 学习新的计算机系统知识:定期学习新的计算机系统知识,提高自己的技术水平。

7.3 高级程序员的实践建议

1. 系统级性能分析

使用高级性能分析工具

# 使用perf进行深度性能分析
perf record -e cpu-cycles,instructions,cache-references,cache-misses -F 99 ./program
perf report --sort=dso,symbol -g

# 使用bpftrace进行系统级性能分析
bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("Executing: %s\n", str(args->filename)); }'

# 使用intel vtune进行高级性能分析
vtune -collect hotspots -result-dir vtune_result ./program
vtune -report hotspots -result-dir vtune_result

# 使用火焰图分析性能
perf record -F 99 -g -- ./program
perf script > perf.out
./stackcollapse-perf.pl perf.out > out.stack
./flamegraph.pl out.stack > flamegraph.svg

2. 系统级优化方法论

阿姆达尔定律的应用

// 计算并行程序的加速比
double amdahl_speedup(double parallel_fraction, int num_threads) {
    return 1 / (1 - parallel_fraction + parallel_fraction / num_threads);
}

// 计算所需的并行度以达到目标加速比
int required_parallelism(double parallel_fraction, double target_speedup) {
    return (int)ceil(parallel_fraction / (1 - 1 / target_speedup));
}

古斯塔夫森定律的应用

// 计算可扩展加速比
double gustafson_speedup(double parallel_fraction, int num_threads) {
    return 1 - parallel_fraction + parallel_fraction * num_threads;
}

3. 系统架构设计

微服务架构的系统级优化

# Kubernetes部署配置
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-service
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
      - name: my-service
        image: my-service:v1
        resources:
          limits:
            cpu: "1"
            memory: "1Gi"
          requests:
            cpu: "0.5"
            memory: "512Mi"
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10

容错设计的高级技术

// 故障注入的示例
#include <signal.h>

void simulate_failure() {
    raise(SIGSEGV);  // 模拟段错误
}

void failure_handler(int signum) {
    printf("Caught signal %d, handling failure\n", signum);
    // 执行故障恢复逻辑
    // 1. 保存状态
    // 2. 通知监控系统
    // 3. 尝试恢复或终止
}

void setup_failure_handler() {
    struct sigaction sa;
    sa.sa_handler = failure_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGABRT, &sa, NULL);
}

int main() {
    setup_failure_handler();

    // 正常执行逻辑
    for (int i = 0; i < 10; i++) {
        if (i == 5) {
            simulate_failure();
        }
        printf("Iteration %d\n", i);
    }

    return 0;
}

7.4 中级程序员的学习路径

  1. 基础阶段:掌握计算机系统的基本概念(如CS:APP前4章)
  2. 实践阶段
    • 编写简单的系统程序(如shell、内存分配器)
    • 使用性能工具分析和优化代码
    • 阅读开源库的源代码(如glibc、Linux内核)
  3. 高级阶段
    • 学习操作系统原理(如《Operating Systems: Three Easy Pieces》)
    • 学习计算机组成原理(如《Computer Architecture: A Quantitative Approach》)
    • 学习网络编程(如《Unix网络编程》)

计算机系统是一个不断发展的领域,需要我们不断学习和实践。希望我们都能写出高效、可靠的代码,设计出优秀的计算机系统。

八、常见问题与解答

8.1 为什么需要了解计算机系统的底层原理?

Q:作为初级程序员,我只需要会写代码就行了,为什么要了解底层原理?

A:虽然初级程序员主要在应用层工作,但了解底层原理能帮助你:

  • 写出更高效的代码(比如知道如何优化内存访问)
  • 快速定位和修复 bug(比如程序崩溃时知道从哪里入手)
  • 理解为什么有些代码比其他代码运行得更快
  • 在学习高级概念时更容易理解

Q:为什么理解计算机系统对现代应用开发很重要?

A:现代应用开发(如Web开发、移动开发)虽然使用高级语言和框架,但底层原理仍然是决定性能和可靠性的关键:

  1. Web应用:内存泄漏会导致服务器崩溃,CPU绑定操作会降低响应速度
  2. 移动应用:内存使用直接影响应用的流畅度和电池续航
  3. 大数据应用:内存访问模式和缓存使用决定处理速度
  4. 嵌入式开发:对资源限制敏感,需要深度理解硬件和软件交互

8.2 这本书适合初级程序员阅读吗?

Q:我是一个初级程序员,直接读这本书会不会太难?

A:这本书确实有一定难度,但如果你已经掌握了一门编程语言的基础知识,是可以尝试阅读的。建议:

  • 先从简单的章节开始(如概述、数的表示)
  • 遇到困难的章节可以先跳过,回头再看
  • 配合代码示例和实践项目一起学习

8.3 如何将书中的知识应用到实际编程中?

Q:我读了这本书,但不知道如何在实际项目中应用这些知识?

A:建议:

  1. 从优化简单的代码开始(如数组访问、循环结构)
  2. 尝试编写一些底层相关的代码(如内存管理、位运算)
  3. 使用性能分析工具(如 Profiler)来找到性能瓶颈
  4. 阅读优秀开源库的源代码,学习它们的实现方式

8.4 学习计算机系统需要哪些前置知识?

Q:学习计算机系统之前需要掌握哪些知识?

A:建议在阅读之前掌握:

  • 至少一门编程语言(C语言最佳,Java/Python也可以)
  • 基本的编程概念(变量、函数、循环、条件判断)
  • 简单的数据结构和算法
  • 操作系统的基本概念(文件系统、进程、线程)

Q:如何平衡抽象层次和底层知识?

A:中级程序员需要知道何时使用抽象,何时需要深入底层:

  • 大多数情况下,使用高级语言和框架即可
  • 当遇到性能问题、调试困难或系统设计挑战时,需要深入底层
  • 理解抽象的”成本”和”收益”,在开发效率和系统性能间做权衡

九、现代CPU架构与虚拟化技术

9.1 现代CPU架构

超标量和乱序执行

  • 超标量:多个执行单元同时执行指令
  • 乱序执行:不按程序顺序执行,通过依赖分析提高效率
  • 重命名寄存器:消除寄存器的名相关

分支预测

  • 静态预测:基于分支类型(如向后跳转预测为循环)
  • 动态预测:基于历史记录(如分支目标缓冲区)

SIMD指令(单指令多数据)

// 使用SSE指令优化数组求和
#include <emmintrin.h>

float sum_array_sse(float *arr, int n) {
    __m128 sum = _mm_setzero_ps();  // 初始化sum为0

    for (int i = 0; i < n; i += 4) {
        __m128 vec = _mm_loadu_ps(&arr[i]);  // 加载4个float
        sum = _mm_add_ps(sum, vec);          // 求和
    }

    // 水平求和sum中的4个元素
    float temp[4];
    _mm_storeu_ps(temp, sum);

    return temp[0] + temp[1] + temp[2] + temp[3];
}

9.2 虚拟化技术

硬件辅助虚拟化(如Intel VT-x)

  • CPU支持根模式和非根模式(虚拟机)
  • 内存虚拟化:EPT(扩展页表)
  • I/O虚拟化:VT-d(直接I/O)

容器虚拟化(如Docker)

  • 使用Linux的namespaces和cgroups实现
  • 进程级别的虚拟化(比VM更轻量)

十、学习资源推荐

10.1 高级书籍

  • 《The Art of Computer Programming》(TAOCP):计算机科学的圣经
  • 《Computer Architecture: A Quantitative Approach》:计算机架构的权威教材
  • 《Design Patterns: Elements of Reusable Object-Oriented Software》:设计模式经典
  • 《The Unix Programming Environment》:Unix编程环境经典
  • 《Distributed Systems: Concepts and Design》:分布式系统权威教材
  • 《High Performance MySQL》:MySQL性能优化权威指南

10.2 高级在线资源

  • MIT 6.824:分布式系统(高级课程)
  • MIT 6.858:计算机安全(高级课程)
  • Stanford CS144:计算机网络(高级课程)
  • CMU 15-445:数据库系统(高级课程)
  • Coursera《高性能计算》(高级课程)
  • Udacity《并行编程》(高级课程)

10.3 高级实践项目

  • 实现一个简单的操作系统内核
  • 编写一个高性能的网络服务器(支持并发连接)
  • 实现一个简单的数据库引擎(支持SQL解析和查询优化)
  • 编写一个分布式文件系统(支持容错和数据复制)
  • 实现一个简单的虚拟机(支持类JVM的字节码执行)
  • 优化一个现有的系统(使用性能分析工具找到瓶颈并优化)

10.4 高级开源项目学习

  • Linux内核:学习操作系统实现(重点关注内存管理和进程调度)
  • LevelDB:学习键值存储引擎的实现(Google开源项目)
  • Apache Kafka:学习分布式消息系统的实现(Apache顶级项目)
  • Redis:学习高性能内存数据库实现(重点关注数据结构和持久化)
  • Nginx:学习高性能Web服务器实现(重点关注事件驱动架构)

10.5 高级工具和框架

  • Bazel:Google的构建系统(支持大型项目的构建)
  • Docker:容器化技术(支持快速部署和环境一致性)
  • Kubernetes:容器编排系统(支持大规模容器管理)
  • Prometheus:监控和警报系统(支持系统监控)
  • Grafana:数据可视化系统(支持监控数据可视化)
  • Jaeger:分布式追踪系统(支持微服务调试)