斯坦福-CS107-计算机组成系统笔记-全-

斯坦福 CS107 计算机组成系统笔记(全)

001:课程介绍与概述 🎓

在本节课中,我们将学习CS107课程的基本信息、课程目标、学习资源以及课程的核心价值。我们将通过一些简单的代码示例,初步了解本课程将要探讨的系统级编程概念。

课程概述

欢迎来到春季学期的CS107课程。本课程旨在帮助你深入理解计算机系统的工作原理,从底层硬件到高级编程语言。通过本课程的学习,你将掌握C语言编程、内存管理、调试技巧以及计算机体系结构的基本知识。

课程团队与资源

课程由两位讲师共同负责:Julie Zelenski和Michael Chang。此外,还有16名助教提供支持,他们将负责答疑、批改作业和主持实验课。

主要学习资源

以下是本课程的主要学习资源:

  • 课程网站cs107.stanford.edu。所有课程公告、作业、实验安排和课程政策都将在此发布。
  • Piazza论坛:用于课程讨论和提问。
  • 助教邮箱:用于联系课程团队。
  • 答疑时间:助教将定期安排答疑,帮助你解决学习中的问题。
  • 同学互助:鼓励与班上近400名同学交流学习。

课程政策提醒

本课程强调独立完成作业。请务必阅读课程网站上关于学术诚信和合作政策的详细说明。课程的核心学习价值在于你亲自编写程序、调试代码并解决问题的过程。

课程目标与定位

上一节我们介绍了课程的基本信息,本节中我们来看看本课程的核心目标及其在计算机科学体系中的定位。

CS107是一门系统课程。它旨在帮助你理解计算机如何执行程序,并培养你在接近机器层面的编程和调试能力。

核心学习目标

我们的学习目标分为三个层次:

  1. 精通掌握:熟练编写和调试C代码,深入理解内存和指针的工作原理。
  2. 熟悉了解:接触并理解汇编语言、计算机运算和程序性能分析等概念。
  3. 初步接触:了解计算机硬件(如处理器)的基本工作原理。

课程的广泛价值

即使你未来不专攻系统领域,本课程培养的技能也具有广泛价值:

  • 提升编程自信:通过解决复杂的底层问题,全面提升你的编程和问题解决能力。
  • 理解系统行为:学会探究计算机和程序在底层的行为,能够评估bug的成因和严重性。
  • 解读技术事件:能够更深入地理解新闻报道中的技术问题(如安全漏洞),形成自己的判断。

初探系统概念:代码示例

为了让你对本课程将要面对的问题有一个直观感受,我们来看两个简单的C程序示例。这些例子展示了当程序在接近机器的层面运行时,可能出现的“反直觉”行为。

示例一:整数溢出的平方

第一个程序读取一个整数,计算其平方并输出。

int num = read_int();
int square = num * num;
printf("%d squared is %d\n", num, square);

运行这个程序时,输入较小的数字(如5,7)会得到正确结果。然而,当输入一个较大的数字(如50000)时,输出竟然是一个负数。输入100000时,输出变成了一个正数,但却是错误的值。

发生了什么?
这涉及到整数溢出。计算机使用固定数量的比特位存储整数。当运算结果超出这个范围时,就会发生“环绕”,导致结果异常。我们将在课程中详细研究其原理。

示例二:数组越界的后果

第二个程序试图计算从1到N(N由用户输入)的整数和,但它使用了一个有缺陷的方法:它声明了一个仅能容纳5个整数的数组,却试图填充更多元素。

int sum = 0;
int numbers[5]; // 数组大小固定为5
// ... 尝试填充超过5个数字到数组中 ...
// ... 然后求和 ...

当输入的数字N小于等于8时,程序似乎能正常工作。但当N等于9时,程序崩溃,并输出:Segmentation fault (core dumped)

发生了什么?
程序访问了分配给数组numbers之外的内存区域(数组越界)。C语言本身不会阻止这种行为。最终,操作系统检测到程序访问了不该访问的内存,于是强制终止了它,这就是“段错误”。与高级语言(如Java)会抛出清晰的异常不同,在系统编程中,我们需要学习使用调试器等工具来诊断这类错误。

系统概念的现实意义

你可能会想,这些刻意编写的小程序bug与现实有何关系?实际上,类似的底层问题在真实世界中时有发生。

  • 波音787客机软件bug:2015年发现,如果飞机连续通电超过8个月,一个负责计时的计数器会因溢出而变为负数,导致系统故障。这正与我们第一个示例的原理相同。
  • Linux系统安全漏洞:2015年底发现,在某些Linux系统的登录界面,连续按28次退格键再回车,竟能绕过密码验证。其根源之一就是类似于第二个示例的缓冲区溢出漏洞。攻击者通过精心构造的输入,越界改写了关键数据,从而控制了系统。

学习本课程后,你将有能力阅读并理解关于上述漏洞的详细技术分析报告。你将不仅能理解问题如何发生,还能评估其真实影响,而不被媒体的过度渲染所误导。

课程安排与下一步

本节课我们一起学习了CS107的课程框架、目标和一些引人入胜的入门示例。最后,我们来看看近期的安排。

以下是开学第一周你需要完成的事项列表:

  • 实验课注册:从周三上午10点开始,在课程网站注册固定的实验时间段。名额有限,请尽早注册。
  • Unix帮助课程:本周将举行(替代第一次实验课),帮助你熟悉命令行环境。即使你毫无经验,也请务必参加。
  • 课前阅读:为周五的课程完成指定的阅读材料。
  • 作业0:第一个编程作业即将发布。你可以先浏览,在参加Unix帮助课程后再正式开始。

我们周五的课程再见。

002:指针、内存分配与数组 🧠

在本节课中,我们将要学习C语言中一个极其重要的概念——指针。我们将探讨指针的基本操作、如何在堆上分配内存,以及数组与指针之间的紧密联系。这些知识对于整个学期的学习至关重要。

概述 📋

指针是C语言的核心特性之一,它允许我们直接操作内存地址。理解指针对于实现数据结构(如链表、二叉树)、高效地共享数据以及模拟“按引用传递”等功能至关重要。本节课我们将从基础开始,逐步深入。

指针基础操作 🎯

上一节我们概述了指针的重要性,本节中我们来看看指针的具体语法和基本操作。

为了清晰地理解指针在内存中的行为,我们将结合代码和内存示意图进行讲解。在示意图中,左侧是内存地址,右侧的方框内是存储的内容,变量名标注在方框旁边以示关联。

例如,变量 i 是一个 int 类型,存储值 42,位于地址 9000。需要注意的是,一个 int 在我们的机器上占用 4个字节,因此它实际占据地址 9000、9001、9002 和 9003。而一个指针(如 int*)占用 8个字节

声明与取址运算符 (&)

以下是声明指针和获取变量地址的方法:

int i = 42;
int j = 107;
int* p = &i; // p 存储了 i 的地址(例如 9000)
int* q = &j; // q 存储了 j 的地址(例如 9004)

& 是“取址”运算符。p = &i; 意味着将变量 i 的内存地址存入指针变量 p 中。

解引用运算符 (*)

解引用运算符用于访问指针所指向地址中存储的值。

printf("%d\n", *p); // 输出 42

*p 表示:首先查看 p 中存储的地址(9000),然后前往该地址并读取其中的值(42)。

核心概念:声明时的 *(如 int* p;)表示变量 p 是一个指向整数的指针。而表达式中的 *(如 *p)是解引用运算符,用于获取指针指向的值。两者语境不同。

指针操作练习与分析 🔍

上一节我们介绍了指针的声明和解引用,本节中我们通过一些练习来巩固理解。

以下是四个独立的代码行,请分析每行代码对内存的影响,并判断其合理性。

  1. p = q;
  2. *p = *q;
  3. *p = q;
  4. p = *q;

以下是每行代码的分析:

  1. p = q;

    • 操作:将 q 中存储的地址(9004)复制到 p 中。现在 pq 都指向变量 j。我们称 pq 互为别名
    • 合理性:合理。这是常见的指针赋值操作。
  2. *p = *q;

    • 操作:先解引用 q 得到值 107,然后解引用 p 并将值 107 写入 p 所指向的地址(即 i 的内存)。结果:i 的值变为 107
    • 合理性:合理。这是通过指针修改变量值的标准操作。
  3. *p = q;

    • 操作:将 q 的值(一个地址,如 9004)尝试存入 p 所指向的 int 类型变量(i)中。这会导致编译器发出警告,因为我们在尝试将一个地址(int* 类型)存入一个整数(int 类型)变量。如果地址值恰好能放入 int,内存中 i 的值会变成一个无意义的数字。
    • 合理性:几乎总是错误的。这通常意味着逻辑错误。
  4. p = *q;

    • 操作:先解引用 q 得到整数值 107,然后将这个值 107 作为地址存入指针 p。现在 p 指向一个极可能无效的地址(107)。后续若解引用 p(如 *p),程序很可能会因访问非法内存而崩溃(段错误)。
    • 合理性:错误且危险。

核心结论:在赋值时,务必注意等式左右的类型匹配。指针 = 指针整数 = 整数(通过解引用得到)是合理的。混合类型(整数 = 指针指针 = 整数)几乎总是错误的,编译器会给出警告,应高度重视这些警告。

未初始化指针与空指针 ⚠️

上一节我们分析了指针操作,本节中我们来看看使用指针时需要警惕的一些问题。

未初始化指针

与普通变量一样,未初始化的指针存储着不可预测的垃圾值。

int* p; // 未初始化
printf("%d\n", *p); // 危险!可能崩溃或读取随机内存

解引用一个未初始化的指针是危险的,因为它可能指向任意内存地址,导致程序崩溃或难以调试的内存错误。

空指针 (NULL)

空指针是一个特殊的指针值,表示“不指向任何地方”。

int* p = NULL;

NULL 的值通常是 0,它不是一个有效的内存地址。解引用空指针必定会导致程序崩溃(段错误)。然而,NULL 非常有用,可用于检查指针状态(例如,判断链表是否到达末尾)。

if (p != NULL) {
    // 安全地使用 p
    printf("%d\n", *p);
}

堆内存分配:mallocfree 💾

到目前为止,我们讨论的变量都位于上,其内存会在函数返回时自动回收。但有时我们需要更灵活地控制内存的生命周期,这时就需要使用内存。

在C语言中,我们使用 malloc 函数在堆上申请内存,使用 free 函数释放内存。

使用 malloc 分配内存

malloc 接收一个参数:需要分配的字节数。它返回一个指向新分配内存块的指针(类型为 void*)。

int* p = (int*)malloc(sizeof(int)); // 分配一个 int 所需的内存
*p = 17; // 在分配的内存中存储值

sizeof(int) 运算符可以获取 int 类型在当前机器上占用的字节数(例如4)。这样写的代码具有可移植性。

使用 free 释放内存

当我们不再需要这块内存时,必须使用 free 将其归还给系统。

free(p);

重要注意事项

  1. free(p) 之后,指针 p 本身的值(存储的地址)不会改变,它仍然指向原来的地址(2000)。但这块内存已被系统回收,不应再被访问。继续解引用 p 或向其中写入数据是未定义行为,可能导致程序崩溃或数据损坏。
  2. 对同一块内存只能 free 一次。重复 freefree 一个非 malloc 返回的指针会导致错误。
  3. 如果只分配而不释放(malloc 后没有对应的 free),会导致内存泄漏。程序运行期间,这部分内存无法被再次使用。

核心类比:将 mallocfree 想象为租房和退租。malloc 拿到钥匙(指针)和房屋(内存)。free 是退租,将钥匙还给系统。退租后你还留有钥匙副本(指针值未变),但再进去就是非法闯入(访问已释放内存)。不退租就离开会导致房屋闲置(内存泄漏)。

数组与指针算术 📊

上一节我们介绍了堆内存管理,本节中我们将发现数组与指针之间存在着深刻的联系。

数组即指针

在C语言中,数组名在大多数情况下可以当作指向其第一个元素的指针来使用。

int* arr = (int*)malloc(4 * sizeof(int)); // 分配可容纳4个int的内存
arr[0] = 2;
arr[1] = 4;
arr[2] = 6;
arr[3] = 8;

这里,arr 是一个 int* 指针,但我们可以使用数组下标语法 arr[i] 来访问内存,就像操作一个真正的数组一样。

指针算术

指针可以进行加减运算,但运算单位是所指向类型的大小,而不是单个字节。

int* q = p + 2; // q 指向 p 向后第2个整数(即 arr[2],值为6)

如果 p 指向地址 2000,p + 2 并不是指向 2002,而是指向 2000 + 2 * sizeof(int),即 2008。

数组下标与解引用的等价性

数组访问和指针算术是等价的:

arr[2]   等价于  *(arr + 2)
&arr[2]  等价于   arr + 2

这个等价关系揭示了数组和指针的本质联系。arr[i] 只是 *(arr + i) 的语法糖。

栈数组

数组也可以在栈上声明:

int stack_arr[4] = {10, 20, 30, 40};

此时,stack_arr 同样可以当作指向数组首元素的指针(int*)使用。栈数组和堆数组的主要区别在于生命周期和是否可调整大小。

字符串初探 🔤

由于指针和数组的紧密关系,我们可以重新审视字符串。在C语言中,字符串本质上是字符数组,而 char* 常被用来表示字符串。

char* str = "Hello"; // str 指向一个只读的字符数组
char str2[] = "World"; // str2 是一个可修改的字符数组

字符串以空字符 \0 结尾,这是判断字符串结束的标志。我们将在实验课中深入探讨字符串的操作。

总结 🎓

本节课中我们一起学习了:

  1. 指针基础:指针是存储地址的变量。使用 & 获取地址,使用 * 解引用。
  2. 类型安全:确保指针操作中的类型匹配,避免危险的类型混淆。
  3. 堆内存管理:使用 malloc 在堆上动态分配内存,使用 free 及时释放,避免内存泄漏和非法访问。
  4. 数组与指针的同一性:数组名可视为指针,数组下标访问 a[i] 等价于指针算术解引用 *(a+i)
  5. 指针算术:对指针加减整数时,步长是其指向类型的大小。

指针是C语言的强大特性,也是后续学习数据结构和系统编程的基石。请务必理解并熟练运用这些概念。我们将在接下来的课程和实验中反复练习。

003:指针与内存管理实战 🧠

在本节课中,我们将要学习指针在C语言中的实际应用,特别是如何通过指针传递变量引用、如何正确地在栈和堆上分配内存,以及如何使用调试工具(如GDB和Valgrind)来诊断常见的内存错误。我们将通过一系列代码示例,深入探讨这些概念。


概述

上一讲我们讨论了数组,特别是C字符串。本节我们将继续深入,探讨何时使用栈内存与堆内存,如何通过指针实现“按引用传递”,并通过实际的代码示例来演示指针操作、常见的内存错误及其调试方法。


栈与堆内存分配对比 📊

在C语言中,我们有两种主要的内存分配方式:栈(Stack)和堆(Heap)。以下是两者的核心对比:

  • 栈分配

    • 声明方式int arr[5];
    • 优点
      1. 自动管理:函数返回时,内存自动释放,无需手动调用 free,避免了内存泄漏。
      2. 高效:分配和释放速度极快,常数时间开销极小。
    • 缺点
      1. 生命周期固定:内存仅在声明它的函数作用域内有效。
      2. 大小固定:声明后无法调整数组大小。
  • 堆分配

    • 声明方式int *arr = malloc(5 * sizeof(int));
    • 优点
      1. 控制生命周期:内存可以持续存在,直到显式调用 free 释放。
      2. 可调整大小:可以使用 realloc 函数调整已分配内存块的大小。
    • 缺点
      1. 手动管理:必须手动释放内存,否则会导致内存泄漏。
      2. 效率较低:虽然也是常数时间,但分配和释放的开销比栈大得多。

核心原则:在可能的情况下优先使用栈。仅当需要控制变量的生命周期(例如,函数返回后数据仍需保留)或需要动态调整大小时,才使用堆。


通过指针实现“按引用传递” 🔄

在C++中,可以使用 & 语法实现按引用传递参数。C语言没有此语法,但我们可以通过指针达到相同效果。

以下是一个示例,演示如何修改函数外部的变量值:

// 错误示例:按值传递,无法修改外部变量
void change(int x) {
    x += 10; // 只修改了局部副本
}
int main() {
    int num = 107;
    change(num); // num 仍然是 107
}

// 正确示例:通过指针按引用传递
void change(int *p) { // 参数是指向int的指针
    *p += 10; // 解引用指针,修改其指向的值
}
int main() {
    int num = 107;
    change(&num); // 传递num的地址
    // 现在 num 的值是 117
}

关键点:要修改函数外部的变量,需要传递该变量的地址(&variable),并在函数内部通过解引用指针(*pointer)来访问和修改其值。


字符串处理与内存错误实战 🛠️

我们将通过一个将字符串转换为小写的程序,来探索不同的实现方式及其潜在问题。程序涉及栈字符串、堆字符串和字符串常量。

函数1:原地修改(lowercase1

此函数直接修改输入字符串。

char *lowercase1(char *str) {
    for (int i = 0; i < strlen(str); i++) {
        str[i] = tolower(str[i]);
    }
    return str; // 返回原指针,返回值非必需
}
  • 优点:高效,无额外内存分配。
  • 问题
    1. 修改了原始字符串。
    2. 对字符串常量调用会导致段错误(Segmentation Fault),因为字符串常量存储在只读内存区。

调试技巧:使用GDB定位段错误。

  1. 在GDB中运行程序。
  2. 程序崩溃后,使用 backtrace(或 bt)查看调用栈。
  3. 使用 up 命令切换到调用函数(如 main),查看是哪一行代码引发了问题。

函数2:未初始化的指针(lowercase2

此函数尝试返回一个新字符串,但指针未初始化。

char *lowercase2(const char *str) {
    char *copy; // 错误:指针未初始化,指向垃圾地址
    for (int i = 0; i <= strlen(str); i++) { // 注意:需要复制终止符 ‘\0‘
        copy[i] = tolower(str[i]); // 段错误:向非法地址写入
    }
    return copy;
}
  • 错误copy 是一个未初始化的指针,解引用它会导致访问随机内存地址,通常引发段错误。
  • 编译器警告:现代编译器通常会给出“变量未初始化”的警告。
  • Valgrind诊断:运行 valgrind ./program,它会明确报告“Invalid write of size 1”以及尝试写入的地址(如 0x0,即NULL)。

函数3:返回指向栈内存的指针(lowercase3

此函数在栈上分配数组并返回其地址。

char *lowercase3(const char *str) {
    char copy[strlen(str) + 1]; // 在栈上分配数组
    for (int i = 0; i <= strlen(str); i++) {
        copy[i] = tolower(str[i]);
    }
    return copy; // 危险:返回局部变量的地址!
}
  • 问题:函数返回时,栈内存 copy 被释放(或标记为可重用)。返回的指针变成了“悬空指针”(Dangling Pointer)。
  • 现象:可能偶尔“工作”,但极其脆弱。如果后续函数调用重用了这块栈内存,之前返回的数据就会被覆盖。
  • 编译器警告:通常会提示“返回局部变量的地址”。

函数4:堆分配但大小错误(lowercase4

此函数在堆上分配内存,但分配空间不足。

char *lowercase4(const char *str) {
    // 错误:分配长度不足,应为 strlen(str) + 1
    char *copy = malloc(strlen(str));
    for (int i = 0; i <= strlen(str); i++) { // 循环会写入第 strlen(str) 个字符(‘\0‘)
        copy[i] = tolower(str[i]); // 写入越界!
    }
    return copy;
}
  • 错误malloc(strlen(str)) 只为字符分配了空间,没有为终止符 \0 分配空间。循环中的 copy[i] = ...i == strlen(str) 时,会向分配的内存块之后写入一个字节。
  • 潜在后果
    • 可能覆盖堆管理器的关键数据,导致程序崩溃或诡异行为。
    • 可能不会立即崩溃,成为难以发现的隐患。
  • Valgrind诊断:Valgrind会报告“Invalid write of size 1”,并明确指出写入发生在“0 bytes after a block of size X alloc‘d”,即刚好在分配块之后写入,这是数组越界的典型标志。

修正:始终确保为字符串分配 strlen(str) + 1 的空间。

函数5 & 6:修改传入的指针本身

有时我们不仅想修改字符串内容,还想让调用者的指针变量指向新的内存块。

错误尝试(lowercase5

void lowercase5(char *str) {
    char *copy = malloc(strlen(str) + 1);
    // ... 复制并转换为小写 ...
    str = copy; // 这只改变了局部参数 `str` 的值
    // 内存泄漏!原始的堆字符串未被释放,新的 `copy` 也无人指向。
}

这无法修改 main 函数中的原始指针(如 heap),因为参数传递仍是按值传递(只不过这个“值”是一个地址)。

正确方法(lowercase6
要修改调用者的指针变量,需要传递该指针变量的地址,即一个指向指针的指针(char **)。

void lowercase6(char **str_ptr) { // 参数是 char**
    char *copy = malloc(strlen(*str_ptr) + 1); // 解引用一次得到原始字符串
    // ... 复制并转换为小写 ...
    // 释放旧内存(如果来自堆)以避免泄漏
    // free(*str_ptr);
    *str_ptr = copy; // 解引用 str_ptr 来修改调用者的指针
}
int main() {
    char *heap = malloc(...);
    // ...
    lowercase6(&heap); // 传递指针的地址
    // 现在 heap 指向新的小写字符串副本
}

重要限制:此方法不能用于栈数组(如 char stack[] = “...“;),因为不能对数组名使用 & 来获得一个 char **。它仅适用于堆指针或可修改的指针变量。


总结

本节课我们一起深入探讨了C语言中指针与内存管理的实战应用:

  1. 栈与堆的选择:默认使用栈以求高效和安全;需要长生命周期或可变大小时才使用堆。
  2. 按引用传递:通过传递变量的地址(&)并在函数内解引用指针(*)来实现。
  3. 字符串操作陷阱
    • 修改字符串常量会导致段错误。
    • 未初始化的指针是常见错误源。
    • 切勿返回指向栈内存的指针。
    • 为字符串分配内存时,牢记为终止符 \0 预留空间(+1)。
  4. 调试工具
    • GDB:用于定位段错误和检查程序状态。
    • Valgrind:不可或缺的内存错误检测工具,能发现越界访问、使用未初始化内存、内存泄漏等问题。
  5. 双指针的使用:当需要改变调用者手中的指针本身时,需要使用指向指针的指针。

理解这些概念并熟练使用调试工具,是写出健壮、无内存错误C程序的关键。

004:指针进阶与泛型函数

在本节课中,我们将要学习 sizeof 操作符的更多细节、类型转换的机制,并重点介绍C语言中实现泛型编程的核心概念——void* 指针。我们将了解如何编写和使用可以处理多种数据类型的通用函数。

sizeof 操作符详解

上一节我们介绍了指针和内存的基础知识,本节中我们来看看 sizeof 操作符的一些特殊用法。

sizeof 可以返回一个变量或类型在内存中所占的字节数。其返回值类型为 size_t,打印时使用格式说明符 %zu

int i = 107;
double d = 3.14159;
int* ip = &i;
char* cp = "hello";

printf("sizeof(i) = %zu\n", sizeof(i)); // 输出 4
printf("sizeof(d) = %zu\n", sizeof(d)); // 输出 8
printf("sizeof(ip) = %zu\n", sizeof(ip)); // 输出 8
printf("sizeof(cp) = %zu\n", sizeof(cp)); // 输出 8

对于在栈上声明的数组,sizeof 有一个特殊行为:它可以返回整个数组占用的总字节数。

int arr[3] = {10, 20, 30};
size_t total_bytes = sizeof(arr); // total_bytes = 12
size_t element_size = sizeof(arr[0]); // element_size = 4
int element_count = total_bytes / element_size; // element_count = 3

然而,这个特性仅限于在声明该数组的同一函数内部使用。一旦将数组作为参数传递给另一个函数,它就会退化为指针,此时 sizeof 返回的将是指针本身的大小(例如8字节),而不是数组的总大小。

void size_params(int arr[], int* ptr) {
    // 在函数内部,arr 和 ptr 都是指针
    printf("sizeof(arr) = %zu\n", sizeof(arr)); // 输出 8
    printf("sizeof(ptr) = %zu\n", sizeof(ptr)); // 输出 8
}

因此,除了栈数组这一特例外,我们通常无法通过数组本身获知其长度,必须额外传递一个表示元素数量的参数。

类型转换

接下来,我们探讨类型转换。类型转换告诉编译器将某个值视为另一种类型。

简单的值转换会由编译器执行实际的数值转换。

int i = 107;
float f = 3.14159;
float cast_i = (float)i; // cast_i = 107.0
int cast_f = (int)f; // cast_f = 3 (截断小数部分)

更复杂(也更危险)的是指针类型的转换。这不会改变内存中的原始数据,只是改变了编译器解释这些数据的方式。

int i = 107;
float f = 3.14159;
float* fp = (float*)&i; // 将 int* 强制转换为 float*
int* ip = (int*)&f; // 将 float* 强制转换为 int*

printf("*fp = %f\n", *fp); // 输出一个无意义的值(非107.0)
printf("*ip = %d\n", *ip); // 输出一个无意义的大数字(非3)

这种指针类型转换非常危险,因为它绕过了编译器的类型检查。除非你确切知道自己在做什么,否则应避免使用。我们将在后续课程中解释这些“无意义”数值的来源。

引入泛型函数

现在,让我们进入本节课的核心主题:泛型函数。泛型函数是指能够操作多种数据类型的函数。

设想我们需要编写多个功能相同但类型不同的函数,例如计算数组中某个元素出现次数的函数:

int count_int(int key, int array[], int n);
int count_float(float key, float array[], int n);
int count_char(char key, char array[], int n);

这些函数的代码逻辑几乎完全相同,只是参数类型不同。复制粘贴代码是糟糕的风格,并且难以维护。我们希望有一个统一的函数来处理所有类型。

void*:指向“任意类型”的指针

在C语言中,实现泛型的关键是 void* 指针。void* 是一种通用指针类型,可以指向任何类型的数据。

  • 赋值:可以将任何类型的指针(如 int*, char*)直接赋值给 void* 变量,无需强制转换。
  • 限制:不能对 void* 指针进行解引用指针算术运算,因为编译器不知道它指向的数据类型和大小。

构建泛型函数:gcount

为了编写一个通用的计数函数 gcount,我们需要解决几个问题:

  1. 传递数组:使用 void* arr 参数来接收指向任何类型数组的指针。
  2. 传递元素大小:增加一个 size_t elem_size 参数,告诉函数每个数组元素占多少字节。这样函数才能在内存中正确“步进”到下一个元素。
  3. 传递键值:泛型函数不能直接操作数据值,只能操作指针。因此,我们需要传递一个指向键值的指针 const void* key,而不是键值本身。

以下是 gcount 的原型:

int gcount(const void* key, const void* arr, int n, size_t elem_size);

作为客户端,我们这样调用它来统计整数数组:

int scores[] = {85, 92, 76, 92, 95, 92};
int score_key = 92;
int n = sizeof(scores) / sizeof(scores[0]); // 计算元素个数

// 注意:传递的是 &score_key(指针),而不是 score_key(值)
int count = gcount(&score_key, scores, n, sizeof(scores[0]));

调用时,我们必须非常小心地确保传递的 key 指针类型正确。例如,如果数组元素是 char*(字符串),那么 key 应该是 char**(指向字符串指针的指针)。

char* words[] = {"ABC", "DEF", "ABC"};
char* word_key = "ABC";
// 正确:words[0] 的类型是 char*,所以指向它的指针是 char**
count = gcount(&word_key, words, 3, sizeof(words[0]));
// 这比较的是指针地址是否相同,而非字符串内容是否相同

标准库中的泛型函数:qsort

C标准库提供了强大的泛型函数,如快速排序 qsort 和二分查找 bsearch

qsort 的原型如下:

void qsort(void* base, size_t nmemb, size_t size,
           int (*compar)(const void*, const void*));

参数说明:

  • base: 待排序数组的起始地址 (void*)。
  • nmemb: 数组元素个数。
  • size: 每个元素的大小。
  • compar: 函数指针,指向一个比较函数。

qsort 函数本身不知道如何比较我们自定义类型的元素。因此,我们需要自己提供一个比较函数,qsort 会在排序过程中调用它。这被称为回调函数

比较函数的原型必须严格定义为:

int compare_func(const void* a, const void* b);

它接收两个指向待比较元素的 const void* 指针,并返回:

  • 负数:如果 a 应排在 b 之前。
  • 零:如果 a 等于 b
  • 正数:如果 a 应排在 b 之后。

例如,为整数数组编写比较函数:

int compare_ints(const void* a, const void* b) {
    // 1. 将 void* 转换为我们已知的实际类型 int*
    const int* ia = (const int*)a;
    const int* ib = (const int*)b;
    // 2. 解引用并比较值
    return (*ia - *ib); // 升序排序
}

// 使用 qsort
int arr[] = {5, 2, 8, 1, 9};
int n = sizeof(arr) / sizeof(arr[0]);
qsort(arr, n, sizeof(arr[0]), compare_ints);

通过改变比较函数内部的逻辑,我们可以轻松实现降序排序、按结构体的某个字段排序等复杂需求,这正是泛型结合回调函数的强大之处。

总结

本节课中我们一起学习了:

  1. sizeof 操作符对于栈数组的特殊行为,以及其在函数参数中的局限性。
  2. 类型转换,特别是危险的指针类型转换,它改变了编译器对数据的解释方式。
  3. 泛型函数的概念及其必要性。
  4. 使用 void* 指针作为实现泛型的基础,它可以指向任何数据,但不能直接解引用或进行算术运算。
  5. 如何作为客户端调用泛型函数(如 gcount),关键点是传递元素大小和指向键值的指针。
  6. 标准库泛型函数 qsort 的用法,以及如何编写回调函数来定义自定义的比较逻辑。

理解 void* 和回调函数是掌握C语言高级编程和系统编程的关键。在接下来的实验和课程中,我们将获得更多实践机会。

005:void* 的深入探讨与实现

在本节课中,我们将继续探讨 void* 指针。上一节我们介绍了 void* 的概念以及如何从客户端(调用者)的角度使用它。本节中,我们将重点关注实现层面,学习如何编写操作 void* 的函数,并了解如何绕过 void* 的一些限制。我们还将花时间分析一些在使用 void* 时常见的错误,特别是“间接层级”错误。

实现通用函数:以 find_max 为例

首先,我们来看一个具体的例子:将一个查找数组中最大值的函数 find_max 改造成通用版本。原始函数只能处理整数数组。

int find_max(int *arr, int n) {
    int max = arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }
    return max;
}

我们的目标是让这个函数能处理任意类型的数组(例如字符串数组、双精度浮点数数组等)。为了实现这一点,我们需要回答四个关键问题。

问题一:参数 arr 应该是什么类型?

由于我们不知道数组的具体类型,不能使用 int*char* 等具体类型的指针。唯一的选择是使用 void*,表示一个指向未知类型数据的指针。

答案: arr 应该是一个 void*

问题二:函数应该返回什么?

通用函数无法直接操作或返回元素本身,因为它们不知道元素的具体类型。因此,通用函数通常返回一个指向元素的指针,而不是元素的值。

答案: 函数应该返回一个 void*,指向数组中的最大元素。

根据前两个答案,我们可以更新函数原型和部分代码:

void* find_max(void *arr, int n, size_t elem_size, int (*cmp_fn)(const void*, const void*)) {
    void *max = arr; // max 指向第一个元素
    // ... 循环比较逻辑待更新
    return max;
}

问题三:如何访问数组的第 i 个元素?

在原始代码中,我们使用 arr[i] 来访问元素。但对于 void*,我们不能直接进行指针算术运算(如 arr + i),因为编译器不知道每个元素的大小。

解决方案: 我们需要一个额外的参数 elem_size 来告知函数每个元素占用的字节数。为了进行指针运算,我们可以将 void* 临时转换为 char*,因为 char 的大小是1字节,这样指针加法就能以字节为单位进行。

访问第 i 个元素的公式如下:

void *ith_element = (char*)arr + i * elem_size;

答案: 通过将 arr 转换为 char*,然后加上 i * elem_size 来获取指向第 i 个元素的指针。

问题四:如何比较两个元素?

我们无法使用 >< 直接比较 void* 指向的未知类型数据。解决方案是让客户端提供一个比较函数(回调函数),这个函数知道如何比较特定类型的两个元素。

比较函数的原型应与 qsort 库函数所使用的类似:

int cmp_fn(const void *a, const void *b);

它接收两个指向元素的 const void* 指针,并返回一个整数:正数表示 a > b,负数表示 a < b,0 表示相等。

find_max 中,我们这样使用它:

if (cmp_fn(ith_element, max) > 0) {
    max = ith_element;
}

答案: 通过客户端提供的回调函数来比较元素。

完整的通用 find_max 实现

综合以上答案,我们可以写出完整的通用 find_max 函数:

void* find_max(void *arr, int n, size_t elem_size, int (*cmp_fn)(const void*, const void*)) {
    void *max = arr; // 指向第一个元素
    for (int i = 1; i < n; i++) {
        void *ith_element = (char*)arr + i * elem_size;
        if (cmp_fn(ith_element, max) > 0) {
            max = ith_element;
        }
    }
    return max; // 返回指向最大元素的指针
}

客户端调用示例

以下是客户端如何调用这个通用函数来查找整数数组和字符串数组的最大值。

对于整数数组:

int nums[] = {10, 20, 99, 15};
int count = sizeof(nums) / sizeof(nums[0]);
// 提供比较整数的函数
int cmp_int(const void *a, const void *b) {
    int ia = *(const int*)a;
    int ib = *(const int*)b;
    return ia - ib;
}
// 调用 find_max,并将返回的 void* 转换为 int* 再解引用
int *max_ptr = (int*)find_max(nums, count, sizeof(int), cmp_int);
int max_val = *max_ptr;

对于字符串数组(char* 数组):

char *strs[] = {"apple", "zebra", "banana"};
int count = sizeof(strs) / sizeof(strs[0]);
// 提供比较字符串的函数(按字母顺序)
int cmp_str(const void *a, const void *b) {
    const char **sa = (const char**)a; // 注意:元素是 char*,所以参数是指向 char* 的指针
    const char **sb = (const char**)b;
    return strcmp(*sa, *sb);
}
// 调用 find_max,并将返回的 void* 转换为 char** 再解引用得到 char*
char **max_str_ptr = (char**)find_max(strs, count, sizeof(char*), cmp_str);
char *max_str = *max_str_ptr;

关键点: 通用函数操作的是指向元素的指针。因此,如果数组元素类型是 T,那么:

  • find_max 返回 T*
  • 比较函数接收 const T* 类型的参数。

实现通用交换函数:generic_swap

接下来,我们看看如何实现一个通用的交换函数 generic_swap,它可以交换任意类型的两块内存。

挑战与解决方案

对于具体类型的交换(如 int),我们可以直接解引用指针:

void swap_int(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

但对于 void*,我们面临两个问题:

  1. 如何分配临时存储空间? 我们不知道要交换的数据类型,因此无法声明一个具体类型的 temp 变量。
  2. 如何进行复制? 我们不能使用 *a = *b 这样的解引用赋值。

解决方案: 使用 memcpy 函数和基于字节的操作。

  1. 使用一个 char 数组(大小为 elem_size 字节)作为临时存储空间。char 的大小是1字节,因此 char 数组可以精确地按字节存储任何数据。
  2. 使用 memcpy(dest, src, size) 函数来复制指定字节数的内存。

通用交换函数实现

void generic_swap(void *a, void *b, size_t size) {
    char temp[size]; // 在栈上分配 size 字节的临时空间
    memcpy(temp, a, size);   // 将 a 指向的内容复制到 temp
    memcpy(a, b, size);      // 将 b 指向的内容复制到 a
    memcpy(b, temp, size);   // 将 temp 的内容复制到 b
}

客户端调用与间接层级错误

正确调用 generic_swap 的关键在于传递正确的指针和大小。这里最容易犯的错误是“间接层级”错误。

假设我们要交换两个字符串指针 char* s1char* s2

  • 正确调用(交换指针本身):
    generic_swap(&s1, &s2, sizeof(char*));
    
    我们传递的是指针变量 s1s2 的地址(即 char**),并交换这两个地址值。这改变了 s1s2 指向的字符串。
  • 错误调用1(交换指针指向的字符):
    generic_swap(s1, s2, sizeof(char*));
    
    我们传递的是 s1s2 的值(即它们指向的字符串的首地址)。函数会尝试交换这两个地址开始处的 sizeof(char*)(通常是8)个字节。这不会交换指针,而是会破坏字符串数据的前几个字符,导致未定义行为。
  • 错误调用2(意图交换整个字符串内容,但大小错误):
    generic_swap(&s1, &s2, strlen(s1)+1);
    
    我们传递了指针的地址,但指定的大小是整个字符串的长度。函数会尝试交换 &s1&s2 这两个内存地址处的大量字节,这极有可能导致段错误,因为我们在操作本不该触碰的内存。

核心教训: 当使用 void* 和通用函数时,编译器无法进行类型检查。你必须非常仔细地考虑“间接层级”——你传递的是数据的地址,还是数据本身的地址?你指定的大小是否与你要操作的数据类型匹配?画图分析是避免此类错误的有效方法。

总结

本节课中我们一起学习了:

  1. 实现通用函数:我们通过改造 find_max 函数,深入理解了如何利用 void*、元素大小 (elem_size) 和客户端回调函数来实现操作任意类型数据的算法。
  2. 绕过 void* 的限制:我们学会了通过将 void* 转换为 char* 来进行以字节为单位的指针运算,并使用 memcpy 进行内存块的复制,从而克服了 void* 不能解引用和进行指针算术运算的问题。
  3. 警惕间接层级错误:我们通过 generic_swap 的例子,强调了在使用 void* 时精确控制指针层级和数据大小的重要性。这类错误编译时通常没有警告,但会导致运行时逻辑错误或崩溃,需要开发者格外小心。

掌握这些概念对于后续课程、作业和实验都至关重要。

006:数据表示入门

在本节课中,我们将要学习计算机如何表示数据。我们将从最基本的单位——比特(bit)和字节(byte)开始,探讨如何将它们解释为独立的开关值,以及如何将它们组合起来表示整数。理解这些底层表示是理解计算机如何工作的关键。

比特、字节与位运算

在深入探讨之前,我们首先需要明确一些基本定义。

  • 比特:比特是“二进制数字”的缩写,其值只能是 01。我们可以将其视为一个开关,1 代表“开”或“真”,0 代表“关”或“假”。
  • 字节:一个字节由 8 个比特组成。在 C 语言中,字节是最小的可寻址单元,我们可以通过 char 类型的变量来操作一个字节。

由于 C 语言无法直接操作单个比特,我们需要一组新的运算符来操作字节中的各个比特位,这些运算符被称为位运算符

以下是 C 语言中主要的位运算符及其功能:

  • 按位与 (&):当两个操作数的对应位都为 1 时,结果位才为 1
    • 公式result_bit = a_bit & b_bit
  • 按位或 (|):当两个操作数的对应位至少有一个为 1 时,结果位就为 1
    • 公式result_bit = a_bit | b_bit
  • 按位异或 (^):当两个操作数的对应位不同时,结果位为 1
    • 公式result_bit = a_bit ^ b_bit
  • 按位取反 (~):将操作数的每一位取反,0110
    • 公式result_bit = ~a_bit
  • 左移 (<<):将操作数的所有位向左移动指定的位数,右侧空出的位用 0 填充。
    • 代码a << n (将 a 的位左移 n 位)
  • 右移 (>>):将操作数的所有位向右移动指定的位数。对于无符号数,左侧空出的位用 0 填充。
    • 代码a >> n (将 a 的位右移 n 位)

位运算的应用:位掩码

上一节我们介绍了位运算符,本节中我们来看看如何使用它们来操作一组独立的开关标志,这通常通过位掩码技术实现。

以下是一个使用位掩码管理字体属性(粗体、斜体、下划线)的示例:

// 定义字体属性标志,每个标志对应一个独立的位
enum FontTrait {
    BOLD = 1 << 0,   // 二进制: 001
    ITALIC = 1 << 1, // 二进制: 010
    UNDERLINE = 1 << 2 // 二进制: 100
};

int main() {
    unsigned char myTraits = BOLD; // 初始为粗体: 001

    // 1. 开启下划线位:使用 按位或 (|)
    myTraits |= UNDERLINE; // 结果: 001 | 100 = 101 (粗体+下划线)

    // 2. 关闭粗体位:使用 按位与 (&) 和 按位取反 (~)
    myTraits &= ~BOLD; // 结果: 101 & ~001 = 101 & 110 = 100 (仅下划线)

    // 3. 翻转斜体位:使用 按位异或 (^)
    myTraits ^= ITALIC; // 结果: 100 ^ 010 = 110 (下划线+斜体)

    // 4. 检查特定位是否开启:使用 按位与 (&) 创建掩码
    unsigned char mask = BOLD | UNDERLINE; // 掩码: 001 | 100 = 101
    if (myTraits & mask) { // 检查 myTraits 中是否有 BOLD 或 UNDERLINE 位为1
        printf(“Bold or Underline is on.\n”);
    }

    return 0;
}

通过这种方式,我们可以将多个布尔标志紧凑地存储在一个变量中,并高效地进行设置、清除、翻转和检查操作。

将比特解释为数字:无符号整数

了解了如何操作单个比特后,我们现在来看看如何将一串比特解释为数字。我们首先从无符号整数开始,即所有数字都是非负的。

将二进制比特模式转换为十进制数字的过程,类似于我们熟悉的十进制“位值”概念。在十进制中,数字 567 表示 5*10^2 + 6*10^1 + 7*10^0。在二进制中,我们使用 2 的幂次。

例如,比特模式 01101011 可以解释为:
0*2^7 + 1*2^6 + 1*2^5 + 0*2^4 + 1*2^3 + 0*2^2 + 1*2^1 + 1*2^0 = 107

然而,直接使用二进制或十进制表示比特模式都不够方便。二进制太长,十进制又无法直观看出比特模式。因此,我们引入了十六进制表示法。

十六进制使用 0-9 和 a-f(或 A-F)共 16 个字符来表示 0-15 的值。其优势在于,每 4 个二进制位恰好对应 1 个十六进制位,转换非常方便。

  • 0110 (二进制) = 6 (十进制) = 0x6 (十六进制)
  • 1011 (二进制) = 11 (十进制) = 0xb (十六进制)

因此,01101011 这个字节可以简洁地表示为 0x6b。在 C 语言和系统编程中,十六进制是表示比特相关常量的标准方式。

对于一个字节(8 位)的无符号整数,其可以表示的范围是 0 (比特模式 000000000x00) 到 255 (比特模式 111111110xff)。

二进制运算与溢出

上一节我们介绍了如何表示无符号数,本节中我们来看看对它们进行数学运算时会发生什么,特别是溢出现象。

二进制加法的规则与十进制类似,只是逢二进一。例如,计算 107 (0x6b)58 (0x3a)

  01101011 (107)
+ 00111010 (58)
---------------
  10100101 (165)

计算过程正确无误。

但是,当结果超出数据类型所能表示的范围时,就会发生溢出。例如,用单字节计算 255 + 1

  11111111 (255)
+ 00000001 (1)
---------------
 100000000 (256,理论结果)

结果需要 9 个比特位 (1 00000000),但一个字节只能容纳 8 位。最高位的 1 会被丢弃,最终我们得到 00000000,即 0。计算机会静默地执行这个操作,不会发出警告。

我们可以将无符号数的所有可能值想象成一个圆圈。从 0 开始,每次加 1 就顺时针移动一步,直到 255。再加 1,就会“溢出”并回到 0。这个圆圈代表了模运算,对于单字节,就是模 256 (2^8)。

表示有符号整数:补码

现在,我们面临一个更复杂的问题:如何用有限的比特位表示负数?我们无法在圆圈上无限扩展,必须用一部分比特模式来表示负数。

一个直观的想法是原码表示法:用最高位表示符号(0 为正,1 为负),其余位表示绝对值。例如,-1 表示为 10000001。但这种方法有两个主要缺点:

  1. 存在 +0 (00000000) 和 -0 (10000000) 两种零值。
  2. 加法和减法的硬件逻辑需要特殊处理,不能统一。

因此,现代计算机普遍采用补码表示法。其核心思想是:重新排列圆圈上的数字,让加法和减法运算可以统一用相同的硬件逻辑完成。

在补码中:

  • 圆圈的上半部分(大约 0 到 127)仍然表示正数。
  • 圆圈的下半部分则用于表示负数。具体来说,-1 被放置在 0 的逆时针方向第一步。-2-1 的逆时针下一步,依此类推。
  • 这样,正数 + 负数 的运算就变成了在圆圈上顺时针移动相应的步数,硬件实现与无符号加法完全相同,溢出位同样被丢弃。
  • 例如,5 + (-2) 在圆圈上就是从 5 顺时针移动 2 步(因为加负数等于减正数),到达 3,结果正确。

对于一个 n 位的补码整数,其表示范围为:-2^(n-1)2^(n-1)-1。对于单字节(8位),就是 -128127

将一个数转换为其相反数(取负)的公式是:按位取反,然后加 1

  • 公式-x = ~x + 1
  • 例如,2 (00000010) 取反得 11111101,加 111111110,这正是 -2 的补码表示。
  • 一个需要记住的特殊值是 -1,其补码表示为所有位都是 1 (111111110xff)。

有符号与无符号的注意事项

补码设计的一个美妙之处在于,加、减、乘等运算以及相等比较 (==) 的硬件电路对于有符号数和无符号数是完全相同的。然而,两者之间仍有一些重要区别:

  1. 溢出点不同:无符号数在 255 -> 0 处溢出;有符号数在 127 -> -128 处溢出。
  2. 右移操作 (>>):对于无符号数,右移后左侧空位补 0。对于有符号数,右移是算术右移,左侧空位会复制原来的符号位(即最高位)。这可以保证负数右移后仍然是负数。
  3. 关系比较 (<, >, <=, >=):当比较一个有符号数和一个无符号数时,C 语言标准规定会将有符号数隐式转换为无符号数再进行对比。这可能导致反直觉的结果。
    • 例如:-1 (有符号,补码为 0xffffffff) 与 130 (无符号) 比较。-1 被转换为无符号数,变成了一个非常大的正数 (2^32 - 1),因此 (-1 < 130) 的结果为假 (0)。

在实际编程中,混合使用有符号和无符号类型时需要格外小心,避免这类陷阱。

字符表示:ASCII 码

在结束对整数表示的讨论前,我们简单提一下字符的表示。计算机同样用数字代码来表示字符,最常用的系统是 ASCII 码

ASCII 码用 7 位二进制数(扩展版本用 8 位)定义了 128 个字符,包括:

  • 数字 0-9
  • 大写和小写英文字母 A-Z, a-z
  • 标点符号和控制字符(如换行符 \n、空字符 \0

例如:

  • 字母 ‘A’ 的 ASCII 码是 65 (0x41)。
  • 数字 ‘9’ 的 ASCII 码是 57 (0x39)。
  • 空字符 ‘\0’ 的 ASCII 码是 0 (0x00)。

因此,字符串 “Stanford” 在内存中就是一系列对应字母 ASCII 码的字节,最后以一个值为 0 的字节结尾。


本节课中我们一起学习了数据表示的基础知识。我们从比特和字节的定义出发,学习了如何使用位运算符和位掩码来操作独立的标志位。然后,我们深入探讨了如何将比特序列解释为无符号整数和有符号整数(补码),理解了二进制运算和溢出行为。最后,我们简要了解了字符的 ASCII 表示,并指出了有符号与无符号数在比较和移位时的关键区别。这些概念是理解计算机如何存储和处理信息的基石。在下节课中,我们将把这些概念推广到更大的整数类型,并开始学习另一种重要的数据类型——浮点数的表示方法。

007:浮点数表示

概述

在本节课中,我们将要学习如何表示非整数的实数。我们将从回顾有符号和无符号整数的差异开始,然后深入探讨两种表示实数的方法:定点表示法和浮点表示法。我们将重点介绍浮点数的表示原理,包括其组成部分(符号位、指数位和尾数位)以及如何在实际计算中使用它们。


有符号与无符号整数的回顾

上一节我们介绍了有符号和无符号整数的表示方法,本节中我们来看看在实际代码中混合使用它们时可能遇到的问题。

以下是需要注意的几个关键点:

  1. 赋值操作只是复制位模式
    当在有符号和无符号类型之间进行赋值时,不会进行数值转换,只是简单地复制位模式。这可能导致对相同位模式的解释完全不同。

    unsigned char uch = 250; // 位模式:11111010, 值:250
    signed char sch = uch;   // 复制相同的位模式:11111010, 值:-6
    
  2. 右移位运算符的行为不同
    对于无符号数,右移时左侧填充0。对于有符号数,右移时左侧填充符号位的副本(算术右移),以保持数值的符号。

    unsigned char u = 250; // 11111010
    u >>= 1;               // 结果:01111101 (值 125)
    signed char s = -6;    // 11111010
    s >>= 1;               // 结果:11111101 (值 -3,填充了符号位1)
    
  3. 混合类型比较可能导致意外结果
    当比较有符号和无符号数时,C语言会将有符号数转换为无符号数,这可能导致逻辑错误。

    int si = -1;
    unsigned int ui = 2;
    if (si < ui) { // 条件为假,因为 -1 被当作一个很大的无符号数
        // 不会执行
    }
    

总结:通常,我们只在需要直接操作位时才使用无符号类型。在其他情况下,应谨慎使用,以避免因混合类型而导致的意外行为。


从整数到实数:定点表示法

上一节我们回顾了整数表示,本节中我们来看看如何表示实数。我们的第一个尝试是定点表示法

定点表示法是二进制多项式表示法的直接扩展。我们不仅使用2的正幂次方(如 8, 4, 2, 1),还使用2的负幂次方(如 1/2, 1/4, 1/8)来表示小数部分。

公式:一个二进制数 b3 b2 b1 b0 . b-1 b-2 b-3 的值为:
值 = b3*2^3 + b2*2^2 + b1*2^1 + b0*2^0 + b-1*2^{-1} + b-2*2^{-2} + b-3*2^{-3}

例如,二进制 0101.1100 表示:
0*8 + 1*4 + 0*2 + 1*1 + 1*(1/2) + 1*(1/4) + 0*(1/8) + 0*(1/16) = 5.75

在定点表示法中,我们预先决定用多少位表示整数部分,多少位表示小数部分(例如,用4位表示整数,4位表示小数)。这种表示法进行加减运算非常方便,就像处理整数一样。

然而,定点表示法有显著的缺点:

  • 范围狭窄:整数部分位数固定,限制了可表示数字的大小。
  • 精度分配不灵活:无论数字大小,小数部分的精度(如1/16)是固定的。对于非常大的数字(如3.5万亿),我们可能不关心能否区分3.5万亿和3.5000001万亿,更希望用这些位来表示更大的数字范围。

这些局限性促使我们寻找更高效的表示方法。


浮点表示法:科学记数法的思想

上一节我们看到了定点表示法的局限,本节中我们来看看一个更强大的解决方案:浮点表示法。其灵感来源于十进制的科学记数法。

在科学记数法中,一个数字被表示为 有效数字 × 10^指数。例如:

  • 3500 写作 3.5 × 10^3
  • 0.0035 写作 3.5 × 10^

这种方法将数字分解为两部分:

  1. 有效数字 (Significand/Mantissa):表示数字的精度部分。
  2. 指数 (Exponent):表示数字的规模或数量级。

关键优势在于,我们可以用相同的空间(例如“3.5”和指数值)来表示数量级差异巨大的数字,而无需写出所有的零。二进制浮点数采用了完全相同的思路。


深入浮点数:IEEE 754 格式与“迷你浮点数”

为了理解浮点数的细节,我们引入一个简化的8位模型,称为“迷你浮点数”。它虽然不是真实的C语言类型,但能清晰地展示原理。

一个迷你浮点数包含三部分:

  1. 1位符号位 (s):0 表示正数,1 表示负数。
  2. 4位指数位 (exp):表示2的幂次,但采用偏置编码
  3. 3位尾数位 (frac):表示有效数字的小数部分。

核心公式:一个浮点数表示的值为:
值 = (-1)^s × (1.frac_2) × 2^(exp - bias)

其中:

  • s 是符号位。
  • 1.frac_2 是一个二进制小数。我们总是假设整数部分是1(称为“隐含的1”),只将小数部分 frac 存储在尾数位中。
  • exp 是将4位指数位当作无符号整数解读的值。
  • bias(偏置值)是 2^(k-1) - 1,对于4位指数(k=4),bias = 2^(3)-1 = 7。偏置编码使得指数 exp 可以表示负指数(当 exp < bias)和正指数(当 exp > bias)。

示例:将位模式转换为数值
假设位模式为 0 0111 000

  • 符号位 s = 0(正数)。
  • 指数位 exp = 0111_2 = 7
  • 尾数位 frac = 000,所以 1.frac = 1.000_2 = 1
  • 计算指数:exp - bias = 7 - 7 = 0
  • 最终值:(-1)^0 × 1 × 2^0 = 1.0

示例:将数值转换为位模式
要表示 -5.0

  1. 转换为二进制:-101.0_2
  2. 规格化(使其成为 1.xxx 形式):-1.01_2 × 2^2。(将二进制点左移两位)
  3. 因此:
    • 符号位 s = 1(负数)。
    • 有效数字小数部分 frac = 011.01 中的 .01)。
    • 真实指数 E = 2
    • 计算偏置后的指数 exp = E + bias = 2 + 7 = 9 = 1001_2
  4. 最终位模式:1 1001 010(尾数补足到3位)。

浮点数的特性与挑战

上一节我们学习了浮点数的编码方式,本节中我们来看看这种表示法带来的重要特性和必须面对的挑战。

  1. 精度与范围之间的权衡
    浮点数用有限的位模式实现了巨大的表示范围(例如32位float可达约±10^38),这是通过牺牲均匀精度换来的。数字在数轴上的分布是不均匀的:

    • 靠近0的地方,数字非常密集,可以表示像 1.175494 × 10^{-38} 这样极小的数。
    • 远离0的地方,数字间隔(称为 εULP)变得很大。例如,在数字 8.0 附近,下一个可表示的浮点数可能是 9.0,这意味着我们无法精确表示 8.5
  2. 无法精确表示许多数字
    由于使用二进制分数,许多简单的十进制小数无法被精确表示,例如 0.20.1。它们在二进制中是无限循环小数,存储时会被舍入。

    float f = 0.2; // 实际存储的值是 0.20000000298023224 的近似值
    if (f == 0.2) { // 这可能为假!
        // 谨慎进行浮点数的相等比较
    }
    

  1. 特殊值
    • :通过指数位全为0且尾数位全为0来表示。有 +0.0-0.0 两种表示。
    • 无穷大:通过指数位全为1且尾数位全为0来表示,符号位决定正负。
    • NaN (非数字):通过指数位全为1且尾数位非零来表示,用于表示无效操作的结果(如 sqrt(-1))。


C语言中的浮点类型

在实际的C语言编程中,我们主要使用两种浮点类型,它们遵循IEEE 754标准:

类型 位数 符号位 指数位 尾数位 偏置值 大致范围 大致精度
float 32 1 8 23 127 ±1.2×10^{-38} 到 ±3.4×10^ 约6-7位十进制数字
double 64 1 11 52 1023 ±2.2×10^{-308} 到 ±1.8×10^ 约15-16位十进制数字

注意:C语言中没有 unsigned floatunsigned double 类型。


总结

本节课中我们一起学习了实数的计算机表示。

  1. 我们首先回顾了有符号和无符号整数混合使用时可能出现的位复制、移位和比较问题。
  2. 接着,我们探讨了定点表示法,它直接扩展了整数的二进制多项式表示,但存在范围和精度固定的局限。
  3. 然后,我们引入了浮点表示法的核心思想,它借鉴了科学记数法,将数字分解为有效数字指数两部分,从而在有限位数内实现了极大的表示范围和灵活的精度。
  4. 通过分析简化的“迷你浮点数”模型,我们详细了解了浮点数的三个组成部分:符号位、采用偏置编码指数位和存储小数部分的尾数位,并掌握了其转换公式 值 = (-1)^s × (1.frac) × 2^(exp - bias)
  5. 最后,我们讨论了浮点数的关键特性:不均匀的精度分布许多十进制小数无法被精确表示以及特殊值(如零、无穷大、NaN)的存在,并介绍了C语言中 floatdouble 类型的基本参数。

理解浮点数的表示方式和局限性对于进行科学计算、图形处理或任何涉及非整数运算的编程都至关重要,它能帮助我们预见并避免常见的数值精度错误。

008:浮点数与汇编语言入门

在本节课中,我们将要学习两个主要部分。首先,我们将完成对浮点数表示和运算的讨论,重点关注程序员应如何有效使用浮点数并避免常见陷阱。其次,我们将开启一个全新的主题——汇编语言,了解C代码如何被翻译成机器能够执行的指令。

浮点数总结与实用指南

上一节我们详细探讨了浮点数的表示细节。本节中,我们来看看如何从更宏观的角度理解和使用浮点数。

浮点数系统的核心思想是将一个数字拆分为两部分:有效数字(Significand)和指数(Exponent)。这类似于科学计数法,但底数是2。我们可以将指数部分理解为衡量数字的“单位”。例如,数字1.5本身没有意义,除非我们知道它的单位是纳米还是光年。浮点表示法的设计目标是在不同的“单位”(即数量级)下,都能保持相对误差在一定范围内,而不是绝对误差。

特殊值的表示:零与非规格化数

在浮点数的标准公式 值 = 1.xxx × 2^y 中,我们无法通过任何xxx和y的组合得到零。因此,IEEE标准使用特殊位模式来表示零。

在我们的8位“迷你浮点数”(1个符号位,4个指数位,3个有效位)示例中,当指数位全为0时,我们进入“非规格化数”区域。此时,值的计算公式变为 值 = 0.xxx × 2^(1 - 偏移量)。通过将有效位设置为0,我们就能表示数字0。由于存在符号位,我们同样可以表示 -0。系统会正确处理这种情况,使 -0 与 0 在比较时相等。

非规格化数填补了0和最小规格化正数之间的空白,使得数值表示在0附近更加稠密。

浮点数的精度与相对误差

理解浮点数的关键在于区分绝对误差和相对误差。随着数字数量级(指数)的变化,相邻可表示浮点数之间的绝对间隔(称为epsilon)会剧烈变化。然而,相对间隔(epsilon除以该数量级的数值)大致保持恒定。这意味着,当处理非常大或非常小的数字时,我们能够容忍的绝对误差可能很大,但相对精度是可控的。

浮点数运算的陷阱

由于上述表示特性,浮点数运算并不总是符合纯数学的直觉。例如,结合律和分配律可能不成立。

以下是一个运算顺序影响结果的例子:

float trillion = 1e12;
float thousand = 1000.0;
float result1 = (trillion + thousand) - trillion; // 结果可能为 0
float result2 = trillion + (thousand - trillion); // 结果可能为 1000

在第一个计算中,trillion + thousand 的结果在浮点数精度下可能仍然是 trillion,因为1000相对于1万亿来说变化太小,无法在有限的精度位中体现出来,从而导致后续减法结果错误。

另一个需要注意的问题是,不应直接比较两个浮点数是否完全相等。由于表示误差,理论上相等的两个计算可能产生略有不同的结果。更安全的做法是检查两个数的差值是否小于一个极小的容忍值(epsilon)。

核心建议:作为程序员,我们应当时刻意识到浮点数的精度限制。通过理解误差来源,我们可以重新组织计算顺序、选择合适的数据类型(如有时使用double代替float),或采用更稳定的数值算法来规避问题。

汇编语言入门

上一节我们结束了关于数据表示的讨论。本节中,我们将开启全新的篇章,探索代码本身是如何被表示和执行的。

当我们编写C代码并编译成可执行文件时,编译器会将高级语言转换为机器码,这是CPU能够直接理解和执行的二进制指令。为了便于人类阅读和理解这一转换过程,我们研究汇编语言,它是机器码的文本表示形式,与机器指令一一对应。

为什么学习汇编语言?

学习汇编语言有以下几个重要原因:

  1. 理解编译器行为:了解编译器如何将C代码优化或翻译成底层指令,有助于我们写出性能更好的代码。
  2. 进行底层调试与优化:在调试复杂问题或进行极致性能优化时,查看汇编代码是必不可少的技能。
  3. 理解系统工作原理:它是理解函数调用、内存布局、程序执行流程等计算机系统核心概念的基础。

指令集架构

我们学习的是 x86-64 ISA。ISA是硬件设计者和软件开发者之间的一份“契约”,它定义了:

  • CPU支持哪些指令(如加法、乘法、内存读取)。
  • 指令的格式和编码。
  • 程序可用的资源(如寄存器)。
  • 函数调用约定(如参数和返回值如何传递)。

x86-64架构经过数十年的发展,包含了大量指令,但入门阶段我们只需掌握其中最核心的一部分。

核心组件:寄存器

在C语言模型中,我们操作内存中的变量。但在实际硬件中,CPU无法直接对内存中的数据进行运算。数据必须先从内存加载到寄存器中,运算完成后再存回内存。

x86-64有16个通用寄存器,每个64位宽(8字节),用于存放整数、指针等数据。它们的名字有些历史原因,如%rax, %rbx, %rcx, %rdi, %rsi等。寄存器可以单独访问其低32位(如%eax)、低16位(如%ax)或低8位(如%al),以方便处理不同大小的数据。

第一个汇编指令:MOV

mov指令用于在寄存器和内存之间,或在寄存器之间移动数据。其语法是 mov 源, 目的,表示将源操作数的值复制到目的操作数。

以下是一些mov指令的示例及其含义:

  • movq $-1, %rax
    • 立即数 -1(64位)移动到寄存器 %rax$表示立即数,q表示“四字”(8字节)。
  • movq %rdi, %rax
    • 将寄存器 %rdi 中的值移动到 %rax。按照调用约定,%rdi通常存放函数的第一个参数,%rax存放返回值。
  • movq (%rdi), %rax
    • 从内存中加载数据。(%rdi)表示以%rdi中的值作为内存地址,取出该地址处的8字节数据,放入%rax。这对应C语言中的解引用操作 *ptr
  • movq 16(%rdi), %rax
    • 带偏移量的内存加载。计算地址 %rdi + 16,取出该地址处的8字节数据。这对应C语言中的数组访问 array[2](假设long类型,每个元素8字节)。
  • movq (%rdi, %rsi, 8), %rax
    • 带比例因子的索引寻址。计算地址 %rdi + %rsi * 8,取出数据。这对应 array[index]。比例因子可以是1, 2, 4, 8。
  • movl $0, (%rdi)
    • 将32位(双字)立即数0写入%rdi指向的内存地址。l后缀表示操作32位数据。

关键点:汇编语言中没有高级语言中的“类型”概念,只有数据的大小(通过指令后缀如b-字节, w-字, l-双字, q-四字来区分)。是解释为整数、指针还是浮点数,完全取决于程序员(或编译器)的意图。例如,同样的movq %rdi, %rax指令,在C语言中可能对应 return (long)param;return (long*)param;return *(long**)param;,汇编代码无法区分这些情况。


本节课中我们一起学习了浮点数使用的核心要点和常见陷阱,并初步认识了汇编语言的世界。我们了解了程序如何从C代码编译为机器指令,介绍了x86-64架构的基本组件——寄存器,并详细讲解了最基础的mov指令及其多种寻址模式。理解这些内容是后续深入学习函数调用、控制流和程序优化的基石。在接下来的课程和实验中,我们将继续探索汇编语言的奥秘。

009:汇编语言中的算术运算与控制结构

概述

在本节课中,我们将继续学习汇编语言,重点探讨C语言中的算术运算、逻辑运算以及控制结构(如if语句和循环)是如何被翻译成汇编指令的。我们将通过具体的代码示例,理解高级语言结构在底层机器上的实现方式。


回顾与引入

上一节我们介绍了汇编语言的基础,包括MOV指令和多种内存寻址模式。本节中,我们将看看C语言中的其他常见操作是如何被翻译成汇编指令的。

类型转换与汇编

首先,我们回顾一下类型转换在汇编层面的表现。类型信息在编译到汇编时基本丢失,汇编指令只关心对内存字节的操作。

示例:指针类型转换

long *ptr;
*(ptr + 3) = 0; // 对 long* 进行指针运算和解引用

对应的汇编指令是MOVQ $0, 24(%rdi)。这里243 * sizeof(long)

如果将ptr转换为char*

char *cptr = (char*)ptr;
*(cptr + 3) = 0;

对应的汇编指令变为MOVB $0, 3(%rdi)。指令从MOVQ变为MOVB,偏移量从24变为3。类型转换本身不产生单独的指令,它只影响编译器对代码的解释,从而生成不同的内存访问指令(按1字节而非8字节访问)。

LEA 指令:加载有效地址

LEA指令用于计算地址,而不进行解引用。它的语法与MOV类似,但含义不同。

示例:返回地址

long* func(long *ptr) {
    return ptr + 1; // 返回 ptr[1] 的地址,而非值
}

对应的汇编指令是LEAQ 8(%rdi), %rax。这条指令将%rdi中的地址值加上8(一个long的大小),结果存入%rax并不访问该地址处的内存

在C语言中,&ptr[1]等价于ptr + 1,都可能被编译为LEA指令。


算术与逻辑运算

以下是C语言中一些基本运算对应的汇编指令介绍。

加法指令 ADD

ADD指令执行加法操作,其格式为ADD src, dst,效果是dst = dst + src

示例函数

int arithmetic(int a, int *b) {
    int local = a + *b;
    return local;
}

对应的汇编核心部分:

movl    %edi, %eax      ; 将参数a存入%eax
addl    (%rdx), %eax    ; 将*b的值加到%eax上
  • ADDL中的L后缀表示操作4字节数据。
  • 许多算术指令都采用这种目标操作数 += 源操作数的模式。

减法与乘法

SUBIMUL指令分别用于减法和乘法,模式与ADD相同。

示例

int local2 = local - param2 * param1;

可能被编译为:

subl    %esi, %eax      ; %eax = %eax - %esi (param2)
imull   %edi, %eax      ; %eax = %eax * %edi (param1)

一行C代码可能对应多条汇编指令,因为硬件没有复合运算指令,编译器需要将其分解。

位运算

位运算指令如AND, OR, NOT, XOR以及移位指令SAR(算术右移)、SHR(逻辑右移)也遵循类似的模式。

有符号与无符号的差异
对于加、减、乘、位与、位或等运算,有符号数和无符号数使用相同的机器指令。这是二进制补码表示法的优势之一。
区别主要体现在右移操作:

  • SAR (算术右移):用于有符号数,高位填充符号位。
  • SHR (逻辑右移):用于无符号数,高位填充0。
    编译器根据源代码中的类型信息决定使用哪条指令。

巧用 LEA 进行算术运算

LEA指令因其能计算[基址 + 偏移量 + 索引*比例]这种形式的地址,常被编译器巧妙地用于执行普通的整数算术运算。

示例

int result = 5 + param1 + param2 * 4;

可能被编译为:

leal    5(%rdi, %rsi, 4), %eax

这条LEAL指令计算了%rdi + %rsi*4 + 5,并将32位结果存入%eax。这证明了在汇编层面,地址计算和整数算术在本质上是相通的

类型提升与扩展指令

当从小尺寸类型转换到大尺寸类型(如charint)时,需要进行符号扩展或零扩展。

符号扩展 MOVSBL

int promote_signed(char c) {
    return (int)c; // 符号扩展
}

对应指令:MOVSBL %dil, %eax。将1字节的%dil符号扩展为4字节的%eax

零扩展 MOVZBL

unsigned long promote_unsigned(unsigned char uc) {
    return (unsigned long)uc; // 零扩展
}

对应指令可能为MOVZBL %dil, %eax,然后自动零扩展至高32位。

数组访问中的扩展

int array_access(int *arr, int i) {
    return arr[i];
}

在计算地址arr + i * 4前,需要将32位的索引i符号扩展为64位:

movslq  %esi, %rsi      ; 将 int i 符号扩展为 long
movl    (%rdi, %rsi, 4), %eax ; 访问 arr[i]

控制结构

控制结构(如条件分支和循环)在汇编中通过跳转指令标签实现。

无条件跳转 JMP

JMP指令使执行流无条件跳转到指定标签处。

示例:无限循环(goto形式)

void infinite_loop(int *ptr) {
    loop:
        (*ptr)++;
        goto loop;
}

对应汇编:

.L2:
    addl    $1, (%rdi)
    jmp     .L2

条件跳转与 IF 语句

条件跳转依赖于CMP(比较)指令设置的标志位。

IF-THEN 结构

if (param == 107) {
    *ptr += 5;
}

对应汇编通常采用“条件不满足则跳过”的逻辑:

cmpl    $107, %edi      ; 比较 param 和 107
jne     .Lskip          ; 如果不等于(Not Equal),跳转到 .Lskip
addl    $5, (%rsi)      ; if 语句体
.Lskip:
    ...                 ; 后续代码

IF-ELSE 结构

if (param1 < 5) {
    *ptr ^= param2;
} else {
    *ptr = -param2;
}

对应汇编需要两个跳转:

cmpl    $4, %edi        ; 比较 param1 和 4 (因为 param1 < 5 等价于 param1 <= 4)
jg      .Lelse          ; 如果大于4,跳转到else块
xorl    %edx, (%rsi)    ; then 语句体
jmp     .Lend           ; 跳过else块
.Lelse:
    negl    %edx
    movl    %edx, (%rsi) ; else 语句体
.Lend:
    ...

循环结构

WHILEFOR循环在汇编中本质相同,都包含初始化、条件检查和循环体。

WHILE 循环示例

int i = 1, sum = 0;
while (i < n) {
    sum += i;
    i++;
}

其汇编结构通常将条件检查放在循环体底部(优化后):

    movl    $1, %edx           ; i = 1
    movl    $0, %eax           ; sum = 0
    jmp     .Lcondition        ; 先跳转到条件检查
.Lloop:                        ; 循环体开始
    addl    %edx, %eax         ; sum += i
    addl    $1, %edx           ; i++
.Lcondition:                   ; 条件检查
    cmpl    %edi, %edx         ; 比较 i 和 n
    jl      .Lloop             ; 如果 i < n,跳回循环体

FOR 循环
将上述while循环改为for循环,生成的汇编代码完全相同。这印证了for循环只是while循环的语法糖,在汇编层面没有特殊指令。


在GDB中查看与调试汇编

我们可以使用GDB来验证和单步执行汇编指令。

  • 查看汇编代码disassemble function_name
  • 单步执行汇编指令stepisi
  • 查看寄存器值print $rax
  • 布局模式:使用layout split可以同时查看源代码和汇编代码。

在调试时,你可能会发现C源代码的执行顺序与汇编指令的顺序不完全一致,这是编译器优化的结果。通过单步执行汇编指令,可以精确跟踪程序的真实执行流程。


总结

本节课我们一起学习了C语言中多种结构到汇编语言的翻译:

  1. 算术与逻辑运算:如ADD, SUB, IMUL, AND, OR等指令,通常采用目标操作数 += 源操作数的模式。
  2. 类型处理:类型信息在汇编中基本丢失,但类型转换和提升会影响指令的选择(如MOVS vs MOVZ, SAR vs SHR)。
  3. 控制流IFELSEWHILEFOR等结构通过CMP指令结合条件跳转(JE, JNE, JG, JL等)和无条件跳转(JMP)来实现。
  4. 地址计算LEA指令不仅用于计算地址,也常被用于高效的整数算术运算。

理解这些翻译规则,能够帮助我们阅读汇编代码,推断其对应的C语言结构,并在调试时深入到机器指令层面分析程序行为。

010:程序构建过程详解 🛠️

在本节课中,我们将要学习一个C程序是如何从源代码一步步变成可执行文件的。我们将深入探讨构建过程中的四个主要步骤:预处理、编译、汇编和链接。理解这些步骤不仅能帮助我们更好地理解程序是如何工作的,还能让我们在面对各种构建错误时,能够快速定位问题所在并找到正确的解决方案。

预处理阶段

上一节我们介绍了构建过程的概览,本节中我们来看看第一个步骤:预处理。

预处理是构建过程的第一步,由预处理器负责。它的主要工作是处理所有以 # 开头的指令,例如 #include#define。预处理器并不理解C语言的语法,它只是进行简单的文本查找和替换。

以下是预处理器处理的主要指令类型:

  • #include:将指定头文件的内容复制到当前文件中。
  • #define:定义宏,在代码中进行文本替换。
  • #ifndef / #endif:条件编译,常用于防止头文件被重复包含。

我们可以使用 gcc -E 命令来让GCC只运行预处理步骤并输出结果。例如:

gcc -E hello.c -o hello.i

这将生成一个 .i 文件,其中包含了经过预处理后的C代码。

预处理器能捕获的错误类型非常有限。它主要检查 #include 的文件是否存在。例如,如果包含了一个不存在的头文件,预处理器会报错:

fatal error: bogus.h: No such file or directory

然而,预处理器不会检查 #define 宏定义中的语法错误。例如,如果你错误地在宏定义中加了分号,预处理器会忠实地进行替换,而将语法错误留给后续步骤处理。

编译阶段

现在,我们已经有了经过预处理的C代码(.i 文件),接下来进入编译阶段。

编译器是构建过程中最复杂的部分,它的任务是将C语言源代码翻译成汇编语言。这个步骤包含了语法分析、语义分析、类型检查和代码优化等一系列复杂操作。

我们可以使用 gcc -S 命令来让GCC在编译后停止,生成汇编代码文件。例如:

gcc -S hello.i -o hello.s

生成的 .s 文件是文本文件,包含了可读的汇编指令。

编译器负责捕获我们在编程时遇到的大多数错误。以下是编译器会检查的主要错误类型:

  • 语法错误:例如缺少分号、括号不匹配等。
  • 类型错误:例如将整数赋值给指针、函数参数类型不匹配等。
  • 未声明的标识符:使用了未声明或未定义的变量或函数。

一个关键点是:类型检查只发生在编译阶段。一旦代码被翻译成汇编语言,所有的类型信息就都丢失了。汇编指令只操作寄存器和内存地址,不关心它们原本代表的是整数、浮点数还是指针。因此,确保类型正确的责任完全落在编译器身上。

汇编阶段

编译完成后,我们得到了人类可读的汇编代码(.s 文件)。下一步是将这些文本指令转换成机器能直接理解的二进制格式。

汇编器的工作相对直接,它基本上是将每一条汇编指令一对一地翻译成对应的机器码。我们可以使用 gcc -c 命令来执行编译和汇编,生成目标文件。例如:

gcc -c hello.s -o hello.o

生成的 .o 文件是一个二进制文件,称为可重定位目标文件

由于汇编器只是简单地进行翻译,它本身几乎不进行错误检查。只要编译器生成的汇编代码是有效的,汇编器就能顺利工作。我们可以使用 objdump -d 工具来反汇编 .o 文件,查看其汇编内容:

objdump -d hello.o

需要注意的是,在 .o 文件中,调用其他文件中的函数(例如 printf)的地址还没有被确定,只是一个占位符。我们可以使用 nm 命令查看目标文件中的符号定义和引用:

nm hello.o

输出中,U 表示未定义的符号(需要链接时解决),T 表示在文本段中定义的函数。

链接阶段

到目前为止,我们的操作都是针对单个源文件(.c)的。一个程序通常由多个模块组成,链接器的作用就是将多个独立编译的目标文件(.o 文件)以及所需的库文件组合成一个完整的可执行程序。

链接器主要完成两项关键工作:

  1. 符号解析:将每个目标文件中未定义的符号引用(如 printf)与另一个目标文件中该符号的定义关联起来。
  2. 重定位:将每个目标文件的代码和数据节分配到最终的内存地址中,并修改所有对这些地址的引用。

链接器错误通常表现为“未定义的引用”。例如:

undefined reference to `function_name`

这通常意味着链接器找不到某个函数或变量的定义。

然而,链接器有一个重要的局限性:它不进行类型检查。因为它处理的是已经失去类型信息的机器码。这可能导致一些在编译阶段未被捕获的错误。例如:

  • 如果函数原型声明错误(参数个数或类型不匹配),但调用时看起来正确,编译器可能只会给出警告,而链接器会成功链接,导致运行时行为错误。
  • 如果没有包含头文件(缺少函数原型),编译器会假设函数参数类型,并生成调用代码。链接器只要能找到同名函数就会链接成功,这几乎必然导致运行时崩溃。

关于库的链接:C标准库(如 stdio.h, stdlib.h 中的函数)默认会被自动链接。但有一些库需要手动指定,最典型的例子是数学库 libm。要使用 math.h 中的函数(如 pow, sin),需要在链接时加上 -lm 标志:

gcc main.o -o main -lm

请注意,#include <math.h> 只为编译器提供原型声明,而 -lm 是告诉链接器去连接包含函数实现的数学库文件,两者缺一不可。

静态函数与作用域

static 关键字用于函数时,会改变其链接属性。一个被声明为 static 的函数只在定义它的源文件内部可见,链接器不会将其暴露给其他文件。

这使得我们可以在不同的 .c 文件中定义同名的 static 函数而不会发生冲突。如果没有 static 修饰,链接器会发现两个同名的全局函数定义,从而产生“多重定义”错误。

总结

本节课中我们一起学习了C程序构建的完整流程。我们深入探讨了四个核心步骤:

  1. 预处理:处理 # 指令,进行文本替换和文件包含。
  2. 编译:将C代码转换为汇编代码,并进行严格的语法和类型检查。
  3. 汇编:将汇编代码转换为机器码,生成可重定位的目标文件。
  4. 链接:合并多个目标文件和库,解析符号引用,生成最终的可执行文件。

理解这些步骤的输入、输出和职责,能帮助我们:

  • 精准定位错误:根据错误信息判断问题是出在语法(编译期)、缺少头文件(编译期)、未定义符号(链接期)还是缺少库(链接期)。
  • 理解工具行为:明白为什么 #include 无法解决“未定义的引用”错误,以及为什么需要同时使用 #include <math.h>-lm
  • 进行高级调试:可以在不同阶段中断构建过程,检查中间输出(如 .i, .s, .o 文件),以诊断复杂问题。

掌握程序构建的底层过程,是成为一个精通系统和底层细节的程序员的重要一步。

011:程序优化 🚀

在本节课中,我们将学习如何量化程序性能,并探讨编译器能为我们自动完成哪些优化,以及哪些优化需要我们手动进行。我们还将学习如何使用性能分析工具来定位代码中的“热点”,从而进行有针对性的优化。

测量性能 📏

上一节我们介绍了课程的整体安排,本节中我们来看看如何量化程序的性能。到目前为止,我们主要通过大O表示法来讨论算法效率。大O表示法关注的是输入规模增长时,算法运行时间的渐进行为。它是一个与机器和语言无关的度量标准,非常适合比较不同算法。

然而,大O表示法也有其局限性。当我们深入到系统层面,关心具体的汇编指令执行效率时,大O表示法无法帮助我们比较两段功能相同但实现不同的汇编代码。此外,大O表示法忽略了常数因子,但在实际优化中,常数因子可能带来巨大的性能差异。

那么,有哪些替代的测量方法呢?

以下是几种常见的性能度量方法:

  • 挂钟时间:即程序从开始到结束实际经过的时间。这对于用户体验(如网页加载时间)非常重要。但其缺点是容易受到系统负载等其他进程的影响,导致测量结果波动较大。
  • 指令计数:统计程序执行过程中运行的汇编指令总数。这种方法的问题在于,并非所有指令的执行时间都相同。例如,乘法和除法指令就比加法指令昂贵得多。
  • 时钟周期:这是我们将要重点使用的度量标准。处理器有一个内部时钟,其频率(如3GHz)表示每秒可以执行30亿个时钟周期。我们可以将周期视为处理器在一个步骤中可以完成的最小工作量单位。通过测量程序运行所消耗的周期数,我们可以得到一个相对稳定且能反映真实计算成本的性能指标。

编译器优化 🛠️

上一节我们讨论了如何测量性能,本节中我们来看看编译器能为我们自动完成哪些优化。在整个课程中,我们一直使用 -Og 标志进行编译,它允许编译器在基本不影响调试的前提下进行一些优化。

编译器可以执行多种优化,以下是一些常见的例子:

  • 常量折叠:如果代码中包含大量常量计算,编译器会在编译时直接计算出结果,而不是在运行时计算。例如,对于表达式 107 * 5 + sqrt(2),编译器会直接计算出最终数值。
  • 公共子表达式消除:如果同一个表达式在代码中多次出现且值不变,编译器会计算一次并将其结果复用,避免重复计算。例如,对于 param + 107 这个在多个地方使用的表达式,编译器会先计算一次并存储在寄存器中。
  • 强度削弱:用更廉价的操作替换昂贵的操作。例如,编译器会将乘以7的操作 x * 7 转换为 (x << 3) - x(即先乘以8再减去自身),因为位移和加法比乘法更快。对于除以3的操作 x / 3,编译器可能会转换为乘以一个魔数的位移操作。

值得注意的是,编译器在优化时遵循“只要输出结果不变,就可以改变实现方式”的原则。这意味着它可能会引入新的变量、改变循环结构,甚至完全消除函数调用(如将递归阶乘转换为迭代计算)。只要这些改变不影响程序的可观察输出,并且假设没有人在调试器中逐行检查中间状态,编译器就有权进行这些优化。

然而,编译器的优化能力也有限制。它基于启发式方法,并非总能做出最佳选择,有时过于激进的优化(如 -O3)甚至可能降低性能。因此,我们通常从编写清晰、直接的代码开始,然后依赖编译器进行基础优化。

编译器无法完成的优化 🎯

上一节我们看到编译器能完成许多优化,但有些优化需要程序员的介入。编译器无法优化那些它无法完全推理或保证正确性的代码。

以下是编译器难以优化的几种情况:

  • 无法确定不变性的循环:例如,在一个将字符串转换为小写的循环中,每次迭代都调用 strlen(s)。编译器无法确定 tolower 操作不会意外地在字符串中插入空终止符,从而改变字符串长度,因此它不敢将 strlen 移出循环。这会导致算法从 O(n) 退化为 O(n²)。程序员需要手动将长度计算提到循环外部。
  • 过于复杂或特殊的模式:编译器擅长优化简洁、通用的代码,但对于手动展开的一长串硬编码 if-else 语句,它可能无法识别其中的算术模式并将其优化。有时,直接使用一个清晰的算术表达式(如 index / 10)会比一长串 if 语句更快,且更易于阅读。
  • 算法选择:这是最重要的限制。编译器无法改变算法本身的时间复杂度。如果你写了一个选择排序(O(n²)),编译器无法将其自动替换为快速排序(O(n log n))。算法的选择必须由程序员完成。

因此,优化的正确策略是:首先用最清晰的方式编写代码,并选择正确的算法。然后,如果性能不达标,再使用工具定位瓶颈,并针对性地进行手动优化。

使用性能分析工具定位热点 🔍

上一节我们了解了哪些优化需要手动进行,本节中我们来看看如何系统地找到代码中的性能瓶颈。我们不应该盲目猜测哪部分代码慢,而应该使用性能分析工具。

我们将使用 Valgrind 工具套件中的 Callgrind。Callgrind 是一个分析器,它通过模拟运行程序来记录每个函数、每行代码甚至每条指令被执行的次数(或消耗的周期估算)。

以下是使用 Callgrind 的基本步骤:

  1. 使用 valgrind --tool=callgrind ./your_program 运行程序。这会生成一个 callgrind.out.<pid> 文件。
  2. 使用 callgrind_annotate --auto=yes callgrind.out.<pid> 命令生成可读的报告。

报告会显示程序中各个函数的指令计数占比。我们可以从中找到“热点”,即那些消耗了绝大部分执行时间的代码区域。例如,在一个排序程序中,分析报告可能清晰地显示 selection_sort 函数占据了大部分指令,而其中的内层循环又是该函数内的热点。

通过分析一个哈希表程序的例子,我们看到当哈希桶数量设置过小时,CMapPut 函数(即插入操作)会消耗惊人的指令数,成为主要瓶颈。在调整哈希桶数量后,新的分析报告显示 scanf 和内存分配(malloc/free)成为了主要开销。这告诉我们,优化内存分配策略(例如,对于已知大小的数据,考虑使用栈而非堆)可能是下一步的优化方向。

使用性能分析工具可以让我们避免无谓的猜测,将优化精力集中在真正影响性能的关键部分。

总结 📝

本节课中我们一起学习了程序优化的核心知识。我们首先探讨了如何超越大O表示法,使用时钟周期等更精确的指标来测量性能。接着,我们了解了编译器能够自动完成的多种优化,如常量折叠和强度削弱,但也明白了其局限性。我们认识到,对于算法选择、编译器无法确定的不变性以及复杂逻辑,需要程序员进行手动优化。最后,我们学会了使用 Callgrind 性能分析工具来科学地定位代码中的性能瓶颈,从而进行有针对性的、高效的优化。记住优化的黄金法则:先写清晰正确的代码,再测量性能,最后针对热点进行优化。

012:内存层次结构与缓存优化 🧠

在本节课中,我们将学习计算机系统中的内存层次结构,理解缓存的工作原理,并探讨如何编写对缓存友好的高效代码。我们将通过具体的代码示例和性能分析工具来揭示内存访问模式对程序性能的巨大影响。

概述

到目前为止,我们一直假设每次内存访问(例如 array[i])所花费的时间是相同的。本节课我们将揭示这个假设是错误的。我们将介绍内存层次结构的概念,解释为什么缓存至关重要,并学习如何利用局部性原理来优化程序性能。

内存层次结构:速度、容量与成本的权衡

上一节我们介绍了优化技术,本节中我们来看看一个不同的优化角度:内存层次结构。

在设计计算机系统时,我们通常有三个主要目标:

  1. 高容量:我们希望内存能存储大量数据(例如数GB的RAM)。
  2. 高速度:我们希望访问内存像访问寄存器一样快(理想情况下只需一个CPU周期)。
  3. 低成本:我们希望内存价格在可承受范围内。

然而,我们无法用单一类型的内存同时满足这三个目标。让我们比较一下我们已经熟悉的两种存储:

  • 寄存器:容量极小(例如16个,每个8字节),但速度极快(访问时间小于1个周期)。
  • RAM(内存):容量巨大(数GB),但访问速度相对较慢(可能需要数十到数百个周期)。

此外,CPU性能的提升速度(遵循摩尔定律)远快于RAM性能的提升速度,导致“内存墙”问题日益严重——CPU和内存之间的速度差距越来越大。

那么,我们该怎么办?解决方案是引入缓存

缓存:一个折中的方案

缓存是一种容量较小但速度较快的内存,位于CPU和主存(RAM)之间。它由硬件自动管理,对软件(汇编语言)是透明的。虽然它既不是最大的也不是最快的,但通过巧妙的策略,它能显著提升整体性能。

为了理解缓存为何有效,让我们看一个现实世界的类比:写论文时查阅资料。

  • 你的书桌(寄存器):手边的资料,访问极快,但空间有限。
  • 你房间的书架(缓存):存放一些可能用到的书,访问较快,容量中等。
  • 图书馆(主存RAM):藏书海量,但每次取书都需要花费较长时间。

如果每次需要一句话都跑去图书馆,效率极低。更聪明的做法是:当你需要一本书时,从图书馆借出并放在书架上。之后如果需要查阅同一本书的其他部分,直接从书架上拿取即可,省去了往返图书馆的时间。

缓存工作的关键在于一个核心假设:程序的内存访问不是完全随机的,而是具有局部性

局部性原理

缓存之所以有效,是因为程序通常表现出两种类型的局部性:

  1. 时间局部性:如果一个数据项被访问,那么它在不久的将来很可能再次被访问。

    • 例子:在循环中反复使用的累加变量 sum
    • 代码示例sum += array[i];
  2. 空间局部性:如果一个数据项被访问,那么其邻近的数据项也可能很快被访问。

    • 例子:顺序遍历一个数组。
    • 代码示例for (int i = 0; i < n; i++) sum += array[i];

如果程序的内存访问是完全随机的(就像在图书馆随机挑一本书),那么缓存将毫无帮助。幸运的是,大多数程序都表现出良好的局部性。

缓存术语与性能指标

在深入探讨之前,我们需要了解一些关键术语:

  • 命中:要访问的数据在缓存中找到。
  • 未命中:要访问的数据不在缓存中,需要从更慢的内存(如RAM)中获取。
  • 未命中率:内存访问中发生未命中的比例。我们希望这个数值尽可能低。
    • 公式未命中率 = 未命中次数 / 总访问次数
  • 命中时间:缓存命中时,获取数据所需的时间。
  • 未命中惩罚:缓存未命中时,从下级存储器获取数据所需的额外时间。

平均内存访问时间可以通过以下公式估算:
平均访问时间 = 命中时间 + 未命中率 × 未命中惩罚

这个公式表明,即使未命中率仅有微小的上升(例如从1%到3%),如果未命中惩罚很大(例如100个周期),平均访问时间也可能翻倍,导致程序性能显著下降。

现代系统中的缓存层次

我们的机器通常具有多级缓存,每一级都在速度、容量和成本之间进行权衡:

  1. L1缓存:容量小(例如32KB),速度极快(1-2个周期)。目标是让命中访问尽可能快。
  2. L2缓存:容量更大(例如4-6MB),速度较慢(约10-20个周期)。目标是尽可能减少需要访问主存的情况。
  3. 主存(RAM):容量最大(数GB),速度最慢(50-200个周期)。

随着CPU与内存性能差距的拉大,现代高端处理器甚至引入了L3和L4缓存来进一步缓冲。

缓存的组织与工作原理(高层视角)

缓存通常被划分为大小固定的(例如64字节)。当程序访问某个内存地址时,硬件不仅会加载该地址的数据,还会将整个数据块加载到缓存中。这利用了空间局部性——相邻的数据很可能很快被用到。

硬件需要快速判断一个地址是否在缓存中。一种简化的策略是使用地址的一部分位来索引缓存行,并用剩余的位作为“标签”进行比较。如果标签匹配,则为命中;否则为未命中。实际的硬件实现更为复杂,但核心思想是提供一种快速、确定性的查找机制。

代码示例分析:缓存对性能的影响

现在,让我们回到课程开始时提出的问题:为什么以不同顺序求和数组,性能差异如此巨大?

我们有以下五种求和一个百万元素数组的方法:

  1. 顺序求和(sum_forward
  2. 逆序求和(sum_backward
  3. 循环展开求和(sum_unrolled
  4. 先奇后偶求和(sum_odds_then_evens
  5. 随机顺序求和(sum_random

使用性能分析工具 callgrind(开启缓存模拟)进行分析后,我们发现:

  • 顺序/逆序求和:性能相近。缓存未命中率约为 1/16(因为64字节缓存块可容纳16个4字节整数)。这是不可避免的冷未命中
  • 循环展开:减少了循环控制指令,但缓存未命中次数不变,因此性能提升有限。
  • 先奇后偶求和:破坏了空间局部性。访问元素0后,接下来访问的是元素2、4、6...,而缓存预取的是元素1、3、5...,导致未命中率翻倍(约 1/8)。这是可避免的未命中
  • 随机顺序求和:完全破坏了空间局部性。几乎每次访问都是未命中(未命中率约99.3%),导致运行时间比其他方法慢6倍。

这个例子清晰地展示了访问模式对缓存效率,进而对程序性能的决定性影响。

编写缓存友好代码

理解了缓存原理后,我们如何编写对缓存友好的代码呢?以下是核心原则:

  1. 关注内存访问模式:尽量使用顺序、连续的内存访问。数组是最缓存友好的数据结构之一。
  2. 利用时间局部性:频繁使用的数据(如循环内的累加器)应尽量保存在寄存器或栈帧中。
  3. 注意数据结构的布局
    • 数组 vs 链表:遍历链表时,节点在内存中可能分散分布,导致缓存未命中率高。示例显示,遍历链表的性能可能比遍历数组慢一个数量级。
    • 如果必须使用链表,尽量在访问一个节点时,集中访问其所有字段(如 datanext),以利用加载整个缓存块带来的空间局部性。
  4. 进行测量,而非猜测:使用 valgrindcallgrind(带 --simulate-cache=yes)等工具来量化程序的缓存未命中率。不要盲目优化,要用数据指导决策。

总结

本节课中我们一起学习了计算机系统中的内存层次结构和缓存机制。我们了解到:

  • 内存访问并非等时,缓存的存在是为了弥补CPU与主存之间的速度差距。
  • 缓存的有效性依赖于程序的局部性(时间局部性和空间局部性)。
  • 缓存未命中会带来巨大的性能惩罚,即使未命中率的小幅上升也可能导致程序运行时间显著增加。
  • 作为程序员,我们可以通过设计缓存友好的数据结构和访问模式来提升程序性能。这包括优先使用连续存储、优化循环、以及利用性能分析工具进行实证优化。

掌握这些知识对于编写高效的系统程序至关重要,尤其是在实现像堆分配器(He allocator)这样性能敏感的项目时。请务必使用工具进行测量,让数据成为你优化的指南针。

013:课程总结与展望 🎓

在本节课中,我们将回顾CS107课程的核心内容,探讨其在实际世界中的应用,并展望未来的学习与发展方向。我们将一起总结课程的价值,并讨论如何将所学知识应用于后续的课程、研究或职业生涯中。


课程回顾与价值总结

上一节我们介绍了课程的基本情况,本节中我们来看看CS107在整个计算机科学领域中的定位和价值。根据课程顾问的反馈,CS107常被推荐为计算机科学学生应优先选择的三门核心课程之一。这反映了本课程在培养学生深入理解计算机系统方面的重要性。

以下是学生们认为从本课程中获得的最有价值的几点收获:

  • 深入理解底层细节:掌握了高级语言背后被忽略的系统级细节,例如内存管理和指针操作。
  • 掌握调试工具:学会了使用GDB等调试器以及Valgrind等内存检查工具,这是后续课程和开发工作中的基本技能。
  • 理解汇编语言:具备了阅读和理解汇编代码的能力,这在系统编程、性能优化和安全领域非常有用。
  • 洞察高级语言原理:理解了Java、Python等高级语言在内存、指针和地址层面的工作原理,即使这些语言试图隐藏这些细节。
  • 培养问题解决能力与信心:通过完成具有挑战性的作业,培养了在压力下解决问题、查阅文档(如man page)和按时交付项目的能力和信心。

C语言与系统知识的现实应用

我们了解了课程的理论价值,现在来看看这些知识在现实世界中的具体应用。C语言远非过时的语言,它在当今的软件开发中依然占据核心地位。

根据2015年基于职位招聘需求的编程语言排名,C语言高居第二位。这表明市场对掌握C语言和系统编程技能的人才仍有大量需求。许多对性能、效率或硬件控制有要求的领域,如操作系统、嵌入式系统和高性能计算,都严重依赖C语言。

一个具体的例子是2015年发现的一个存在于广泛使用的引导程序中的安全漏洞。相关代码如下:

mset(buff + klen - 1, ...);

如果变量 klen 的值为0,执行减1操作会导致其值变为最大的无符号整数(发生回绕)。当将其与指针 buff 相加时,mset 的操作起始位置将远在缓冲区开始之前,可能覆盖关键数据(如返回地址),从而导致严重的安全问题。理解指针、内存布局和整数溢出是发现和修复此类漏洞的关键。

平衡抽象与底层控制

在掌握了强大的底层控制能力后,我们需要学会平衡使用抽象和底层工具。系统领域的独特之处在于,完成任何有意义的项目都需要大量的协作,并依赖于许多已有的抽象层(如操作系统、编译器、硬件架构)。

重要的教训是:为工作选择合适的工具。在大多数情况下,可以信任编译器、类型系统和库函数。例如,通常使用 array[i] 而非复杂的指针运算,使用 x + y 而非担心整数溢出。只有在必要时(如实现特定算法或与硬件交互),才应使用 void *memcpy 等底层操作。理解底层是为了更好地使用和信任抽象,而非总是抛弃它们。

未来学习与发展路径

基于对课程内容的不同感受,学生未来的路径可以大致分为几个方向。以下是针对不同兴趣点的建议:

  • 热爱系统编程的学生:建议继续深入学习系统相关课程,如CS110(计算机系统原理)。这门课可以作为探索操作系统、编译器、网络等更广泛系统领域的入门课程。
  • 偏好高级语言与应用开发的学生:CS110同样是一门核心课程,可以将其视为对系统知识的拓展和应用。之后,可以自由探索人工智能、理论、图形学、人机交互、数据管理等计算机科学的其他丰富领域。107课程培养的调试、问题解决和系统理解能力在这些领域同样宝贵。
  • 非CS专业或兴趣广泛的学生:本课程培养的自学能力、解决问题的毅力和对计算机工作原理的深刻理解,在任何需要技术思维和分析能力的领域都是极具价值的资产。

问答与拓展

关于斯坦福的课程、研究或职业发展,学生们提出了一些具体问题。

关于喜欢的课程:除了CS107,操作系统(CS140)和高级项目课程(CS194)也备受推崇。后者提供了从零开始设计大型软件、定义接口和协作开发的宝贵经验。

关于成为课程助教(Section Leader):通过CS198项目申请成为课程助教是一个极好的途径。它不仅能锻炼教学和调试能力,也是成为高阶课程(如CS107、CS110)研究生助教的重要资历,同时也是一个充满活力的社区。

关于系统研究前沿:当前活跃的系统研究领域包括编译优化(如概率优化)、虚拟化技术、软件定义网络(SDN)、可扩展性与高性能计算等。学术界的研究与工业界的应用(如谷歌的数据中心)之间存在大量的技术转化。

关于实习与工作:CS107的经历是进入技术行业的重要敲门砖。即使职位不直接使用C语言,课程所培养的扎实功底、解决复杂问题的信心和对系统原理的理解,也深受招聘者看重。

关于代码审查的意义:我们编写代码是为了让人阅读,而不仅仅是让机器执行。严格的代码风格审查旨在培养学生编写清晰、可维护、可协作代码的习惯。这种能力在未来的团队项目和职业生涯中至关重要。


本节课中我们一起学习了CS107课程的总结与展望。我们回顾了课程带来的核心技能,探讨了C语言和系统知识在现实中的持续重要性,强调了平衡抽象与底层控制的智慧,并为大家规划了未来的多种可能路径。希望大家能带着从本课程中获得的知识、技能和信心,在计算机科学或其他领域继续探索,取得更大的成就。祝大家在期末作业和考试中一切顺利!

014:期中后内容复习与习题讲解 📚

在本节课中,我们将回顾期中考试后所学的核心算法与概念,包括图算法、动态规划等,并通过具体习题来巩固理解。课程结尾会进行总结。

图遍历算法 🗺️

上一节我们介绍了课程的整体安排,本节中我们来看看图遍历算法。图遍历是探索图结构的基础,主要有两种方法:深度优先搜索(DFS)和广度优先搜索(BFS)。

以下是两种算法的核心要点:

  • 深度优先搜索 (DFS):沿着一条路径深入探索,直到尽头再回溯。
  • 广度优先搜索 (BFS):从起点开始,逐层向外探索所有相邻节点。

这两种算法的运行时间均为 O(m + n),其中 m 是边数,n 是顶点数。通过它们可以解决许多问题,例如检查图的连通性、寻找连通分量、进行拓扑排序(DFS)以及寻找无权图中的最短路径(BFS)。

关于寻找强连通分量,虽然理论上可以通过多次运行 BFS 来尝试,但通常使用基于 DFS 的专门算法(如 Kosaraju 或 Tarjan 算法)更为高效和直接。

最短路径算法 🛣️

在能够遍历图之后,我们很自然地想知道图中两点间的最短路径。我们讨论了三种主要的最短路径算法。

以下是三种算法的对比:

  • Dijkstra 算法:用于在边权非负的图中寻找单源最短路径。它是三者中最快的,但适用条件也最严格。
  • Bellman-Ford 算法:用于在边权可为负的图中寻找单源最短路径。它还能检测图中是否存在负权环。
  • Floyd-Warshall 算法:用于寻找图中所有顶点对之间的最短路径。

你需要掌握这些算法的适用场景和大致时间复杂度,具体细节和证明请参考讲义。

动态规划 💡

接下来,我们离开图算法,看看另一种强大的算法设计范式——动态规划。动态规划通过解决重叠子问题来高效求解复杂问题。

设计动态规划算法通常遵循以下步骤:

  1. 定义状态:确定要计算什么,通常用数组(如 1D, 2D, 3D)来表示,并明确每个维度的含义。
  2. 建立递推关系:找出状态之间的转移方程。
  3. 确定基础情况:设置最小子问题的解。
  4. 计算顺序:确定填表的顺序,以确保计算当前状态时,其所依赖的子状态已被计算。

动态规划与分治法思想类似,都是将大问题分解为子问题。区别在于,动态规划的子问题通常大量重叠,因此需要存储子问题的解以避免重复计算。

最小生成树算法 🌲

现在让我们回到图论,看看如何寻找连接图中所有顶点的最小代价树,即最小生成树。我们学习了三种算法。

以下是三种算法的简要说明:

  • Borůvka 算法:每一轮为每个连通分量选择一条最小边,然后合并分量。
  • Kruskal 算法:按边权升序考虑所有边,如果加入当前边不会形成环,则将其加入生成树。其时间复杂度涉及反阿克曼函数,非常高效。
  • Prim 算法:从一个顶点开始,不断添加连接当前树与树外顶点的最小边。它类似于 Dijkstra 算法,但维护的是连接到树的边的权重,而非到源点的距离。

全局最小割与 Karger 算法 ✂️

我们讨论了一个具体的图分割问题:全局最小割。其目标是将图顶点划分为两个集合,使得连接这两个集合的边的总权重最小。

对于此 NP-Hard 问题,我们学习了一个随机算法——Karger 算法。其核心操作是边收缩:随机选择一条边,将其两端点合并为一个“超点”,并处理关联的边。重复此过程 n-2 次,最后剩余两个超点之间的边数(或权重和)即为一个割的值。

该算法的时间复杂度为 O(n²)。关键结论是:在包含 n 个顶点的图中,单次随机收缩命中全局最小割中任何一条边的概率至少为 (n-2)/n。通过重复运行算法多次并取最佳结果(蒙特卡洛方法),可以高概率找到全局最小割。

最大流与最小割 📊

我们讨论了另一种割:s-t 最小割。给定源点 s 和汇点 t,目标是找到一个割,使得 st 分属不同集合,且割的容量最小。

与此紧密相关的概念是最大流。流是对图中每条边分配一个非负值,满足容量限制和流量守恒(流入等于流出)。最大流-最小割定理指出:从 st最大流值等于分离 st最小割容量。

为了计算最大流,我们介绍了 Ford-Fulkerson 方法。其思想是:

  1. 从零流开始,构造残量图。
  2. 在残量图中寻找一条从 st 的增广路径。
  3. 沿该路径推送尽可能多的流量(路径上最小剩余容量)。
  4. 更新残量图(减少正向边容量,增加反向边容量)。
  5. 重复步骤2-4,直到无法找到增广路径。

增广路径的选择策略(如最短路径-BFS、最大容量路径)会影响算法运行时间,但核心框架不变。


问题解答与习题讲解环节

(注:此部分为课堂互动内容整理,涉及具体算法实现思路的讨论。)

问题1:半连通图判定
给定一个有向图,判断是否对于任意两个顶点 uv,至少存在一条从 uv 或从 vu 的路径。
思路

  1. 首先使用 DFS 或 Kosaraju/Tarjan 算法找出所有强连通分量(SCC),并将每个 SCC 收缩为一个超点,得到一个有向无环图(DAG)。
  2. 对这个 DAG 进行拓扑排序。
  3. 检查拓扑序列中每一对相邻的超点之间,是否存在至少一条从前一个超点指向后一个超点的路径(在原DAG中表现为存在有向边)。如果这个条件对所有相邻超点都成立,则原图是半连通的。
  4. 理由:在拓扑序中,如果存在一对相邻超点间没有路径相连,则分属这两个超点的原图顶点之间将无法按题目要求互通。反之,若所有相邻超点间都有路径,则利用拓扑序的传递性,可以证明任意两个超点间都有路径,从而原图任意两顶点间至少有一个方向的路径。

问题2:最长递增子序列 (LIS)
给定一个整数序列,找到最长的(严格)递增子序列的长度。
动态规划解法

  • 状态定义:设 dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。
  • 递推关系dp[i] = max(dp[j]) + 1,其中 0 <= j < inums[j] < nums[i]。即,查看所有在 i 之前且值小于 nums[i] 的元素,从它们结尾的 LIS 中选取最长的,然后接上 nums[i]
  • 基础情况:每个位置的初始 LIS 长度至少为 1(即只包含自身)。
  • 答案max(dp[0...n-1])
  • 时间复杂度O(n²),因为对每个 i 需要遍历其之前的所有 j
  • 优化提示:存在 O(n log n) 的贪心+二分查找算法。维护一个数组 tails,其中 tails[k] 存储长度为 k+1 的所有递增子序列中末尾元素的最小值。该数组是递增的,可以通过二分查找来更新。

总结 🎯

本节课中我们一起回顾了期中考试后的核心内容:

  1. 图算法:包括遍历(DFS/BFS)、最短路径(Dijkstra, Bellman-Ford, Floyd-Warshall)、最小生成树(Borůvka, Kruskal, Prim)、全局最小割(Karger)以及最大流/最小割(Ford-Fulkerson)。
  2. 动态规划:作为一种重要的算法设计范式,其核心在于定义状态、建立递推关系和处理基础情况。
  3. 通过两个习题(半连通图判定和最长递增子序列)我们实践了将图论概念(强连通分量、拓扑排序)和动态规划应用于具体问题。

请务必理解这些算法背后的思想、适用条件以及它们之间的联系,这对于应对期末考试至关重要。

015:期中复习课

在本节课中,我们将对CS161课程期中考试所涵盖的核心主题进行一次全面的回顾。我们将快速梳理图论、算法分析、递归、分治、排序、二叉搜索树和哈希表等关键概念,并通过示例帮助你巩固理解。

算法分析:渐进符号

上一节我们介绍了课程概述,本节中我们来看看算法分析的基础——渐进符号。这是评估算法效率的核心工具。

我们使用大O、大Ω和大Θ符号来描述函数的增长级别。其定义如下:

  • 若存在正常数 cn₀,使得对所有 n ≥ n₀,有 f(n) ≤ c·g(n),则 f(n) = O(g(n))。这表示上界
  • 若存在正常数 cn₀,使得对所有 n ≥ n₀,有 f(n) ≥ c·g(n),则 f(n) = Ω(g(n))。这表示下界
  • f(n) = O(g(n))f(n) = Ω(g(n)),则 f(n) = Θ(g(n))。这表示紧确界

在实践中,我们最常用大O符号来表示算法运行时间的上界。以下是一些有用的运算规则:

  • O(f(n)) + O(g(n)) = O(max(f(n), g(n)))
  • O(f(n)) * O(g(n)) = O(f(n) * g(n))

以下是应用大O符号分析算法复杂度的步骤:

  1. 将程序分解为多个独立的代码块。
  2. 分析每个代码块的复杂度。
  3. 对于嵌套循环,使用乘法规则。
  4. 对于顺序执行的代码块,使用加法规则,并保留增长最快的一项。

递归求解方法

理解了如何分析线性结构代码后,本节我们来看看当算法复杂度以递归形式表达时该如何求解。主要有三种方法。

主定理

主定理是求解特定形式递归式的最直接方法。它适用于形如 T(n) = aT(n/b) + f(n) 的递归式,其中 a ≥ 1, b > 1

主定理包含三种情况,你需要记忆或将其写在备忘单上:

  1. f(n) = O(n^(log_b a - ε)),其中 ε > 0,则 T(n) = Θ(n^(log_b a))
  2. f(n) = Θ(n^(log_b a) * log^k n),则 T(n) = Θ(n^(log_b a) * log^(k+1) n)
  3. f(n) = Ω(n^(log_b a + ε)),其中 ε > 0,且满足正则条件 af(n/b) ≤ cf(n)c < 1),则 T(n) = Θ(f(n))

注意:在情况1和情况3中,ε 必须严格大于0。在情况3中,必须验证正则条件。

代入法

代入法用于证明一个递归式的解(上界或下界),但它本身不提供猜测解的方法。

使用代入法的步骤如下:

  1. 猜测解的形式,例如 T(n) = O(g(n))
  2. 使用数学归纳法证明该猜测。关键步骤是假设对于所有小于 nm,有 T(m) ≤ c·g(m) 成立,然后利用递归式证明 T(n) ≤ c·g(n)
  3. 在证明过程中,可能需要调整常数 c 和起始点 n₀ 的值以使归纳步骤成立。通常,只要归纳步骤正确,可以选取足够大的 n₀ 来处理基本情况。

递归树法

当递归式是多项式的和时(例如 T(n) = T(n/3) + T(2n/3) + n),递归树法非常直观。

使用递归树法的步骤如下:

  1. 将递归式展开成一棵树,每个节点代表一次递归调用的成本。
  2. 计算每一层所有节点的总成本。
  3. 将整棵树所有层的成本相加,通常涉及对等比数列或等差数列求和。

关于递归式的其他注意事项

  • 在渐进分析中,可以忽略递归式中的向下取整(floor)向上取整(ceiling) 函数。
  • 当递归式中包含如 O(1)O(n³) 项时,应将其理解为 ≤ c·1≤ c·n³,不能直接替换为 1,但常数 c 通常不影响最终的渐进复杂度。

分治算法

分治是一种重要的算法设计范式,它将一个大问题分解成若干个相似的子问题,递归解决子问题,再合并结果得到原问题的解。

分治算法的核心步骤是:

  1. 分解:将原问题划分为多个子问题。
  2. 解决:递归地求解各个子问题。若子问题足够小,则直接求解。
  3. 合并:将子问题的解合并成原问题的解。

许多经典算法都采用了分治策略,例如归并排序和快速排序。分治算法的时间复杂度通常可以用递归式来描述,这又回到了我们上一节讨论的递归求解方法。

排序算法

排序是分治思想的典型应用。我们重点讨论两种重要的排序算法。

归并排序

归并排序是分治算法的典范。其步骤如下:

  1. 分解:将待排序数组平分为两半。
  2. 解决:递归地对左右两半分别进行归并排序。
  3. 合并:将两个已排序的数组合并成一个有序数组。合并操作使用双指针法,在线性时间内完成。

归并排序的递归式为 T(n) = 2T(n/2) + Θ(n)。根据主定理,其时间复杂度为 Θ(n log n)。这是一个最坏情况下的保证。

快速排序

快速排序同样采用分治思想,但策略不同:

  1. 分解:选择一个基准元素,将数组划分为两部分:小于基准的元素和大于基准的元素。
  2. 解决:递归地对两个子数组进行快速排序。
  3. 合并:由于基准元素已在正确位置,且子数组已排序,因此无需合并操作。

快速排序的性能高度依赖于基准的选择。最坏情况下(例如总是选择最大或最小元素),时间复杂度为 O(n²)。但若随机选择基准,期望运行时间为 O(n log n)

决策树与排序下界

决策树是一种模型,用于表示基于比较的排序算法的执行过程。树的每个内部节点代表一次元素比较,每条路径代表一种可能的比较序列,每个叶子节点代表一种可能的排序结果。

对于一个有 n 个元素的数组,共有 n! 种可能的排列。因此,对应的决策树至少要有 n! 个叶子。一棵高度为 h 的二叉树最多有 2^h 个叶子。由此可得:2^h ≥ n!。利用斯特林公式 n! ≈ √(2πn)(n/e)^n 近似,可以推导出 h = Ω(n log n)。这证明了任何基于比较的排序算法,其最坏情况运行时间的下界是 Ω(n log n)

顺序统计量

顺序统计量问题是指在包含 n 个元素的集合中寻找第 k 小(或第 k 大)的元素。一个经典的最坏情况线性时间算法(BFPRT算法)也使用了分治思想。

算法步骤如下:

  1. n 个元素划分为 ⌊n/5⌋ 组,每组5个元素(剩余不足5个的为一组)。
  2. 找出每组的中位数,构成一个中位数集合。
  3. 递归地调用本算法,找出中位数集合的中位数,作为基准 x
  4. 利用基准 x 将原数组划分为三部分:小于 x、等于 x、大于 x
  5. 判断第 k 小的元素落在哪个部分,并在相应的部分中递归查找。

该算法的关键在于基准的选择保证了每次递归调用至少能减少一定比例的元素。其递归式约为 T(n) ≤ T(n/5) + T(7n/10) + O(n),可以证明其解为 T(n) = O(n)

二叉搜索树

二叉搜索树是一种用于维护动态集合的数据结构,它支持高效的搜索、插入和删除操作。

性质:对于树中任意节点,其左子树中所有节点的值都小于该节点的值,其右子树中所有节点的值都大于该节点的值。

基本操作

  • 搜索:从根开始,若目标值等于当前节点值则找到;若小于则进入左子树;若大于则进入右子树。时间复杂度为 O(h)h 为树高。
  • 插入:先执行搜索,在搜索终止的位置(空节点)创建新节点插入。时间复杂度为 O(h)
  • 删除:找到目标节点后,分三种情况处理:
    1. 无子节点:直接删除。
    2. 有一个子节点:用其子节点替代它。
    3. 有两个子节点:找到其后继节点(右子树中的最小节点),用后继节点的值替换当前节点值,然后递归删除后继节点。时间复杂度为 O(h)

旋转操作:旋转是维护BST平衡(如AVL树、红黑树)的基础操作,用于局部调整树的结构而不破坏BST性质。

  • 左旋:围绕一个节点向右子方向“旋转”,使其右子节点成为新的子树根。
  • 右旋:围绕一个节点向左子方向“旋转”,使其左子节点成为新的子树根。
    旋转操作通过重新链接几个指针完成,保持了子树的中序遍历顺序不变。

哈希表

哈希表是一种通过哈希函数将键映射到表中位置来实现高效查找的数据结构。它主要需要解决哈希冲突(多个键映射到同一位置)的问题。

链地址法

在链地址法中,哈希表的每个槽位都指向一个链表。当发生冲突时,新元素被添加到对应槽位的链表中。

性能分析:定义装载因子 α = n / m,其中 n 是元素数量,m 是槽位数量。在简单均匀哈希的假设下,一次不成功查找的期望时间复杂度为 O(1 + α)。插入和成功查找的期望时间也是 O(1 + α)。当 α = O(1) 时,所有操作都是 O(1) 期望时间。

开放地址法

在开放地址法中,所有元素都直接存放在哈希表数组里。当发生冲突时,会按照一个预定的探查序列寻找下一个空闲槽位。

两种常见的探查序列生成方式:

  1. 线性探查:第 i 次探查的位置为 h(k, i) = (h'(k) + i) mod m
  2. 双重哈希:使用两个哈希函数,第 i 次探查的位置为 h(k, i) = (h₁(k) + i·h₂(k)) mod m

性能分析:在开放地址法中,装载因子 α 必须小于1。在均匀哈希假设下,一次不成功查找的期望探查次数约为 1/(1 - α)。当 α 接近1时,性能会急剧下降。

总结

本节课中我们一起学习了CS161期中考试的核心主题。我们从算法分析的渐进符号开始,深入探讨了求解递归式的主定理、代入法和递归树法。接着,我们回顾了分治算法的设计范式,并分析了归并排序和快速排序这两种经典排序算法及其理论下界。我们还了解了在线性时间内寻找顺序统计量的算法,以及二叉搜索树的基本操作、旋转和哈希表的两种冲突解决策略及其性能分析。希望这次复习能帮助你巩固知识,为考试做好充分准备。

posted @ 2026-03-29 09:45  布客飞龙I  阅读(3)  评论(0)    收藏  举报