加州理工-CS367-C-语言系统编程笔记-全-

加州理工 CS367 C 语言系统编程笔记(全)

001:变量与基础语法 🚀

在本节课中,我们将快速概览C语言的基础知识,包括如何编写一个简单的程序、理解变量声明以及C语言的基本数据类型。我们将通过对比Java来帮助你快速上手。


概述 📋

C语言是一种强大且广泛使用的编程语言,尤其在系统编程领域。本节课我们将学习C语言的基础语法,包括如何编写“Hello, World!”程序、声明变量以及使用基本数据类型。我们将通过实际的代码示例来加深理解。


课程内容 📚

1. 编写第一个C程序

上一节我们介绍了课程的整体安排,本节中我们来看看如何编写第一个C程序。与Java类似,C程序也需要一个入口点,即main函数。

以下是一个简单的“Hello, World!”程序:

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

代码解释:

  • #include <stdio.h>:这是一个预处理器指令,用于包含标准输入输出库,使我们能够使用printf函数。
  • int main():这是程序的入口点。int表示函数返回一个整数值,通常用于指示程序执行状态(0表示成功,非0表示错误)。
  • printf("Hello, World!\n");printf函数用于输出文本。\n是换行符。
  • return 0;:表示程序成功执行。

2. 编译和运行C程序

在编写完C程序后,我们需要将其编译成可执行文件。本节中我们将学习如何使用GCC编译器。

以下是编译和运行C程序的步骤:

  1. 编译程序:使用GCC编译器将C源代码编译成可执行文件。

    gcc hello.c -o hello
    
    • gcc:GNU C编译器。
    • hello.c:源代码文件。
    • -o hello:指定输出文件名为hello
  2. 运行程序:执行生成的可执行文件。

    ./hello
    

3. 变量与数据类型

在C语言中,变量用于存储数据。本节中我们将学习如何声明变量以及C语言的基本数据类型。

以下是C语言中常见的数据类型及其声明方式:

#include <stdio.h>
#include <stdbool.h>

int main() {
    // 整数类型
    int myInt = 100;
    printf("Integer: %d\n", myInt);

    // 字符类型
    char myChar = 'A';
    printf("Character: %c\n", myChar);

    // 浮点数类型
    float myFloat = 3.14f;
    printf("Float: %f\n", myFloat);

    // 双精度浮点数
    double myDouble = 3.1415926535;
    printf("Double: %lf\n", myDouble);

    // 布尔类型(需要包含stdbool.h)
    bool myBool = true;
    printf("Boolean: %d\n", myBool);

    // 字符串(字符数组)
    char myString[] = "Hello";
    printf("String (array): %s\n", myString);

    // 字符串(字符指针)
    char *myStringPtr = "World";
    printf("String (pointer): %s\n", myStringPtr);

    // 长整数
    long myLong = 100000L;
    printf("Long: %ld\n", myLong);

    // 短整数
    short myShort = 10;
    printf("Short: %hd\n", myShort);

    // 无符号整数
    unsigned int myUnsignedInt = 200;
    printf("Unsigned Int: %u\n", myUnsignedInt);

    // 长长整数
    long long myLongLong = 10000000000LL;
    printf("Long Long: %lld\n", myLongLong);

    return 0;
}

代码解释:

  • 整数类型int用于存储整数,%d是格式化输出整数的占位符。
  • 字符类型char用于存储单个字符,%c是格式化输出字符的占位符。
  • 浮点数类型floatdouble用于存储小数,%f%lf是格式化输出浮点数的占位符。
  • 布尔类型:C语言中没有原生的布尔类型,但可以通过stdbool.h库使用bool类型。布尔值实际上是整数(0表示false,非0表示true)。
  • 字符串:C语言中没有原生的字符串类型,字符串通常通过字符数组或字符指针表示。
  • 其他类型longshortunsigned intlong long用于存储不同范围和精度的整数。

4. 使用Shell环境

在C语言编程中,我们经常使用Shell环境来编译和运行程序。本节中我们将学习一些基本的Shell命令。

以下是常用的Shell命令:

  1. 列出目录内容

    ls
    
  2. 编译C程序

    gcc program.c -o program
    
  3. 运行可执行文件

    ./program
    
  4. 查看命令帮助

    man command_name
    

    command_name --help
    
  5. 清除终端屏幕

    clear
    

总结 🎯

本节课中我们一起学习了C语言的基础语法,包括如何编写和运行一个简单的C程序、声明变量以及使用基本数据类型。我们还介绍了如何使用Shell环境来编译和运行程序。通过对比Java,你可以看到C语言在语法上与Java有许多相似之处,但也存在一些关键差异,例如C语言中没有原生的布尔类型和字符串类型。

在接下来的课程中,我们将深入探讨C语言的更多高级特性,如控制结构、函数和指针。希望本节课的内容能帮助你快速上手C语言编程!

002:运算符与基本概念

在本节课中,我们将继续学习C语言的基础知识,重点介绍运算符、数据类型转换以及一些核心编程概念。我们将通过对比Java语言,帮助你理解C语言中的相似与不同之处。


概述

上一节我们介绍了C语言的基本类型和变量声明。本节中,我们将深入探讨C语言中的各种运算符,包括算术运算符、关系运算符、逻辑运算符和位运算符。我们还将学习数据类型转换(截断)以及处理浮点数时的注意事项。


算术运算符

C语言内置了多种基本运算符,它们与Java中的运算符非常相似。以下是一个展示基本算术运算的示例。

#include <stdio.h>

int main() {
    int a = 15;
    int b = 4;

    int sum = a + b;
    printf("Sum: %d\n", sum);

    int difference = a - b;
    printf("Difference: %d\n", difference);

    int product = a * b;
    printf("Product: %d\n", product);

    int quotient = a / b;
    printf("Quotient: %d\n", quotient);

    int remainder = a % b;
    printf("Remainder: %d\n", remainder);

    return 0;
}

编译并运行此代码,你将看到预期的算术结果。整数除法会截断小数部分,这与Java的行为一致。


递增与递减运算符

递增(++)和递减(--)运算符有两种形式:前置和后置。它们的行为取决于在语句中的位置。

#include <stdio.h>

int main() {
    int a = 5;
    int result;

    // 前置递增
    result = a;
    printf("Before pre-increment: %d\n", result);
    result = ++a; // 先递增,再赋值
    printf("During pre-increment: %d\n", result);
    printf("After pre-increment: %d\n", a);

    // 重置
    a = 5;
    result = a;
    printf("Before post-increment: %d\n", result);
    result = a++; // 先赋值,再递增
    printf("During post-increment: %d\n", result);
    printf("After post-increment: %d\n", a);

    // 递减操作同理
    a = 5;
    result = a;
    printf("Before pre-decrement: %d\n", result);
    result = --a;
    printf("During pre-decrement: %d\n", result);
    printf("After pre-decrement: %d\n", a);

    a = 5;
    result = a;
    printf("Before post-decrement: %d\n", result);
    result = a--;
    printf("During post-decrement: %d\n", result);
    printf("After post-decrement: %d\n", a);

    return 0;
}

前置操作先改变变量值,再使用该值;后置操作先使用当前值,再改变变量。


关系与相等运算符

关系运算符用于比较两个值,返回一个整数结果(0表示假,非0表示真,通常为1)。

#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;
    int result;

    result = (a == b);
    printf("%d == %d: %d\n", a, b, result);

    result = (a != b);
    printf("%d != %d: %d\n", a, b, result);

    result = (a > b);
    printf("%d > %d: %d\n", a, b, result);

    result = (a < b);
    printf("%d < %d: %d\n", a, b, result);

    result = (a >= b);
    printf("%d >= %d: %d\n", a, b, result);

    result = (a <= b);
    printf("%d <= %d: %d\n", a, b, result);

    return 0;
}

C语言没有内置的布尔类型,因此用整数代表逻辑值。


逻辑运算符

逻辑运算符(&&, ||, !)用于组合或取反条件表达式,它们同样操作整数并返回整数结果。

#include <stdio.h>

int main() {
    int a = 1; // 代表真
    int b = 0; // 代表假
    int result;

    result = a && b;
    printf("%d && %d = %d\n", a, b, result);

    result = a || b;
    printf("%d || %d = %d\n", a, b, result);

    result = !a;
    printf("!%d = %d\n", a, result);

    result = !b;
    printf("!%d = %d\n", b, result);

    return 0;
}

位运算符

位运算符直接对整数的二进制位进行操作。理解二进制表示对于掌握这些运算符至关重要。

#include <stdio.h>

int main() {
    unsigned int a = 12; // 二进制: 1100
    unsigned int b = 5;  // 二进制: 0101
    unsigned int result;

    printf("a = 0x%x, b = 0x%x\n", a, b);

    result = a & b;
    printf("a & b = 0x%x\n", result);

    result = a | b;
    printf("a | b = 0x%x\n", result);

    result = a ^ b;
    printf("a ^ b = 0x%x\n", result);

    result = ~a;
    printf("~a = 0x%x\n", result);

    result = a << 1;
    printf("a << 1 = 0x%x\n", result);

    result = a >> 1;
    printf("a >> 1 = 0x%x\n", result);

    return 0;
}

使用unsigned类型可以确保在进行位操作时,数值被当作纯二进制序列处理,避免符号位带来的意外影响。


数据截断

当将一个较大类型的值赋给一个较小类型的变量时,会发生截断。多余的数据会被丢弃。

#include <stdio.h>

int main() {
    int i = 321;
    char c = i; // 整数截断为字符
    printf("int %d -> char %d\n", i, c);

    double pi = 3.14159;
    int intPi = pi; // 浮点数截断为整数,丢弃小数部分
    printf("double %f -> int %d\n", pi, intPi);

    double precise = 123.4567890123;
    float approx = precise; // 双精度截断为单精度,损失精度
    printf("double %.10f -> float %.10f\n", precise, approx);

    return 0;
}

浮点数近似与相等性比较

浮点数(floatdouble)在计算机中是近似值。直接使用相等运算符(==)比较它们可能导致错误结果。

#include <stdio.h>
#include <math.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/86d2e7e4a1e2be61d7319ea32b06403f_6.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/86d2e7e4a1e2be61d7319ea32b06403f_7.png)

int main() {
    float a = 0.1f;
    float b = 0.8f;
    float sum = a + b;
    float c = 0.9f;

    // 错误的比较方式
    int resultWrong = (sum == c);
    printf("sum=%f, c=%f, (sum == c) = %d\n", sum, c, resultWrong);

    // 正确的比较方式:使用阈值
    float threshold = 0.0001f;
    int resultCorrect = fabsf(sum - c) < threshold;
    printf("|sum - c| < %f = %d\n", threshold, resultCorrect);

    // 对double类型同理
    double x = 0.1;
    double y = 0.2;
    double sumDouble = x + y;
    double z = 0.3;

    int resultWrongDouble = (sumDouble == z);
    printf("sumDouble=%f, z=%f, (sumDouble == z) = %d\n", sumDouble, z, resultWrongDouble);

    double thresholdDouble = 0.0000001;
    int resultCorrectDouble = fabs(sumDouble - z) < thresholdDouble;
    printf("|sumDouble - z| < %f = %d\n", thresholdDouble, resultCorrectDouble);

    return 0;
}

永远不要直接使用==比较浮点数。应该计算两个数的绝对差值,并检查该差值是否小于一个可接受的微小阈值。


复合赋值运算符

复合赋值运算符将一个运算和赋值合并为一步。

#include <stdio.h>

int main() {
    int a = 10;
    int temp;

    temp = a; a += 5;  printf("a += 5:  %d -> %d\n", temp, a);
    temp = a; a -= 3;  printf("a -= 3:  %d -> %d\n", temp, a);
    temp = a; a *= 2;  printf("a *= 2:  %d -> %d\n", temp, a);
    temp = a; a /= 4;  printf("a /= 4:  %d -> %d\n", temp, a);
    temp = a; a %= 3;  printf("a %%= 3:  %d -> %d\n", temp, a); // 注意%%用于打印%

    a = 1;
    temp = a; a <<= 2; printf("a <<= 2: %d -> %d\n", temp, a);
    temp = a; a >>= 1; printf("a >>= 1: %d -> %d\n", temp, a);

    a = 6;
    temp = a; a &= 3;  printf("a &= 3:  %d -> %d\n", temp, a);
    temp = a; a |= 2;  printf("a |= 2:  %d -> %d\n", temp, a);
    temp = a; a ^= 1;  printf("a ^= 1:  %d -> %d\n", temp, a);

    return 0;
}

选择使用复合赋值运算符还是分开写的表达式,应以代码的清晰度和可读性为首要标准。


C语言的安全性问题

与Java不同,C语言缺乏许多安全保护机制。例如,C允许你使用未初始化的变量,这会导致不可预测的行为。

#include <stdio.h>
int main() {
    int uninitializedVariable;
    printf("未初始化变量的值: %d\n", uninitializedVariable); // 输出是随机的
    return 0;
}

编译器不会报错,但程序会读取该内存地址中残留的任意值。这使得C语言编程需要格外小心,以确保代码的正确性和稳定性。


总结

本节课中我们一起学习了C语言的核心运算符和重要概念。我们详细探讨了算术运算符、递增递减运算符、关系与逻辑运算符以及位运算符。我们理解了数据截断的含义,并掌握了安全比较浮点数的方法。最后,我们认识了C语言中复合赋值运算符的用法,并意识到了C语言因缺乏安全机制而需要程序员更加谨慎。这些基础知识是将Java知识迁移到C语言环境的关键一步。在接下来的课程中,我们将学习控制结构。

003:控制结构、复杂类型与指针

在本节课中,我们将继续学习C语言,将Java中的知识映射到C语言上。我们将探讨控制结构、复杂数据类型(如结构体和数组)以及指针的概念。完成本次课程后,我们将布置第一个实验,让大家进行一些基础的C语言编程。

控制结构

上一节我们介绍了运算符,本节中我们来看看如何组织语句的执行流程,即控制结构。

为了将多个语句组合在一起执行,我们使用花括号,这与Java非常相似。花括号可以创建一个代码块,其中的变量共享作用域。无论是选择语句还是循环语句,默认情况下,如果不使用花括号,则只影响紧随其后的一条语句。花括号的价值在于,它允许你将多个语句组合成一个原子实体,编译器会将其作为一个整体执行。

花括号的打开和关闭方式与Java完全相同。

选择语句

选择语句是单选择、双选择和多选择语句的总称,你可能更熟悉它们的名字:ifif-else 或嵌套的 if 语句。

以下是选择语句的示例代码:

#include <stdio.h>

int main() {
    int number = 15;

    // 单选择语句
    if (number > 10) {
        printf("Number is greater than 10\n");
    }

    // 双选择语句
    if (number > 10) {
        printf("Number is greater than 10\n");
    } else {
        printf("Number is not greater than 10\n");
    }

    // 多选择语句(嵌套if-else)
    if (number < 10) {
        printf("Number is less than 10\n");
    } else if (number > 10 && number < 20) {
        printf("Number is between 10 and 20\n");
    } else {
        printf("Number is greater than or equal to 20\n");
    }

    return 0;
}

单选择语句是一个决策点,决定是否执行某个代码块或语句。双选择语句保证会得到两种结果之一:如果条件为真,则执行 if 后的代码;如果为假,则执行 else 后的代码。多选择语句(嵌套 if-else)允许在 else 后附加另一个 if,直到最后的 else 作为默认情况。

Switch语句

除了嵌套的多选择语句,还可以使用 switch 控制结构来表示多选择。switch 语句基于相等性检查,而嵌套选择语句可以检查相等性和关系属性(如大于或小于)。switch 只适用于检查相等性的情况。

switch 语句使用标签(case)进行跳转,类似于汇编语言中的标签跳转。因此,需要使用 break 来跳出 switch 块,否则会继续执行后续的代码块。

以下是 switch 语句的示例:

#include <stdio.h>

int main() {
    int number = 3;

    switch (number) {
        case 1:
            printf("Number is one\n");
            break;
        case 2:
            printf("Number is two\n");
            break;
        case 3:
            printf("Number is three\n");
            break;
        default:
            printf("Number is not one, two, or three\n");
            break;
    }

    // 等效的嵌套if-else
    if (number == 1) {
        printf("Number is one\n");
    } else if (number == 2) {
        printf("Number is two\n");
    } else if (number == 3) {
        printf("Number is three\n");
    } else {
        printf("Number is not one, two, or three\n");
    }

    return 0;
}

通常,如果可能,更推荐使用嵌套的多选择语句,这是更好的软件工程实践。有些语言(如Python)甚至不支持 switch 语句。

循环语句

循环语句在C语言中看起来几乎与Java完全相同。Java从C语言继承了许多语法。

以下是循环语句的示例:

#include <stdio.h>

int main() {
    // for循环
    for (int i = 1; i <= 5; i++) {
        printf("For loop: %d\n", i);
    }

    // while循环
    int j = 1;
    while (j <= 5) {
        printf("While loop: %d\n", j);
        j++;
    }

    // do-while循环
    int k = 6;
    do {
        printf("Do-while loop: %d\n", k);
        k++;
    } while (k <= 5);

    return 0;
}

for 循环的特点是所有管理循环逻辑的数据(控制变量的初始化、条件判断和更新)都放在 for 语句的括号内。while 循环在条件为真时重复执行代码块。do-while 循环保证至少执行一次循环体,即使条件初始为假。

通常,如果事先知道需要循环的次数,使用 for 循环;如果不知道,使用 while 循环。do-while 循环适用于需要至少执行一次的情况。

Break和Continue

breakcontinue 在循环中的行为与Java完全相同。break 用于跳出循环,continue 用于跳过当前迭代的剩余语句,直接进入下一次迭代。

以下是 breakcontinue 的示例:

#include <stdio.h>

int main() {
    // break示例
    int i;
    for (i = 1; i <= 10; i++) {
        if (i == 5) {
            printf("Breaking at %d\n", i);
            break;
        }
    }
    printf("Loop ended at i = %d\n", i);

    // continue示例
    for (int j = 1; j <= 10; j++) {
        if (j % 2 == 0) {
            continue; // 跳过偶数
        }
        printf("Odd number: %d\n", j);
    }

    return 0;
}

在第一个循环中,当 i 等于5时,使用 break 跳出循环。在第二个循环中,当 j 是偶数时,使用 continue 跳过打印语句,只打印奇数。

复杂数据类型

我们已经讨论了原始数据类型(如整数、短整型、长整型、浮点数、双精度浮点数和字符)。现在,让我们看看如何创建更类似于Java中对象和数组的数据结构。

结构体

C语言没有对象,但有一种称为结构体(struct)的原型,它是对象的前身。结构体允许你创建包含不同类型数据的集合。

以下是结构体的基本示例:

#include <stdio.h>

// 定义一个结构体
struct fraction {
    int numerator;
    int denominator;
};

// 辅助函数:打印分数
void printFraction(struct fraction f) {
    printf("Fraction: %d/%d\n", f.numerator, f.denominator);
}

int main() {
    // 声明结构体变量
    struct fraction f1, f2;

    // 打印未初始化的值(危险!)
    printf("Before initialization:\n");
    printFraction(f1);

    // 设置结构体字段
    f1.numerator = 22;
    f1.denominator = 7;
    printf("After initialization:\n");
    printFraction(f1);

    // 打印未初始化的f2
    printf("Before copying:\n");
    printFraction(f2);

    // 使用赋值运算符复制结构体
    f2 = f1;
    printf("After copying:\n");
    printFraction(f2);

    return 0;
}

结构体使用 struct 关键字定义,后跟结构体名称和花括号内的字段。你可以像访问对象属性一样,使用点运算符(.)访问结构体的字段。C语言中,结构体赋值是字段的逐位复制。

Typedef

每次引用结构体时都需要写 struct fraction,为了简化,可以使用 typedef 为结构体创建别名。

以下是使用 typedef 的示例:

#include <stdio.h>

// 使用typedef为结构体创建别名
typedef struct {
    int numerator;
    int denominator;
} Fraction;

// 辅助函数:打印分数
void printFraction(Fraction f) {
    printf("Fraction: %d/%d\n", f.numerator, f.denominator);
}

int main() {
    // 使用别名声明变量
    Fraction f1 = {22, 7};
    printFraction(f1);

    return 0;
}

typedef 允许你使用更简洁的名称(如 Fraction)来代替 struct fraction。这是C语言中最接近对象的方式。

结构体允许混合不同类型的数据,而数组则要求所有元素类型相同。

数组

数组在C语言中与Java类似,但有一个重要区别:C语言中的数组不知道自己的长度,而Java数组知道。因此,在C语言中,你需要自己跟踪数组的大小。

以下是数组的基本示例:

#include <stdio.h>

// 辅助函数:打印整数数组
void printIntArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main() {
    // 声明数组
    int numbers[10];

    // 打印未初始化的数组(危险!)
    printf("Initial state (uninitialized):\n");
    printIntArray(numbers, 10);

    // 设置数组值
    for (int i = 0; i < 10; i++) {
        numbers[i] = i * 2;
    }
    printf("After initialization:\n");
    printIntArray(numbers, 10);

    // 字符数组(字符串)
    char greeting[] = "Hello, World!";
    printf("String: %s\n", greeting);

    // 使用数组字面量初始化
    int primes[] = {2, 3, 5, 7, 11};
    printf("Primes: ");
    printIntArray(primes, 5);

    // 危险操作:访问越界索引
    printf("Dangerous access (index 15): %d\n", numbers[15]);

    return 0;
}

数组使用方括号([])声明和索引。字符数组可以用字符串字面量初始化,字符串以空字符(\0)结尾。数组字面量使用花括号初始化。C语言不检查数组越界,访问无效索引可能导致未定义行为。

预处理器指令#define

为了安全地使用数组大小,可以使用预处理器指令 #define 定义常量。

以下是使用 #define 的示例:

#include <stdio.h>

#define ARRAY_SIZE 10

void printIntArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main() {
    int numbers[ARRAY_SIZE];

    for (int i = 0; i < ARRAY_SIZE; i++) {
        numbers[i] = i * i;
    }

    printIntArray(numbers, ARRAY_SIZE);

    return 0;
}

#define 创建一个标签(如 ARRAY_SIZE),在编译时替换为对应的数值。这允许你使用常量值,便于修改且不可变。

多维数组

与Java类似,C语言支持多维数组。多维数组实际上是数组的数组。

以下是一个二维数组的示例:

#include <stdio.h>

#define SIZE 10

int main() {
    int board[SIZE][SIZE];

    // 初始化二维数组
    for (int i = 0; i < SIZE; i++) {
        for (int j = 0; j < SIZE; j++) {
            board[i][j] = i * SIZE + j;
        }
    }

    // 高效访问(行优先)
    printf("Row-major order (efficient):\n");
    for (int i = 0; i < SIZE; i++) {
        for (int j = 0; j < SIZE; j++) {
            printf("%2d ", board[i][j]);
        }
        printf("\n");
    }

    // 低效访问(列优先)
    printf("\nColumn-major order (inefficient):\n");
    for (int j = 0; j < SIZE; j++) {
        for (int i = 0; i < SIZE; i++) {
            printf("%2d ", board[i][j]);
        }
        printf("\n");
    }

    return 0;
}

多维数组在内存中按行优先顺序连续存储。高效访问应遵循行优先顺序,以减少内存跳转。

结构体数组

可以创建结构体数组,类似于Java中的对象数组。

以下是结构体数组的示例:

#include <stdio.h>

#define ARRAY_SIZE 1000

typedef struct {
    int numerator;
    int denominator;
} Fraction;

void printFraction(Fraction f) {
    printf("%d/%d ", f.numerator, f.denominator);
}

int main() {
    Fraction numbers[ARRAY_SIZE];

    // 设置前三个元素
    numbers[0].numerator = 1;
    numbers[0].denominator = 2;

    numbers[1].numerator = 3;
    numbers[1].denominator = 4;

    numbers[2].numerator = 5;
    numbers[2].denominator = 6;

    // 打印前三个元素
    for (int i = 0; i < 3; i++) {
        printFraction(numbers[i]);
    }
    printf("\n");

    // 危险操作:访问越界索引
    // printf("Dangerous: ");
    // printFraction(numbers[ARRAY_SIZE]); // 可能导致崩溃

    return 0;
}

结构体数组允许你存储多个结构体实例。与普通数组一样,需要小心越界访问。

指针

指针是C语言的核心概念之一。它们允许你直接操作内存地址,实现按引用传递。

指针基础

指针是存储内存地址的变量。使用 & 运算符获取变量的地址,使用 * 运算符声明指针或解引用指针。

以下是指针的基本示例:

#include <stdio.h>

int main() {
    int a = 5;
    int *pointer; // 声明指向整数的指针

    pointer = &a; // 将a的地址赋值给指针

    printf("Value of a: %d\n", a);
    printf("Address of a: %p\n", (void*)&a);
    printf("Value of pointer: %p\n", (void*)pointer);
    printf("Dereferenced pointer: %d\n", *pointer);

    return 0;
}

指针类型告诉系统该地址存储的数据类型所占用的字节数。解引用指针可以访问或修改该内存地址存储的值。

通过指针修改变量

可以通过指针修改变量的值。

以下是通过指针修改变量的示例:

#include <stdio.h>

int main() {
    int value = 5;
    int *ptr = &value;

    printf("Original value: %d\n", value);

    // 通过指针修改值
    *ptr = 10;

    printf("Modified value: %d\n", value);

    return 0;
}

通过解引用指针并赋值,可以修改原始变量的值。

结构体指针

可以使用指针指向结构体,并通过箭头运算符(->)访问结构体字段。

以下是结构体指针的示例:

#include <stdio.h>

typedef struct {
    int numerator;
    int denominator;
} Fraction;

void printFraction(Fraction *f) {
    printf("Fraction: %d/%d\n", f->numerator, f->denominator);
}

int main() {
    Fraction f1 = {22, 7};
    Fraction *ptr = &f1;

    printFraction(ptr);

    return 0;
}

当函数参数是指向结构体的指针时,使用箭头运算符访问字段。如果参数是结构体本身,则使用点运算符。

指针的指针

指针可以指向其他指针,形成多级间接引用。

以下是指针的指针的示例:

#include <stdio.h>

int main() {
    int value = 5;
    int *ptr = &value;
    int **ptrToPtr = &ptr;

    printf("Value: %d\n", value);
    printf("Value via pointer: %d\n", *ptr);
    printf("Value via pointer to pointer: %d\n", **ptrToPtr);

    return 0;
}

多级指针类似于多维数组,允许你通过多个间接层访问数据。

空指针

空指针(NULL)表示指针不指向任何内存地址。在C语言中,NULL 通常定义为0。

以下是空指针的示例:

#include <stdio.h>

int main() {
    int *ptr = NULL;

    if (ptr == NULL) {
        printf("Pointer is NULL\n");
    }

    if (ptr == 0) {
        printf("Pointer is also equal to 0\n");
    }

    return 0;
}

空指针用于表示指针未初始化或指向无效地址。

链表示例

指针常用于构建动态数据结构,如链表。

以下是一个简单链表的示例:

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

typedef struct Node {
    int data;
    struct Node *next;
} Node;

void printList(Node *n) {
    while (n != NULL) {
        printf("Node data: %d\n", n->data);
        n = n->next;
    }
}

int main() {
    Node *head = NULL;
    Node *second = NULL;
    Node *third = NULL;

    // 分配内存
    head = (Node*)malloc(sizeof(Node));
    second = (Node*)malloc(sizeof(Node));
    third = (Node*)malloc(sizeof(Node));

    // 赋值并链接节点
    head->data = 100;
    head->next = second;

    second->data = 200;
    second->next = third;

    third->data = 300;
    third->next = NULL;

    // 打印链表
    printList(head);

    // 释放内存
    free(head);
    free(second);
    free(third);

    return 0;
}

链表节点包含数据和一个指向下一个节点的指针。通过指针链接节点,可以动态创建和遍历链表。

指针的陷阱

指针使用不当可能导致程序崩溃或未定义行为。最常见的陷阱之一是使用未初始化的指针。

以下是指针陷阱的示例:

#include <stdio.h>

int main() {
    int *ptr; // 未初始化的指针

    // 危险操作:解引用未初始化的指针
    // *ptr = 13; // 可能导致段错误

    // 正确做法:先初始化指针
    int value = 13;
    ptr = &value;
    *ptr = 42;

    printf("Value: %d\n", value);

    return 0;
}

未初始化的指针指向随机内存地址,解引用它可能导致段错误。始终确保指针指向有效的内存地址。

总结

本节课中我们一起学习了C语言的控制结构、复杂数据类型和指针。我们探讨了选择语句、循环语句、结构体、数组以及指针的基本概念和用法。指针是C语言强大但也危险的特征,它提供了直接操作内存的能力,但需要谨慎使用以避免错误。掌握这些概念是进行系统编程的基础。

004:字符串、函数、输入与动态内存分配

在本节课中,我们将深入学习C语言的核心概念,包括字符串的操作、函数的定义与使用、从用户获取输入以及动态内存分配。我们将通过具体的代码示例来理解这些概念,并学习如何将它们应用到实际编程中。

指针与数组的关联

上一节我们介绍了指针的基本概念,本节中我们来看看指针与数组之间的紧密联系。实际上,我们可以使用指针来遍历一组元素,就像使用数组一样。

以下是指针算术的示例代码:

#include <stdio.h>

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    int *ptr = arr;

    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }

    for (int i = 0; i < 5; i++) {
        printf("*(ptr + %d) = %d\n", i, *(ptr + i));
    }

    arr[2] = 60;
    *(ptr + 3) = 80;

    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }

    return 0;
}

数组名 arr 本质上是一个指向数组首元素的指针。通过指针算术(如 ptr + i),我们可以访问数组中的任意元素。方括号 [] 操作符和指针解引用 * 操作符可以实现相同的功能。

字符串基础

字符串在C语言中并非内置类型,它们是以空字符 \0 结尾的字符数组。以下是字符串的基本操作:

#include <stdio.h>

int main() {
    char str1[] = "Hello World";
    char *str2 = "Hello World";

    printf("%s\n", str1);
    printf("%s\n", str2);

    str1[0] = 'F';
    str1[1] = 'o';
    str1[2] = 'o';
    str1[3] = '\0';

    str2 = "Ted was here and left this long message";

    printf("%s\n", str1);
    printf("%s\n", str2);

    return 0;
}

使用字符数组定义字符串时,可以预留固定空间。使用字符指针定义字符串则更灵活,但存在缓冲区溢出的风险。修改字符串时,字符数组可以通过索引逐个修改,而字符指针可以重新指向新的字符串字面量。

字符串库函数

C标准库 <string.h> 提供了丰富的字符串处理函数,弥补了语言本身的不足。

以下是常用字符串操作的示例:

  • 字符串连接:使用 strcat 函数。
  • 字符串比较:使用 strcmp 函数。它返回两个字符串的差值,0表示相等。
  • 获取字符串长度:使用 strlen 函数。
  • 查找子字符串:使用 strstr 函数。它返回指向子字符串首次出现位置的指针。
  • 复制字符串:使用 strcpy 函数。
#include <stdio.h>
#include <string.h>

int main() {
    char str1[20] = "Hello";
    char str2[] = " World";
    strcat(str1, str2);
    printf("%s\n", str1);

    if (strcmp(str1, "Hello World") == 0) {
        printf("Strings are equal.\n");
    }

    printf("Length: %zu\n", strlen(str1));

    char *sub = strstr(str1, "World");
    if (sub != NULL) {
        printf("Found substring: %s\n", sub);
    }

    char src[] = "Hello";
    char dest[100];
    strcpy(dest, src);
    printf("%s\n", dest);

    return 0;
}

类型转换

从用户输入获取的通常是字符串,我们需要将其转换为数值类型。<stdlib.h> 库提供了转换函数。

以下是类型转换的示例:

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

int main() {
    char int_str[] = "123";
    char float_str[] = "123.45";

    int int_val = atoi(int_str);
    double float_val = atof(float_str);

    printf("Integer: %d\n", int_val);
    printf("Float: %f\n", float_val);

    return 0;
}

atoi 函数将字符串转换为整数,atof 函数将字符串转换为浮点数。

函数

函数是C语言组织代码的基本单元。我们需要理解函数原型、定义、参数传递(值传递与引用传递)以及函数指针。

以下是函数相关概念的示例:

  • 函数原型与定义:原型声明函数签名,定义提供具体实现。
  • 值传递与引用传递:值传递复制参数值,引用传递通过指针直接操作原变量。
  • 常量指针参数:使用 const 关键字保护指针指向的数据不被修改。
  • 静态函数:使用 static 关键字限制函数作用域为当前文件。
  • 函数指针:可以指向函数的指针,用于实现回调或类似对象的行为。
#include <stdio.h>

void add_by_reference(int *a, int b) {
    *a += b;
}

int add_by_value(int a, int b) {
    return a + b;
}

void print_value(const int *val) {
    printf("Value: %d\n", *val);
}

static void helper() {
    printf("This is a static helper function.\n");
}

typedef struct {
    int numerator;
    int denominator;
    void (*init)(struct Fraction *, int, int);
    void (*print)(const struct Fraction *);
} Fraction;

void init_fraction(Fraction *f, int num, int den) {
    f->numerator = num;
    f->denominator = den;
}

void print_fraction(const Fraction *f) {
    printf("%d/%d\n", f->numerator, f->denominator);
}

int main() {
    int x = 10;
    add_by_reference(&x, 5);
    printf("After reference add: %d\n", x);

    x = add_by_value(10, 5);
    printf("After value add: %d\n", x);

    print_value(&x);

    helper();

    Fraction f1;
    f1.init = init_fraction;
    f1.print = print_fraction;
    f1.init(&f1, 1, 2);
    f1.print(&f1);

    return 0;
}

用户输入

从标准输入获取数据是交互式程序的基础。对于数字和字符串,有不同的安全输入方法。

以下是获取用户输入的示例:

  • 获取数字:使用 scanf 配合格式说明符(如 %d, %f)。
  • 获取字符串(危险方式):使用 scanf("%s", str),不检查缓冲区边界。
  • 获取字符串(较安全方式):使用 scanf("%9s", str),限制读取字符数。
  • 获取字符串(安全方式):使用 fgets(str, sizeof(str), stdin),并处理末尾的换行符。
#include <stdio.h>
#include <string.h>

int main() {
    int num;
    float fnum;
    char str[10];

    printf("Enter an integer: ");
    scanf("%d", &num);
    printf("Enter a float: ");
    scanf("%f", &fnum);
    getchar();

    printf("Enter a short string (dangerous): ");
    scanf("%s", str);
    getchar();

    printf("Enter a short string (safer): ");
    scanf("%9s", str);
    getchar();

    printf("Enter a short string (safest): ");
    fgets(str, sizeof(str), stdin);
    str[strcspn(str, "\n")] = '\0';

    printf("You entered: %d, %f, %s\n", num, fnum, str);

    return 0;
}

动态内存分配

静态内存分配在编译时确定大小,缺乏灵活性。动态内存分配允许程序在运行时请求所需内存,使用后释放,提高了内存利用率和程序灵活性。

以下是动态内存分配的示例:

  • 分配单个整数:使用 malloc(sizeof(int))
  • 分配数组:使用 malloc(n * sizeof(int))
  • 分配字符串:使用 malloc((length + 1) * sizeof(char)),为终止符预留空间。
  • 释放内存:使用 free(ptr) 释放分配的内存,并将指针设为 NULL
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *dynamic_int = malloc(sizeof(int));
    if (dynamic_int == NULL) {
        return 1;
    }
    *dynamic_int = 42;
    printf("Dynamic integer: %d\n", *dynamic_int);
    free(dynamic_int);
    dynamic_int = NULL;

    int n;
    printf("Enter number of elements: ");
    scanf("%d", &n);
    int *dynamic_arr = malloc(n * sizeof(int));
    if (dynamic_arr == NULL) {
        return 1;
    }
    for (int i = 0; i < n; i++) {
        dynamic_arr[i] = i * 10;
    }
    for (int i = 0; i < n; i++) {
        printf("%d ", dynamic_arr[i]);
    }
    printf("\n");
    free(dynamic_arr);
    dynamic_arr = NULL;

    return 0;
}

多文件编程与头文件

为了组织大型项目,我们可以将代码拆分到多个源文件和头文件中。头文件(.h)包含函数原型和常量定义,源文件(.c)包含具体实现。

以下是多文件编程的示例:

头文件 myheader.h:

#ifndef MYHEADER_H
#define MYHEADER_H

void say_hello(void);

#endif

源文件 implementation.c:

#include <stdio.h>
#include "myheader.h"

void say_hello(void) {
    printf("Hello from another file!\n");
}

主文件 main.c:

#include "myheader.h"

int main() {
    say_hello();
    return 0;
}

编译时需链接所有源文件:

gcc main.c implementation.c -o myprogram

数学库

C标准库 <math.h> 提供了常用的数学函数,如 sqrt, pow, sin, cos, fabs 等。使用这些函数通常需要在编译时链接数学库(-lm)。

#include <stdio.h>
#include <math.h>

int main() {
    double x = 16.0;
    double y = 3.0;
    printf("sqrt(%f) = %f\n", x, sqrt(x));
    printf("pow(%f, %f) = %f\n", x, y, pow(x, y));
    printf("sin(%f) = %f\n", x, sin(x));
    return 0;
}

编译命令示例:

gcc math_example.c -lm -o math_example

总结

本节课中我们一起学习了C语言中字符串的处理方式、函数的定义与多种参数传递机制、从用户安全获取输入的方法,以及至关重要的动态内存分配技术。我们还了解了如何利用标准库扩展功能,以及如何通过头文件和多个源文件来组织代码。掌握这些概念是进行C语言系统编程的基础。下一节课,我们将尝试综合运用这些知识构建一个简单的应用程序。

005:从Java到C实战转换

概述

在本节课中,我们将通过一个具体的实践项目,学习如何将Java语言编写的应用程序转换为C语言版本。我们将回顾之前课程中涉及的核心概念,如内存管理、函数指针等,并应用这些知识来完成一个“待办事项列表”应用的代码转换。


上一讲我们以异步在线形式进行,探讨了内存分配等核心概念,并了解了C++如何构建在C语言之上。我们还讨论了如何通过函数指针为数据结构创建方法,这为实现面向对象风格的编程提供了思路。

本节我们将转换学习节奏,进行一次基于团队的实践工作坊。目标是检验大家对C语言知识的掌握程度,并通过动手实践加深理解。

以下是本次工作坊的核心任务描述。

  • 我们将分析一个用Java编写的简单“待办事项列表”应用程序。
  • 该应用具备添加任务、查看任务、移除任务和退出程序等基本功能。
  • 我们的目标是以团队形式,在限定时间内,将这份Java代码转换为功能等效的C语言代码。

现在,让我们先来查看这个Java应用程序的运行效果。

# 这是一个示意性的命令,表示运行Java程序
java TodoApp

程序运行后,会显示一个菜单:1. 添加任务,2. 查看任务,3. 移除任务,4. 退出。用户可以与之交互,例如添加“任务一”、“任务二”,然后查看列表,或移除中间的任务,列表会相应更新。

这个应用在Java中实现起来相对简单直接。接下来,我们看看已经存在的C语言版本。

# 编译C语言版本的待办事项应用
gcc todo_app.c -o todo_app
# 运行编译后的程序
./todo_app

C语言版本的程序运行起来,其外观和功能与Java版本完全一致。这证明了用C语言可以实现相同的逻辑。

对于在线学习的同学,建议通过Discord等工具组建小组进行协作。大家可以从课程GitHub仓库的第五讲目录中获取Java源代码文件。

在转换过程中,你可能会遇到各种挑战。请记住,我们课程中讨论过的所有示例源代码都可以在GitHub仓库中找到。

以下是可供参考的核心C语言概念示例代码。

// 示例:函数指针的用法
int (*operation)(int, int); // 声明一个函数指针
operation = add; // 指向add函数
int result = operation(5, 3); // 通过指针调用函数

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/c24a76055b5e33e33e0bae0ae502514b_5.png)

// 示例:动态字符串数组(指针的指针)
char **task_list = malloc(sizeof(char*) * capacity);
task_list[0] = malloc(strlen("Task 1") + 1);
strcpy(task_list[0], "Task 1");

如果对如何处理指向字符的指针、多维数组或字符串操作有疑问,可以参考这些基础示例。你们可以下载这些源代码文件,编译并运行它们,以此作为构建C语言版本待办事项应用的基石。


总结

本节课我们一起进行了一次从Java到C的代码转换实战。我们回顾了内存地址、函数指针等关键概念,并尝试在团队协作中应用这些知识来解决实际问题。虽然C和Java语法相近,但考虑到内存管理的差异,完成一个功能完整的转换并非易事。请大家继续以小组形式完成这个项目,利用课程提供的资源作为参考。

006:.hello程序的生命周期 🖥️

在本节课中,我们将学习一个简单的“Hello World”程序从编写到在计算机上运行并退出的完整生命周期。我们将从软件和硬件两个视角,深入理解计算机系统是如何协同工作来执行程序的。

概述

我们将追踪一个C语言“Hello World”程序的生命周期。这个过程可以分为六个阶段:创建、编译、加载、执行、输出和终止。理解这个过程是理解计算机系统如何工作的基础。

软件视角:程序的生命周期

从纯软件的角度看,一个程序的生命周期可以抽象为六个阶段。

1. 创建阶段

在这个阶段,我们使用文本编辑器编写程序代码,并将其保存为一个源文件(例如 hello.c)。这个文件以文本形式存储在我们的系统中。

2. 编译阶段

接下来,我们需要将人类可读的源代码转换成计算机可以执行的格式。这是由编译器完成的。对于C语言,我们通常使用 gcc 编译器。

编译过程本身包含四个步骤:

  • 预处理:处理源代码中以 # 开头的指令(如 #include),将引用的头文件内容插入到源代码中,生成一个 .i 文件。
  • 编译:将预处理后的C代码翻译成汇编语言代码,生成一个 .s 文件。
  • 汇编:将汇编代码翻译成机器语言指令(二进制代码),生成一个 .o 目标文件。
  • 链接:将程序的目标文件与所需的标准库(如包含 printf 函数的库)合并,生成最终的可执行文件(例如 a.outhello)。

3. 加载阶段

当我们通过命令行(例如在shell中输入 ./hello)运行程序时,操作系统会将可执行文件从磁盘加载到主内存中,为执行做好准备。

4. 执行阶段

中央处理器(CPU)开始执行加载到内存中的机器语言指令。CPU从程序计数器指示的位置读取指令,解释并执行它,然后更新程序计数器指向下一条指令,如此循环。

5. 输出阶段

程序通过调用 printf 这样的函数,将字符串“Hello World”发送到系统的输出流,最终显示在用户的屏幕上。

6. 终止阶段

程序执行完毕后,操作系统会回收分配给该程序的所有内存资源,使其可供系统其他部分使用。

深入编译系统

上一节我们概述了程序的生命周期,本节中我们来看看编译阶段的具体细节。理解编译系统有助于我们优化代码、调试和避免安全漏洞。

gcc 驱动程序在后台调用了四个独立的程序来完成编译工作:

  1. 预处理器 (cpp)hello.c -> hello.i
  2. 编译器 (cc1)hello.i -> hello.s
  3. 汇编器 (as)hello.s -> hello.o
  4. 链接器 (ld)hello.o + printf.o -> hello (可执行文件)

系统信息表示

在深入硬件之前,我们需要理解一个核心概念:计算机系统中的所有信息——包括磁盘文件、内存中的程序和网络传输的数据——都是用二进制位序列表示的。

不同的数据对象(如整数、浮点数、字符串或机器指令)之所以有意义,是因为我们为这些位序列提供了上下文。例如,同样的位序列,作为整数解释和作为浮点数解释会得到完全不同的值。

文本文件是二进制序列的一种特例,它使用 ASCII 编码标准,将每个字符映射为一个字节大小的整数值。我们可以使用 xxdod 命令行工具来查看文件的二进制/十六进制表示。

# 以十六进制和ASCII字符形式查看 hello.c
xxd -g 1 hello.c

# 以十进制整数和ASCII字符形式查看 hello.c
od -t dC hello.c

硬件视角:程序如何运行

现在我们已经了解了软件层面的流程,本节我们将看看硬件是如何支持这个流程的。计算机硬件可以简化为四个主要组件,它们通过总线连接。

1. 总线

总线是贯穿整个系统的电子管道,负责在各个部件之间传递固定大小的字节块,这个块被称为。字的大小是系统的一个基本参数,例如32位系统或64位系统。

2. 输入/输出设备

I/O设备是系统与外部世界的连接通道,例如键盘、鼠标、显示器、磁盘驱动器、网络适配器等。每个设备都通过控制器或适配器与I/O总线相连。

3. 主存储器

主存储器是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。逻辑上,内存可以被看作一个线性的字节数组,每个字节都有唯一的地址。

4. 处理器

处理器(CPU)是解释并执行存储在主存中指令的引擎。它的核心部件包括:

  • 程序计数器:指向下一条要执行的指令。
  • 寄存器文件:小型、高速的存储设备。
  • 算术/逻辑单元:执行算术和逻辑运算。

CPU不断重复执行一个简单的指令周期:取指 -> 译码 -> 执行 -> 更新程序计数器。指令集架构定义了CPU可以执行的基本操作,如加载、存储、运算和跳转。

“Hello”程序在硬件上的运行轨迹

结合以上硬件知识,让我们跟踪 hello 程序的执行过程:

  1. 输入指令:我们在shell中键入 ./hello 并回车。Shell程序通过键盘I/O设备读取字符,并将其存入内存。
  2. 加载程序:Shell通过执行一系列指令,指示操作系统将 hello 可执行文件从磁盘加载到主存。
  3. 执行指令:处理器开始执行 hello 程序主函数中的机器指令。这些指令将“Hello World”字符串从内存复制到寄存器文件。
  4. 输出结果:处理器再将数据从寄存器文件复制到显示设备(图形适配器),最终字符串显示在屏幕上。

重要概念:缓存、进程与抽象

在程序执行过程中,数据在不同存储层次间频繁移动。为了弥补处理器和主存之间的速度差距,系统采用了高速缓存存储器。缓存利用了程序的局部性原理,将处理器近期可能会用到的信息存放在更小、更快的存储设备中。

操作系统是管理硬件和应用程序的软件层。它通过几个关键抽象来简化我们的编程:

  • 进程:是对一个正在运行的程序的抽象。操作系统通过上下文切换来实现多个进程的并发执行。
  • 虚拟内存:为每个进程提供一个统一的、私有的地址空间视图,让每个进程都感觉自己独占了主存。
  • 文件:是对所有I/O设备的抽象,将一切输入输出都视为对字节序列的读写。网络通信也可以被视为一种特殊的文件I/O。

总结

本节课中,我们一起学习了“Hello World”程序完整的生命周期。我们从软件角度分析了它的六个阶段:创建、编译、加载、执行、输出和终止。接着,我们从硬件角度剖析了计算机系统的核心组件——总线、I/O设备、主存和处理器,并跟踪了程序指令和数据在这些组件间的流动。最后,我们介绍了操作系统提供的关键抽象:进程、虚拟内存和文件,这些抽象是构建复杂、安全、高效应用程序的基石。理解这些底层机制,将为我们后续学习系统编程、优化代码和调试程序打下坚实的基础。

007:数据表示与存储

在本节课中,我们将学习计算机系统中数据表示与存储的核心概念。我们将探讨信息如何被编码为二进制,以及不同的编码方式(如整数、浮点数)如何影响程序的运行。理解这些底层原理对于进行系统编程至关重要。


信息存储基础

上一节我们介绍了计算机系统的基本构成。本节中,我们来看看数据在计算机中最基本的存储形式。

计算机是电子机器,而非机械机器。它们利用电信号进行操作,这使得处理速度极快。数据通过晶体管(一种电子开关)的状态来表示,每个开关有两种状态:开(1)或关(0)。这种二进制状态是计算机存储和处理信息的基础。

比特与字节

一个二进制位称为一个比特,它是信息的基本单位。八个比特组成一个字节,字节是计算机中最小的可寻址内存单元。

选择8比特作为一个字节,最初是为了能够编码所有必要的文本字符(如ASCII标准)。一个字节可以表示256种不同的状态(2^8 = 256)。

数据的上下文

相同的二进制序列,根据不同的上下文,可以代表完全不同的含义。例如,二进制序列 01000011 可以解释为:

  • 整数:67
  • 字符:'C'
  • 图像中某个像素的红色分量值
  • 一段声音的特定采样值
  • 一条CPU指令

文件扩展名(如 .txt, .jpg, .mp3)为操作系统和应用程序提供了理解文件内二进制数据所需的上下文。


数值表示与编码

既然所有数据最终都表示为数字,那么理解数值的编码方式就变得尤为重要。我们主要关注三种编码:

  1. 无符号编码:用于表示非负整数。
  2. 补码编码:用于表示可正可负的整数。
  3. 浮点数编码:用于表示实数(包含小数点的数)。

整数是离散值,而浮点数旨在表示连续范围内的值,这涉及到微积分中“无穷小”的概念。


进制转换

为了便于人类理解和操作二进制数据,我们经常需要在不同进制间转换。以下是常见的三种进制:

  • 二进制:基数为2,使用数字0和1。
  • 十进制:基数为10,使用数字0-9。
  • 十六进制:基数为16,使用数字0-9和字母A-F(或a-f)。

在C语言中,我们可以直接使用不同进制的字面量:

  • 十进制:314156
  • 十六进制:0x4CB2C (前缀 0x0X
  • 二进制:0b1101 (某些编译器支持,C标准未正式定义)

进制转换方法

以下是不同进制间转换的核心方法:

十六进制与二进制互转
由于16是2的4次方,转换非常直接。每个十六进制数字对应4个二进制位。

  • 例:0xA3 -> A=1010, 3=0011 -> 10100011

十进制转十六进制
使用“除16取余”法,从下往上读取余数。

314156 ÷ 16 = 19634 ... 余 12 (C)
19634  ÷ 16 = 1227  ... 余 2  (2)
1227   ÷ 16 = 76    ... 余 11 (B)
76     ÷ 16 = 4     ... 余 12 (C)
4      ÷ 16 = 0     ... 余 4  (4)

结果:0x4CB2C

十六进制转十进制
使用乘幂求和法。

  • 例:0x7AF = 7*16^2 + 10*16^1 + 15*16^0 = 7*256 + 10*16 + 15*1 = 1792 + 160 + 15 = 1967

字长与字节顺序

字长

系统的字长决定了其一次性能处理数据的最大位数,也决定了虚拟地址空间的大小。常见的字长有32位和64位。
对于一个 w 位的字长,虚拟地址的范围是从 02^w - 1

字节顺序

对于多字节数据(如 int, double),在内存中存储时,字节的排列顺序有两种约定:

  • 小端序:最低有效字节存储在最低内存地址。
  • 大端序:最高有效字节存储在最低内存地址。

例如,一个32位整数 0x01234567 在内存中的存储方式如下:

内存地址 小端序存储内容 大端序存储内容
0x1000 0x67 0x01
0x1001 0x45 0x23
0x1002 0x23 0x45
0x1003 0x01 0x67

不同的计算机体系结构可能采用不同的字节序,这是在系统间传输数据时需要特别注意的问题。


本节课中,我们一起学习了计算机中数据表示的基础。我们了解了信息如何以二进制比特的形式存储,以及字节作为基本寻址单元的角色。我们探讨了上下文如何赋予二进制序列意义,并介绍了数值的几种关键编码方式。此外,我们还回顾了不同进制(二进制、十进制、十六进制)之间的转换方法,并了解了系统字长和字节顺序(大端序/小端序)的概念。这些知识是理解后续整数运算、浮点数表示等更深入话题的基石。

008:运算符、向量与集合

在本节课中,我们将学习计算机系统中数据的二进制表示,以及如何使用位运算符和逻辑运算符来操作这些二进制数据。我们将探讨字节序、位向量、集合的概念,并通过代码示例展示如何将这些理论应用于实际。


字节序与数据表示

上一节我们介绍了数据在计算机中都以二进制形式表示。本节中,我们来看看数据在内存中存储的具体顺序,即字节序。

任何数据,无论是数值、文本、图像还是音频,最终都会被转换为数值表示。因此,我们可以使用处理数值的原始运算符,以逻辑和有趣的方式来操作这些更复杂的数据类型。

由于所有数据本质上都是数字,理解数字表示是理解系统如何运行的基础。例如,图片可以表示为三个整数值(红、绿、蓝),这依赖于整数编码。声音的振幅则需要能表示正负值的有符号整数编码。

我们之前讨论了字符编码(如ASCII码),它使用一组非负整数来表示字符集中的每个符号。当我们讨论数字编码时,主要关注两个问题:无符号整数和有符号整数的表示。

所有二进制表示都基于数值格式。如果需要更大的编码空间,只需在二进制字符串中添加更多比特位。这就是区分 shortintlong 等数据类型的关键——它们可编码的比特位数不同,从而提供了不同数量的二进制“单词”排列组合。

系统可寻址的最小单位是单个字节。如果要存储一个需要多个字节的单一值(如一个整数),系统必须知道这一点。这就是为什么我们有强类型系统。当你在虚拟地址空间中放入一个整数值时,它会预留一个连续的4字节块来存储该完整二进制字的每个片段。

读取数据时,就引出了字节序的问题:是从最高有效位到最低有效位读取(大端序),还是从最低有效位到最高有效位读取(小端序)。

以下是展示系统字节序的代码示例:

#include <stdio.h>

typedef unsigned char *byte_pointer;

void show_bytes(byte_pointer start, size_t len) {
    size_t i;
    for (i = 0; i < len; i++)
        printf(" %.2x", start[i]);
    printf("\n");
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_4.png)

void show_int(int x) {
    show_bytes((byte_pointer) &x, sizeof(int));
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_6.png)

void show_float(float x) {
    show_bytes((byte_pointer) &x, sizeof(float));
}

void show_pointer(void *x) {
    show_bytes((byte_pointer) &x, sizeof(void *));
}

int main() {
    int ival = 12345;
    float fval = (float) ival;
    int *pval = &ival;
    show_int(ival);
    show_float(fval);
    show_pointer(pval);
    return 0;
}

运行此代码可以显示整数、浮点数和指针的字节序列,从而判断系统使用的是小端序(如输出 39 30 00 00)还是大端序。


不同数据类型的编码

上一节我们看到了数据的字节序。本节中,我们来看看相同数值在不同数据类型(如整数、浮点数、字符串)中是如何被编码成不同二进制序列的。

字符使用单字节表示。字符串可以看作是字符数组,通常以空字符 \0 结尾,作为分隔符。

以下代码比较了相同数字 12345 在整数、浮点数、指针和字符串中的不同编码:

// ...(show_bytes等函数定义同上)

void show_string(char *x) {
    show_bytes((byte_pointer) x, strlen(x)+1); // +1 包含空字符
}

int main() {
    int ival = 12345;
    float fval = 12345.0;
    int *pval = &ival;
    char *sval = "12345";

    show_int(ival);
    show_float(fval);
    show_pointer(pval);
    show_string(sval);
    return 0;
}

字符串 "12345" 的输出(如 31 32 33 34 35 00)对应的是每个字符的ASCII码十六进制值。这说明了字符代码同时具有文本和数值属性。


代码即数据

我们已了解数据在内存中的表示。本节中,我们来看一个关键概念:代码本身在系统中也是以二进制数据的形式存储的。

当C函数被编译后,其机器码会以十六进制表示形式存入内存。系统知道如何将这些十六进制值转换回汇编指令。像 objdump 这样的反汇编工具可以将可执行文件中的十六进制序列转换回汇编语言。

以下是一个简单的C程序及其通过Shell脚本反汇编的示例:

C源文件 (sum.c):

#include <stdio.h>

int sum(int x, int y) {
    return x + y;
}

int main() {
    printf("Sum: %d\n", sum(1, 2));
    return 0;
}

Shell脚本 (disassemble.sh):

#!/bin/bash
# 编译C代码,保留调试信息(-g)
gcc -g -o sum_program sum.c
# 反汇编可执行文件,并搜索sum函数
objdump -d sum_program | grep "<_sum>"

通过 chmod +x disassemble.sh 赋予脚本执行权限,然后运行 ./disassemble.sh,可以看到 sum 函数的汇编指令和对应的十六进制机器码。这证明了代码在系统中与其他数据一样,都是存储在内存中的二进制序列。


位运算符与位向量

我们已经看到数据在底层都是比特序列。本节中,我们来看看用于转换这些比特序列的基本工具:位运算符和逻辑运算符。

位运算符(如 &, |, ^, ~)旨在一次操作一对比特。然而,就像线性代数可以变换整个场一样,位运算符可以应用于整个位向量。前提是两个位向量长度必须相同(可通过零填充实现)。运算符会统一作用于向量中的每一个比特位。

以下是对两个位向量进行各种位运算的示例输出:

A:      11111111
B:      00000001
A|B:    11111111
A&B:    00000001
A^B:    11111110
~A:     00000000

位向量可以用来表示有限集合。我们可以用两种方式看待位向量:

  1. 向量:有序集合,顺序重要,元素可重复。例如二进制字符串 01101001
  2. 集合:无序集合,元素唯一。可以用值为1的比特位的位置索引来表示。例如 01101001 可表示为集合 {0, 3, 5, 6}

集合论中的操作与位运算符有清晰的对应关系:

  • 并集 (Union) -> 按位或 |
  • 交集 (Intersection) -> 按位与 &
  • 补集 (Complement) -> 按位非 ~

这种对应关系非常强大,它允许我们使用更丰富的数学工具(集合论)来理解和操作二进制数据。


应用于复杂数据:图像与声音

位运算符不仅能操作抽象比特,还能处理具体的复杂数据。本节中,我们来看看如何将图像和声音这类抽象数据映射到底层的数值表示,从而用位运算符进行处理。

例如,一个像素的颜色可以用RGB(红、绿、蓝)三个数值表示。假设每个颜色分量只用1比特表示(0关/1开),我们可以组合出8种颜色:

  • 000: 黑
  • 001: 蓝
  • 010: 绿
  • 011: 青
  • 100: 红
  • 101: 品红
  • 110: 黄
  • 111: 白

混合红色(100)和绿色(010)得到黄色(110),这正是按位或(|)操作的结果。这展示了如何用底层比特操作来实现上层的颜色混合。

以下Python代码演示了如何通过直接指定RGB数值来生成图像像素和声音:

生成彩色像素:

import numpy as np
from PIL import Image

# 定义像素颜色 (R, G, B)
red_pixel = [255, 0, 0]
green_pixel = [0, 255, 0]
blue_pixel = [0, 0, 255]
white_pixel = [255, 255, 255]

# 创建图像数组并显示
pixel_array = np.array([red_pixel, green_pixel, blue_pixel, white_pixel], dtype=np.uint8)
img = Image.fromarray(pixel_array.reshape(1, 4, 3), 'RGB')
img.show()

生成简单音调:

import numpy as np
import sounddevice as sd

def generate_tone(frequency, duration, sample_rate=44100):
    t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
    wave = 0.5 * np.sin(2 * np.pi * frequency * t)
    return wave

# 定义音符频率 (Hz)
A4 = 440.00
C5 = 523.25
E5 = 659.25

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_22.png)

# 生成并播放和弦
duration = 2.0
chord = generate_tone(A4, duration) + generate_tone(C5, duration) + generate_tone(E5, duration)
sd.play(chord, samplerate=44100)
sd.wait()

这些例子表明,只要找到数据的数学模型,就可以用数值表示并自动化处理它们。


掩码操作

位运算符的一个实用技巧是掩码。本节中,我们来看看如何使用掩码从位向量中选择或屏蔽特定的比特位。

掩码是一个比特模式,用于“保留”原数据中与之对应的位(通过与操作&),而“屏蔽”或清零其他位。这在需要提取数据的特定部分(如从一个多字节值中取出低字节)时非常有用。

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_24.png)

int main() {
    unsigned int x = 0x89ABCDEF; // 一个多字节值
    unsigned int mask = 0xFF;    // 掩码:保留最低字节
    unsigned int masked_value = x & mask;

    printf("Original: 0x%X\n", x);
    printf("Mask:      0x%X\n", mask);
    printf("Result:    0x%X\n", masked_value); // 输出:0xEF
    return 0;
}

逻辑运算符与移位运算符

最后,我们区分一下逻辑运算符和移位运算符。逻辑运算符(&&, ||, !)将整个表达式求值为布尔值(0或1),不保留位向量的结构,通常用于条件判断。

移位运算符(<<, >>)将比特位向左或向右移动。需要注意的是右移:

  • 逻辑右移:对无符号整数操作,左侧空位补0。
  • 算术右移:对有符号整数操作,左侧空位用符号位(最高位)填充,以保持数值的正负性。

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs467-c-sysprog/img/d8f3398b96f832df1a25f4152f63d6de_32.png)

int main() {
    int x_signed = -100;        // 有符号整数
    unsigned int x_unsigned = 100; // 无符号整数

    printf("Signed -100 >> 2 (算术右移): %d\n", x_signed >> 2);
    printf("Unsigned 100 >> 2 (逻辑右移): %u\n", x_unsigned >> 2);

    // 查看二进制表示(简化示意)
    // 算术右移保留符号,逻辑右移补零
    return 0;
}


总结

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

  1. 数据的二进制本质:所有类型的数据最终都表示为二进制数字。
  2. 字节序:数据在内存中存储的字节顺序(大端序 vs 小端序)。
  3. 代码即数据:可执行程序本身也是以二进制形式存储在内存中。
  4. 位运算符与位向量:使用 &, |, ^, ~ 操作比特序列,以及位向量与集合的对应关系。
  5. 复杂数据的数值表示:图像(RGB)、声音(波形)如何映射为数值,从而能用位级操作处理。
  6. 掩码操作:使用位与运算提取特定位。
  7. 移位运算:区分逻辑移位和算术移位。

理解这些底层概念是进行系统级编程和高效数据操作的基础。下一讲,我们将深入探讨整数(特别是有符号整数)的具体表示方法。

009:整数编码 🧮

在本节课中,我们将要学习计算机系统中整数的两种核心编码方式:无符号编码和二进制补码。理解这些编码是理解数据如何在计算机底层表示和操作的基础。


概述 📋

在之前的课程中,我们了解到所有数据(无论是文本、图像还是声音)最终都会被转换为数值表示,并编码为二进制序列存储在计算机中。本节课,我们将深入探讨如何将整数(即没有小数部分的数字)映射到这些二进制序列上。我们将重点学习两种主要的整数表示方法:无符号整数和二进制补码。


无符号整数编码

上一节我们介绍了数据在计算机中都以二进制序列表示。本节中,我们来看看如何用二进制序列表示非负整数,即无符号整数。

无符号编码是最直观的编码方式,它只包含零和正数。在一个固定宽度(例如8位、16位)的二进制序列中,每一位都代表一个2的幂次方值。

其核心转换公式如下:

对于一个宽度为 w 的位向量 x(表示为 [x_{w-1}, x_{w-2}, ..., x_0]),将其转换为无符号整数的函数 B2U_w 定义为:

B2U_w(x) = Σ_{i=0}^{w-1} x_i * 2^i

以下是该公式在C语言中的一种实现方式:

unsigned int B2U(const char *binary_str, int length) {
    unsigned int value = 0;
    for (int i = 0; i < length; i++) {
        if (binary_str[i] == '1') {
            value += (1U << i); // 将1左移i位,相当于加上2的i次方
        }
    }
    return value;
}

无符号整数的范围

对于一个宽度为 w 位的二进制字符串:

  • 最小可编码值为 0(所有位都为0)。
  • 最大可编码值为 2^w - 1(所有位都为1)。

例如,一个8位(1字节)的无符号整数可以表示的范围是 0 到 255。


二进制补码编码

理解了如何表示非负数后,我们自然需要一种方法来编码负数。这就是二进制补码编码的目的,它允许我们在同一个二进制序列中表示正数、零和负数。

在二进制补码中,最高有效位(最左边的位)被用作符号位。符号位为0表示非负数,为1表示负数。这使得可表示的数值范围大致被平分给了负数和正数(包括零)。

其核心转换公式如下:

对于一个宽度为 w 的位向量 x,将其转换为二进制补码整数的函数 B2T_w 定义为:

B2T_w(x) = -x_{w-1} * 2^{w-1} + Σ_{i=0}^{w-2} x_i * 2^i

以下是该公式在C语言中的一种实现方式:

int B2T(const char *binary_str, int length) {
    int value = 0;
    for (int i = 0; i < length; i++) {
        if (binary_str[i] == '1') {
            if (i == length - 1) {
                // 处理符号位(最高位)
                value -= (1 << i);
            } else {
                // 处理其他位
                value += (1 << i);
            }
        }
    }
    return value;
}

二进制补码的范围

对于一个宽度为 w 位的二进制字符串:

  • 最小可编码值(最负的数)为 -2^{w-1}
  • 最大可编码值(最正的数)为 2^{w-1} - 1

例如,一个8位的二进制补码整数可以表示的范围是 -128 到 127。


编码的转换与上下文

我们已经看到了两种不同的编码方式。一个关键点是:相同的二进制序列,在不同的编码解释下,会得到不同的整数值

例如,二进制序列 10000001

  • 解释为 无符号整数 时,值是 129。
  • 解释为 8位二进制补码 时,值是 -127。

在C语言中,通过类型转换,我们可以改变解释二进制序列的上下文。但这可能导致非预期的结果,因为转换改变的是解读方式,而不是底层比特位。

int main() {
    int signed_val = -12345;
    unsigned int unsigned_val = (unsigned int) signed_val; // 转换:保持比特位不变,改变解释方式
    printf("Signed: %d\n", signed_val);
    printf("Unsigned: %u\n", unsigned_val); // 将输出一个很大的正数
    return 0;
}

因此,在进行混合类型运算或强制类型转换时,必须格外小心。


总结 🎯

本节课中我们一起学习了计算机中整数的两种基本编码方案:

  1. 无符号编码:用于表示零和正整数,其转换基于二进制位的加权求和。
  2. 二进制补码编码:用于表示包含负数、零和正数的整数集,其最高位作为符号位,使得编码范围对称地分布在零的两侧(近似对称)。

理解这两种编码的差异和转换关系至关重要,因为它影响着数据的存储、运算以及我们在C语言中进行类型转换时的行为。它们是理解计算机如何表示和处理信息的基石。在接下来的课程中,我们将继续探讨其他数据类型的编码,如浮点数。

010:整数运算符 🧮

在本节课中,我们将学习整数在计算机中的算术运算,包括加法、减法、乘法和除法。我们将探讨这些运算在固定位宽下的行为,特别是溢出问题,并了解其背后的二进制操作原理。


概述:整数运算的挑战

上一节我们介绍了整数的二进制表示(无符号数和补码)。本节中,我们来看看如何对这些表示进行算术运算。由于计算机使用固定位宽(如32位)存储整数,运算结果可能超出可表示的范围,导致溢出。理解这些行为对于编写健壮的程序至关重要。


无符号加法与溢出

无符号加法是最基础的运算。但当我们对两个w位的无符号数相加时,结果可能需要w+1位来表示。

以下是理解溢出的关键步骤:

  1. 考虑两个4位无符号数的最大值:1111 (15)。
  2. 计算 1111 + 1111,手工二进制加法结果为 11110 (30)。
  3. 这需要5位来存储。但在固定4位系统中,最高位的1会被丢弃,结果回绕为1110 (14)。

在C语言中,溢出不会报错,而是发生回绕。我们可以通过以下代码观察:

#include <stdio.h>
#include <limits.h>

int main() {
    unsigned int max_unsigned = UINT_MAX;
    printf("Max unsigned int: %u\n", max_unsigned);
    printf("Max unsigned int + 1 (overflow): %u\n", max_unsigned + 1);
    return 0;
}

运行后,UINT_MAX + 1 的结果将是 0

溢出检测:对于无符号加法,如果 x + y < x,则发生了溢出。


有符号加法与溢出

对于补码表示的有符号数,溢出可能发生在两个方向:正溢出(结果太大)和负溢出(结果太小)。

以下是两种溢出情况的示例:

  1. 正溢出:最大的正数加1会变成最大的负数。
  2. 负溢出:最小的负数减1会变成最大的正数。

#include <stdio.h>
#include <limits.h>

int main() {
    int max_int = INT_MAX;
    int min_int = INT_MIN;

    printf("Max int: %d\n", max_int);
    printf("Max int + 1 (positive overflow): %d\n", max_int + 1);

    printf("Min int: %d\n", min_int);
    printf("Min int - 1 (negative overflow): %d\n", min_int - 1);

    return 0;
}

溢出检测

  • 正溢出检测:如果两个正数相加得到负数 (x > 0 && y > 0 && x + y < 0)。
  • 负溢出检测:如果两个负数相加得到正数 (x < 0 && y < 0 && x + y >= 0)。


补码取反

将一个补码数转换为它的相反数(如+5变-5),需要两个步骤:

  1. 按位取反:将所有比特位翻转(0变1,1变0)。
  2. 加1:对取反后的结果加1。

公式表示为: -x = ~x + 1

例如,用4位表示 3 (0011):

  1. 按位取反:1100
  2. 加1:1101 (即 -3 的补码表示)

特殊案例:对 INT_MIN 取反,由于其不对称性(例如8位时-128没有对应的+128),结果仍是它自身。


乘法运算

乘法可以分解为移位和加法的组合,这比重复相加高效得多。这与我们手工进行十进制乘法的原理类似。

考虑计算 11 (1011) * 6 (0110)

  1. 检查乘数 0110 的每个位。
  2. 如果某位是1,则将被乘数 1011 左移相应的位数,并累加到结果中。
    • 第0位是0:忽略。
    • 第1位是1:1011 << 1 = 10110,累加。
    • 第2位是1:1011 << 2 = 101100,累加。
    • 第3位是0:忽略。
  3. 将所有移位后的结果相加:10110 + 101100 = 1000010 (即66)。

在代码中,乘法可以通过循环和条件移位实现:

int multiply(int x, int y) {
    int result = 0;
    for (int i = 0; i < sizeof(int) * 8; i++) {
        if ((y >> i) & 1) { // 检查y的第i位是否为1
            result += x << i; // 将x左移i位并累加
        }
    }
    return result;
}

乘法也容易导致溢出,且由于结果增长更快,溢出的可能性比加法更大。


除法运算

除法与乘法相反,通常通过右移来实现。对于无符号数,直接使用逻辑右移(>>)即可。

然而,对于有符号数(补码),需要特别注意:

  • 对于正数,算术右移(高位补0)等同于向下取整的除法。
  • 对于负数,算术右移(高位补1)也是向下取整,但这可能不是我们想要的结果(例如 -5 / 2 希望得到 -2,但 -5 >> 1 可能得到 -3)。

因此,为了实现“向零取整”的标准整数除法,需要对负数进行校正:

int divide_round_to_zero(int x, int k) {
    int is_negative = x < 0;
    int shifted = x >> k; // 算术右移k位
    // 如果是负数且不能被2^k整除,则需要向上调整(加1)
    if (is_negative && (x & ((1 << k) - 1)) != 0) {
        shifted += 1;
    }
    return shifted;
}

本质上,x / (2^k) 可以近似为 (x < 0 ? x + (1<<k) - 1 : x) >> k


总结

本节课中我们一起学习了整数运算的核心机制:

  1. 加法和减法:可能发生溢出,无符号数回绕到0,有符号数则在正负极值间回绕。
  2. 补码取反:遵循 -x = ~x + 1 的规则。
  3. 乘法:在硬件层面被优化为移位和加法的组合,效率很高。
  4. 除法:通过右移实现,但对负数需要特别处理以实现向零取整。

理解这些底层原理有助于我们预测代码行为,避免因溢出和取整问题导致的隐蔽错误。下一节,我们将探讨如何表示和运算非整数——浮点数。

011:数据实验教程

概述

在本节课中,我们将学习加州理工学院C语言系统编程课程的第11讲内容,即“数据实验”。本实验旨在通过一系列编程谜题,加深对整数位级表示、布尔代数以及位运算的理解。我们将介绍实验的目标、文件结构、三类谜题(逻辑、算术、位操作)以及如何测试和提交代码。


实验目标与核心概念

上一节我们概述了课程内容,本节中我们来看看数据实验的具体目标。

数据实验的核心是挑战你对过去几周所学概念的理解,特别是整型数据的位级表示。实验要求你使用受限的C语言运算符,解决一系列编程谜题。

核心概念包括:

  • 位向量:一系列比特的表示。
  • 布尔代数:对位向量执行逻辑运算(与、或、非、异或)。
  • 整数表示:无符号整数和补码有符号整数的不同表示方式。
  • 位运算:使用C语言的位运算符(如 &, |, ~, ^, <<, >>)来操作数据。

实验的目的是让你在实践中熟悉这些概念,而不仅仅是理论学习。


实验环境与文件准备

了解了实验目标后,我们需要准备实验环境。

你需要使用课程提供的Web终端来访问系统实验室,以完成本实验。请使用你的学校凭证登录。Web终端提供了一个完整的Ubuntu GUI环境,你可以使用shell或内置浏览器(如Chromium或Firefox)来访问Autolab或GitHub以获取实验材料。

所有实验材料都存放在课程GitHub仓库中。你需要下载 datalab-handout.tar 文件,并在系统实验室中解压。

解压后,你将看到以下主要文件:

  • README:实验说明文档。
  • bits.c:你需要编辑和实现所有函数的核心C源文件。
  • btest, dlc, driver.pl:用于测试和检查代码正确性的工具。
  • Makefile:用于编译项目的文件。

实验规则与约束

在开始解题之前,必须清楚了解实验规则。

对于整数谜题,你必须使用直线代码。这意味着:

  • 不允许使用任何控制流结构(如 if, switch, for, while)。
  • 只能使用一组受限的原始运算符。

以下是允许的运算符列表:

  • 逻辑非:!
  • 按位与:&
  • 按位或:|
  • 按位异或:^
  • 加法:+
  • 左移:<<
  • 右移:>>
  • 取反:~

此外,还有一个重要限制:

  • 你使用的常数不得超过8位(即一个字节)。这意味着如果你需要构造一个超过8位的位模式,必须通过组合多个8位常数和运算符来实现。

谜题类型详解

现在,让我们深入了解你需要解决的三类谜题。每个列表前会简要介绍其特点。

1. 逻辑谜题

这类谜题主要涉及布尔逻辑操作,用于评估或产生布尔值(真/假,1/0),或者在不改变数值解释的情况下操作位。

以下是逻辑谜题的函数列表:

  • bitOr(x, y):使用仅 &~ 实现 | 操作。
  • bitAnd(x, y):使用仅 |~ 实现 & 操作。
  • isNotEqual(x, y):如果x不等于y则返回1,否则返回0。
  • copyLSB(x):将结果的所有位设置为x的最低有效位。
  • specialBits():返回特定的位模式 0xFFCA3F00。这是需要组合多个8位常数的挑战。
  • conditional(x, y, z):实现条件运算符,若x为真(非零)则返回y,否则返回z。
  • bitParity(x):如果x包含奇数个0,则返回1。

每个问题都有对应的分值和一个最大操作数限制。你必须找到一种在操作数限制内实现功能的方法。

2. 算术谜题

这类谜题涉及数字的算术属性,如加法、减法、求最大值、取反和判断符号等。这些操作会改变位的数值解释。

以下是算术谜题的函数列表:

  • minusOne():返回数值-1。
  • tmax():返回最大的补码整数。
  • negate(x):返回-x,不能使用 - 运算符。
  • isNegative(x):如果x < 0则返回1,否则返回0。
  • isPositive(x):如果x > 0则返回1,否则返回0。
  • bang(x):计算 !x,不能使用 ! 运算符。
  • addOK(x, y):判断 x+y 是否会发生溢出。
  • absVal(x):计算x的绝对值。

3. 位操作谜题(附加题)

这类谜题主要涉及操作数字内的单个位或位组,例如计算置位位数、交换字节或进行逻辑移位。

以下是位操作谜题的函数列表:

  • bitSwap(x, n, m):交换x的第n个和第m个字节。
  • bitCount(x):返回x中值为1的位的数量。
  • logicalShift(x, n):将x逻辑右移n位。

测试、评分与提交

实现了所有函数后,你需要测试其正确性并准备提交。

你必须在代码中添加详细的注释,解释你的实现逻辑。评分将基于:

  1. 通过所有自动化测试。
  2. 仅使用合法的运算符。
  3. 满足最大操作数限制。
  4. 提供清晰的解释性注释。

以下是用于测试和检查的工具:

  • btest:编译并运行 bits.c 中的函数,检查其输出是否正确。你可以测试所有函数或指定单个函数。
    make
    ./btest
    ./btest -f bitOr
    ./btest -f bitOr -1 7 -2 5
    
  • dlc:代码规则检查器。它会静默运行,除非检测到非法运算符、操作数超限或非直线代码。
    ./dlc bits.c
    
  • driver.pl:一个Perl脚本,可以同时运行 btestdlc,并给出综合评分报告。
    ./driver.pl
    

提交指南:你只需要提交最终的 bits.c 源文件。登录Autolab,导航到CSCI 2467课程的Data Lab作业,上传该文件即可。Autolab设有积分榜,你可以查看自己与其他同学的排名。

测试建议:在开发过程中可以使用 printf 调试,但在使用 btest 或提交前务必移除或注释掉所有打印语句,以免干扰评分脚本。


总结

本节课中我们一起学习了“数据实验”的完整流程。我们了解了实验的目标是掌握位级表示和位运算,熟悉了实验环境与文件结构,详细分析逻辑、算术和位操作三类谜题的要求,并掌握了使用 btestdlc 等工具进行测试和验证的方法,最后明确了代码注释和提交的规范。

现在,你可以开始分组协作,动手解决这些编程谜题了。如果在某个问题上遇到普遍困难,可以在下节课前达成共识,我们将一起探讨该问题的解决思路。

012:浮点数(第一部分)📚

在本节课中,我们将要学习计算机中浮点数的表示方法。我们将从整数表示过渡到浮点数,理解其核心概念、二进制表示以及IEEE 754标准。浮点数使我们能够表示非常大或非常小的数值,但同时也是一种近似表示。


从整数到浮点数 🔄

上一节我们介绍了整数数据集的表示,整数是可计数的值,从一个单位到下一个单位之间有离散的步长。然而,整数无法定义集合中两个值之间的空间量。

浮点数则为我们提供了这种能力,允许我们表示实数之间无限小的值。但需要记住,任何以小数或浮点数形式表示的值,在数学上始终是一种近似。例如,0.999... 无限循环等于1的证明,就说明了将小数映射到整数集时,这种近似是固有的。


浮点数的二进制表示 💡

为了将浮点数编码为二进制序列,我们使用一种类似于科学计数法的方法。一个浮点数值 V 可以定义为:

V = M × 2ᴱ

其中:

  • M尾数,它决定了数字的精度和大小。
  • E指数,它通过2的幂次来缩放数字,决定其数量级。

浮点数非常适合表示非常大的数和非常接近零的数,但它始终是对实数运算的近似。


二进制小数详解 🧮

在二进制中,我们使用二进制点(类似于十进制中的小数点)。二进制点左边的位表示整数部分(2⁰, 2¹, 2²...),右边的位表示小数部分(2⁻¹, 2⁻², 2⁻³...)。

以下是一个二进制小数的例子:

101.11₂

我们可以将其计算为:

  • 左边:1×2² + 0×2¹ + 1×2⁰ = 4 + 0 + 1 = 5
  • 右边:1×2⁻¹ + 1×2⁻² = 0.5 + 0.25 = 0.75
  • 总和:5.75

移动二进制点会改变数值:

  • 左移一位相当于除以2。
  • 右移一位相当于乘以2。


近似与局限性 ⚠️

分数值本质上是近似的,这在十进制和二进制中都是如此。有些数在一种进制中可以精确表示,在另一种进制中却不行。

例如:

  • 1/3 在十进制中无法精确表示(0.333...)。
  • 1/5 在十进制中可以精确表示为 0.2,但在二进制中却无法精确表示,只能近似。

这就是为什么我们永远不应该对浮点数进行相等性比较,因为近似表示可能导致微小的误差。


IEEE 754 浮点标准 🏆

之前的位置表示法在表示极大或极小数时效率低下。IEEE 754标准通过更高效的编码方式解决了这个问题。

该标准将浮点数表示为三个字段的组合:

  1. 符号位 (S):决定正负(0为正,1为负)。
  2. 指数域 (Exp):编码指数 E,使用偏置形式表示。
  3. 小数域 (Frac):编码尾数 M 的小数部分。

单精度(32位)和双精度(64位)的位分配如下:

精度 符号位 指数域 小数域 总位数
单精度 1 8 23 32
双精度 1 11 52 64

默认应使用双精度,因为它提供更高的精度,能减少近似带来的误差。如果可能,应优先使用整数建模,避免使用浮点数。


数值的三种情况 📊

根据指数域的值,浮点数分为三种情况:

1. 规格化值(最常见)

  • 条件:指数域既不全为0,也不全为1。
  • 指数E = e - Bias。其中 e 是指数域的无符号整数,Bias 是偏置值(单精度为127,双精度为1023)。
  • 尾数M = 1 + f。其中 f 是小数域的值(0 ≤ f < 1),隐含一个前导1。

2. 非规格化值

  • 条件:指数域全为0。
  • 指数E = 1 - Bias(此时指数为固定值,使得数值非常接近0)。
  • 尾数M = f(没有隐含的前导1,用于表示0和接近0的数)。
  • 作用:提供渐进下溢,使数值在0附近均匀分布。

3. 特殊值

  • 条件:指数域全为1。
  • 表示
    • 小数域全为0:表示无穷大±∞,由符号位决定)。
    • 小数域非全为0:表示非数NaN,Not a Number)。


可视化理解 🎨

想象一条数轴,浮点数能表示的值不是连续分布的,而是像散点一样分布:

  • 在0附近(非规格化区域),点分布得非常密集。
  • 随着数值增大(规格化区域),点与点之间的间隔(“空洞”)会越来越大。

这是因为规格化值是通过 M × 2ᴱ 来定义的,当指数 E 增大时,相同的尾数变化 f 会导致最终数值的跳跃变大。引入偏置 Bias 正是为了优化指数值的表示范围,使其能同时高效地表示很小和很大的数。


总结 ✨

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

  1. 浮点数的概念:它是对实数的近似表示,用于处理极大、极小或带有小数的数值。
  2. 二进制小数:理解了二进制点的概念及如何计算二进制小数的值。
  3. IEEE 754标准:掌握了浮点数在计算机中的存储格式,包括符号位、指数域和小数域。
  4. 三种数值类型:区分了规格化值、非规格化值和特殊值(无穷大、NaN)及其编码方式。
  5. 核心公式:浮点数值的计算公式 V = (-1)ˢ × M × 2ᴱ
  6. 重要原则:应优先使用双精度浮点数,并避免对浮点数进行相等比较。

理解浮点数的表示方式和局限性,对于编写正确、稳定的数值计算程序至关重要。

013:浮点数(第二部分) 🧮

在本节课中,我们将继续深入探讨IEEE浮点数表示法。我们将通过分析微小的浮点数示例,来理解这种表示法如何允许我们编码极大或极小的数字,同时也会揭示其固有的近似性本质及其带来的潜在问题。

上一节我们介绍了浮点数的基本构成,本节中我们来看看具体的编码示例及其含义。

浮点数的三种情况

浮点数通常分为三种情况处理:

  • 非规格化值:用于表示非常接近零的数值。
  • 规格化值:用于表示距离零较远的常规数值。
  • 特殊值:用于表示无穷大、未定义或非数字(NaN)等情况。

微小的浮点数示例

为了清晰地展示浮点数的表示范围和近似性,我们将使用比特数远少于标准单精度(32位)或双精度(64位)的编码方案,例如8位、6位甚至4位编码。

8位浮点数示例

我们首先定义一个8位的“微型浮点数”格式:1位符号位(s),4位指数域(exp),3位小数域(frac)。

以下C代码片段定义了这种格式并遍历所有可能的值:

// 示例:定义并遍历8位微型浮点数的所有可能值
void print_tiny_float_8bit() {
    // 格式: s (1位) | exp (4位) | frac (3位)
    for (unsigned int i = 0; i < 256; i++) {
        // 解析符号位、指数域、小数域
        int s = (i >> 7) & 1;
        int exp = (i >> 3) & 0xF; // 4位指数
        int frac = i & 0x7;       // 3位小数
        // ... 计算并打印对应的数值表示
    }
}

运行此代码会生成一个所有可能值的穷举列表。观察列表可以发现:

  • 数值在数轴上的分布是不均匀的。
  • 在零附近(非规格化区域),数值分布非常密集。
  • 随着数值增大(规格化区域),数值之间的“间隔”或“空洞”变得越来越大。
  • 例如,在某个区间,可能直接从-224跳转到-240,中间没有其他可表示的值。

这直观地展示了浮点数是近似表示。当进行加法等运算时,如果精确结果落在两个可表示值之间,就必须通过舍入来匹配最接近的可表示值。

更小的编码:6位、5位和4位示例

通过进一步减少比特数,我们可以更清晰地观察这种模式。

以下是不同微型浮点数格式的总结:

  • 6位格式 (s:1, exp:3, frac:2):可表示的值更少,非规格化值距离零更远,规格化值的最大范围显著缩小(例如-14到14)。
  • 5位格式 (s:1, exp:2, frac:2):这是教材中的一个例子。其所有可表示值(如0, 0.25, 0.5, 0.75, 1, 1.25, ... 最大到3.5)可以清晰地绘制在数轴上,显示出在零附近的密集聚类和向两端扩散时增大的间隔。
  • 4位格式 (s:1, exp:2, frac:1):这是可调查的极限。穷举列表显示只有少数几个值:0, ±0.5, ±1, ±1.5, ±2, ±3,以及无穷大。任何数学运算的结果都必须映射到这个有限的集合中。

这些微型示例的核心启示是:使用有限比特表示无限实数集必然导致近似和间隔。这解释了为什么在需要高精度的场合(如金融计算),应优先考虑使用整数类型(例如以“分”而不是“元”为单位存储金额),并说明了为何在比较浮点数时应使用误差容限而非直接相等。

浮点运算与舍入

由于浮点数表示存在“空洞”,算术运算(加、减、乘、除)的结果可能无法精确表示,因此舍入是浮点运算不可或缺的一部分。

基本运算步骤为:

  1. 计算数学上的精确结果。
  2. 调整以适应目标精度(可能涉及指数溢出检查)。
  3. 将结果舍入到最近的可表示值。

舍入模式

以下是几种常见的舍入模式及其在处理1.5和2.5等中间值时的行为:

舍入模式 1.4 1.6 1.5 2.5 -1.5
向偶数舍入 1 2 2 2 -2
向零舍入 1 1 1 2 -1
向下舍入 1 1 1 2 -2
向上舍入 2 2 2 3 -1

IEEE标准默认采用“向偶数舍入”。这种模式在统计上更优,因为它避免了持续向上或向下舍入带来的系统性偏差。当值恰好位于两个可表示值的正中间时,它选择舍入到最近的偶数。

加法与乘法示例

让我们在5位微型浮点数格式上观察运算过程。

加法示例:计算 1.25 + 2.5

  • 数学精确结果:3.75
  • 查看5位格式的可表示值列表:... 3.5, 4.0 ...
  • 3.75并不在列表中。应用舍入后,结果被映射到最接近的可表示值 3.5

乘法示例:计算 0.5 * 2.5

  • 数学精确结果:1.25
  • 1.25恰好是5位格式中的一个可表示值,因此结果就是 1.25

这些例子表明,即使初始操作数是可精确表示的,运算结果也可能不可表示,必须经过舍入。

总结

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

  1. 浮点数的表示局限:通过分析8位、6位、5位和4位等微型浮点数格式,我们直观地看到了浮点数在数轴上的分布是不均匀的,存在“间隔”,这导致了其近似表示的本质。
  2. 舍入的必要性与模式:由于上述间隔,浮点算术运算的结果常常需要舍入。我们介绍了向偶数舍入、向零舍入等不同模式,并理解了IEEE标准采用向偶数舍入的原因是为了减少统计偏差。
  3. 实践启示:理解浮点数的这些特性对于编写可靠的程序至关重要。它解释了为什么在需要精确计算的领域(如金融)应避免使用浮点数,而应使用整数类型;也说明了为什么在比较浮点数时应该检查两者之差是否在某个很小的误差范围内,而不是直接使用==操作符。

对数据底层表示的理解,能帮助我们做出更明智的编程决策,选择最适合的数据类型来解决问题。

014:汇编程序设计入门 🖥️

在本节课中,我们将学习计算机系统中程序的机器级表示。我们将从宏观的计算机系统概览开始,逐步深入到软件和硬件视角,理解从源代码编写到编译、链接、汇编,最终生成机器代码并执行的完整流程。本章将帮助我们更好地理解程序在机器层面的表示,从而成为更高效的程序员。

章节概览 📚

上一节我们介绍了数据的二进制表示。本节中,我们将进入第三章,探讨程序的机器级表示。本章将回顾我们在系统概览和第一个实验(实验零)中已经接触过的一些概念,例如编译、链接和汇编的过程。

学习目标 🎯

我们的学习目标包括:

  • 学习如何阅读由C编译器生成的x86-64机器代码。
  • 了解不同控制结构(如条件语句、循环和switch语句)生成的基本指令模式。
  • 掌握过程的实现,包括栈分配、寄存器使用惯例和参数传递。
  • 理解如何在机器层面分配和访问不同的数据结构,如结构体、联合体和数组。
  • 学习实现整数和浮点数算术运算的指令。
  • 利用程序的机器级视图来理解常见的代码安全漏洞,例如缓冲区溢出攻击,以及程序员、编译器或操作系统可以采取的缓解措施。

理解程序在机器上的表示方式,有助于我们在实现功能时做出更优的决策。

课程内容与教材 📖

本课程的幻灯片内容主要源自教材,并以略有不同的方式进行阐述。这本教材编写精良,非常适合学生自学。它用循序渐进的方式和通俗的语言解释概念。因此,我强烈建议大家在有时间的情况下阅读教材,这将极大地巩固你的知识。

同时,我会在课程中补充C代码示例,以便更直观地展示教材中的表格和概念。幻灯片的内容安排将遵循教材的章节顺序。

机器级表示的重要性 🔍

关于机器代码和汇编代码,了解一点历史背景是有价值的。因为当前指令集架构中的许多关键字和寄存器的命名,都源于其历史演变。例如,寄存器为何被定义为AL、AH、AX、EAX或RAX等。理解这些有助于我们更好地掌握当前的体系结构。

我们将要探讨的主题包括:

  1. 程序作为机器代码的各种编码表示。
  2. 机器级别可用的数据格式。
  3. 如何从机器访问信息。
  4. 对信息进行算术和逻辑运算(因为一切本质上都是数值)。
  5. 控制结构和过程(即机器码和汇编代码中的函数)。
  6. 数组的分配与访问。
  7. 异构数据结构(我们已在之前的C代码示例中有所接触)。

对于教材中与我们在课程初期学习的“C语言精华”PDF重叠的部分,我们会快速带过,避免重复讲解。

理论与实践结合 🛠️

本章的一个重要目的是教会大家使用调试器。我们将学习如何识别代码,并利用调试器逐步执行实际在系统上运行的代码,以探查其行为。

我的目标是,在讲解完本章的理论概念后,将其与如何在现实世界中实际应用这些概念结合起来。因此,在接下来的几节课中覆盖完必要内容后,我计划在课堂上进行更多基于实验的研讨会。

我可能会尝试“翻转课堂”的模式,即在接下来的几次课中,我们进行更多实践性的实验工作,同时鼓励大家课后阅读教材,以掌握更通用的概念,从而更好地理解我们在课堂上所做的具体应用。

课程安排与总结 📅

本节课我们为第三章的学习奠定了基础。我的目标是,如果可能的话,在下周一(下一次课)覆盖完第三章所需的所有内容,然后在周三的课堂上开始进行“炸弹实验”。最坏的情况是,我们下周整周学习第三章,然后在再下一周进行炸弹实验。具体进度取决于我们消化内容的速度。

总而言之,本节课我们一起学习了第三章——程序机器级表示的概览和学习目标。我们了解了从历史背景到具体技术主题的路线图,并明确了将理论知识与实践技能(特别是调试技能)相结合的学习方向。掌握这些知识将使我们能够更深入地理解计算机系统的工作方式,并编写出更安全、更高效的代码。

015:编码与格式化

概述

在本节课中,我们将学习机器代码和汇编语言的基础知识。我们将探讨高级C语言代码如何被编译成机器可执行的二进制指令,理解汇编语言作为中间抽象层的作用,并回顾处理器架构(特别是x86-64)的历史演变如何塑造了今天的编程模型。本节课的核心目标是建立从二进制数据表示到实际机器操作的桥梁。


从数据表示到机器操作

上一节我们深入探讨了整数和浮点数的二进制表示。本节中,我们来看看这些二进制数据如何在计算机系统中被实际使用和操作。

计算机系统需要能够操纵数据管理内存处理输入/输出以及进行网络通信。为了实现这些功能,高级语言(如C、Java)编写的代码需要被翻译成机器能够直接理解的指令序列。

这个翻译工作由编译器完成。编译器是一个高度专业化的软件,它的一端是编程语言的语法规则,另一端是目标机器的指令集架构。它负责将高级代码转换为低级的机器代码

机器代码是由编码的字节序列组成的,代表了CPU的低级操作。这些字节序列可以被加载到内存中,由CPU按地址读取和执行。


高级语言与汇编语言的优势

以下是高级语言相对于直接编写汇编或机器代码的主要优势:

  • 开发效率:用更少的代码完成更多工作,更容易编写和阅读。
  • 错误检测:编译器能在编译时发现许多语法和类型错误。
  • 可移植性:同一份高级代码(尤其是Java这类运行在虚拟机上的语言)可以在不同硬件架构上运行。
  • 编译器优化:现代编译器能够自动优化代码,使其运行得更快、更高效。

编译器优化可以通过标志位控制。例如,在GCC中:

  • -Og:为调试生成代码,优化程度最低,保留函数名等可读信息。
  • -O1:进行适度优化。
  • -O2:进行更激进的优化。

选择优化级别取决于项目需求,例如调试时需要-Og,而发布时需要-O2以获得最佳性能。


学习机器代码的动机

理解机器代码对于成为一名资深的开发者至关重要。其意义在于:

  • 理解编译器优化:明白代码如何被优化,从而编写出更高效的源代码。
  • 分析运行时行为:深入理解程序在底层是如何执行的。
  • 识别安全漏洞:许多安全漏洞(如缓冲区溢出)只有在底层代码层面才能被清晰理解。
  • 高效调试:当高级调试工具失效时,能够阅读反汇编代码是定位复杂Bug的关键技能。

在实际工作中,我们更多是阅读编译器生成的汇编代码,而非手动编写。这种能力在调试、性能分析和逆向工程中非常有用。


主流机器语言与历史背景

当前主流的指令集是x86-64,它广泛应用于个人电脑和数据中心服务器。其历史演变如下:

  1. 1978年 - 8086:16位处理器,是IBM PC和MS-DOS的基础。
  2. 1985年 - 80386:升级到32位架构,地址空间翻倍。
  3. 1989年 - 80486:集成浮点运算单元(FPU)。
  4. 1993年 - Pentium系列:引入新的指令集。
  5. 2006年 - Core系列:引入多核、超线程等现代技术。

一个关键特性是向后兼容。现代的64位处理器依然可以运行为早期16位或32位架构编写的程序,这也导致了一些历史遗留的设计特征。

同时,AMD也是该领域的重要参与者,正是AMD率先提出了x86-64(也称AMD64)指令集,后来被Intel采纳。


编译过程详解

从C源代码到可执行文件的完整编译过程分为多个阶段:

  1. 预处理

    • 任务:处理#include#define等预处理指令,进行宏替换。
    • 输入.c源文件。
    • 输出:经过展开的.i文件(仍是C代码)。
  2. 编译

    • 任务:将预处理后的C代码转换为汇编代码。进行语法/语义检查,并根据优化级别进行代码优化。
    • 输入.i文件。
    • 输出.s汇编文件。
  3. 汇编

    • 任务:将人类可读的汇编代码翻译成机器指令(二进制代码),并解析符号名。
    • 输入.s文件。
    • 输出.o目标文件(Object File)。
  4. 链接

    • 任务:将一个或多个目标文件以及所需的库文件合并,解析它们之间的外部引用(如函数调用),生成最终的可执行文件。
    • 输入:一个或多个.o文件。
    • 输出:可执行文件(如a.out)。

目标文件可执行文件都是机器代码,但关键区别在于:可执行文件中的所有符号和内存地址都已被解析和固定,可以被操作系统直接加载执行。


机器级编程的抽象

机器代码和汇编代码暴露了两个核心的硬件抽象:

  1. 处理器状态

    • 指令集架构定义。
    • 主要包括:
      • 程序计数器:存放下一条要执行的指令的地址。
      • 整数寄存器文件:16个命名的存储位置,用于存放整数数据或指针。
      • 条件码寄存器:保存最近算术或逻辑操作的状态(如是否为负、是否溢出)。
      • 向量寄存器:用于存放多个整数或浮点数(SIMD操作)。
  2. 内存模型

    • 内存被视为一个巨大的、按字节寻址的数组。
    • 程序内存包含:机器代码、操作系统数据、运行时栈、用户分配的堆内存块等。
    • 操作系统通过虚拟地址空间管理内存,为每个进程提供独立的内存视图。

汇编代码是机器指令的文本表示,它暴露了一些在高级语言中隐藏的细节,例如程序计数器(在x86-64中称为%rip)。


数据格式与操作数大小

x86架构的数据类型命名与其历史发展紧密相关:

  • 字节:8位。
  • :16位(源于最初的16位8086处理器)。
  • 双字:32位。
  • 四字:64位。

在汇编指令中,通常通过在指令后添加后缀来指定操作数大小:

  • movb:移动一个字节
  • movw:移动一个
  • movl:移动一个双字
  • movq:移动一个四字

这告诉处理器应该对多少位的数据进行操作。


寄存器演变与访问

x86-64有16个通用目的寄存器,它们的名称体现了从16位到64位的演变:

  • 16位时代:%ax, %bx, %cx, %dx...
  • 扩展到32位:%eax, %ebx, %ecx, %edx...(e代表扩展)
  • 扩展到64位:%rax, %rbx, %rcx, %rdx...(r可能代表寄存器)

对于早期寄存器,仍可访问其子部分:

  • %rax(64位) -> %eax(低32位) -> %ax(低16位) -> %ah(高8位)& %al(低8位)

一个重要规则是:在x86-64上,对32位寄存器(如%eax)进行操作会自动将高32位清零。这是一个设计约定。


关键寄存器用途

  • 栈指针%rsp,指向当前栈帧的顶部,用于管理函数调用时的局部变量和保存寄存器。
  • 其他通用寄存器:用于存储临时数据、函数参数、返回值等。
  • 特定用途:某些指令会隐含使用特定寄存器。例如,乘法指令可能使用%rax%rdx来存放结果。

寄存器使用约定对于函数调用至关重要:

  • 前6个整数参数通过%rdi, %rsi, %rdx, %rcx, %r8, %r9传递。
  • 返回值通常存放在%rax中。
  • 调用函数时,调用者需要保存某些寄存器的值(调用者保存寄存器),而被调用函数需要保存另一些寄存器的值(被调用者保存寄存器)。

总结

本节课中,我们一起学习了机器代码和汇编语言的基础。我们理解了高级语言代码如何通过编译、汇编、链接等步骤转化为可执行的机器指令。我们回顾了x86-64指令集的历史演变,看到了数据格式、寄存器命名与历史的关联。我们还探讨了处理器状态和内存模型这两个关键的机器级抽象。掌握这些知识,为我们后续深入理解程序在硬件上的实际执行过程、进行底层调试和性能分析奠定了坚实的基础。下一节,我们将更具体地研究操作数和寻址模式。

016:数据运算符与控制逻辑 🖥️

在本节课中,我们将学习汇编语言中的数据运算符与控制逻辑。我们将探讨如何通过机器码和汇编指令来移动数据、执行算术与逻辑运算,以及如何实现程序的控制流。理解这些底层概念对于掌握计算机系统如何从简单的二进制表示构建出复杂的软件至关重要。

数据操作符与操作数类型 🔢

上一节我们介绍了机器码的基本概念,本节中我们来看看指令操作的具体对象——操作数。操作符(如加法、移动)通常需要操作数,你可以将其理解为函数的参数。在机器码层面,我们关心的数据类型主要与数据的存储位置有关。

操作数类型主要有三种:

  • 立即数:这是一个常量值。在汇编中通常以美元符号 $ 开头表示。例如:$0x123。立即数只能作为源操作数(读取),不能作为目的操作数(写入)。
  • 寄存器:这是CPU寄存器空间中的位置。在汇编中以百分号 % 开头表示。例如:%rax。寄存器既可以作为源操作数,也可以作为目的操作数。
  • 内存引用:这是基于有效内存地址的引用,允许对虚拟内存进行读写。在汇编中通常用括号表示,例如 (%rax)

寻址模式 🗺️

既然我们提到了数据在系统中的位置(立即数、寄存器、内存),现在让我们看看系统如何识别和访问这些位置,即寻址模式。

通用寻址模式的格式如下:
偏移量(基址寄存器, 索引寄存器, 比例因子)

其计算有效地址的公式为:
有效地址 = 偏移量 + 基址寄存器值 + (索引寄存器值 * 比例因子)

以下是各部分的解释:

  • 偏移量:一个整型常数。
  • 基址寄存器:内存地址的起始点。
  • 索引寄存器:从基址开始的偏移量。
  • 比例因子:必须是1、2、4或8,代表数据类型的字节大小,用于在访问数组等连续数据时跳过相应字节。

这种格式非常灵活,支持我们以各种方式访问系统中的任意位置来提取二进制表示。

数据传送指令 📤

理解了如何寻址后,我们来看看汇编中最基本的操作之一:移动数据。mov 指令族用于在立即数、寄存器和内存之间传送数据。

mov 指令根据操作数大小有不同的变体:

  • movb:传送1个字节(8位)。
  • movw:传送2个字节(一个字,16位)。
  • movl:传送4个字节(双字,32位)。
  • movq:传送8个字节(四字,64位)。

指令格式为 movX 源操作数, 目的操作数。源操作数可以是立即数、寄存器或内存地址。目的操作数只能是寄存器或内存地址。一个重要限制是:不能直接在两个内存位置之间传送数据,必须通过寄存器中转。

以下是几个 mov 指令的例子:

movl $0x123, %eax       # 立即数 -> 寄存器
movq %rax, %rdx         # 寄存器 -> 寄存器
movb (%rdi, %rcx), %al  # 内存 -> 寄存器
movb $0xFF, (%rax)      # 立即数 -> 内存
movl %eax, -12(%rbp)    # 寄存器 -> 内存(带偏移)

对于将较小数据传送到较大空间的情况,有符号扩展和零扩展指令:

  • movz:零扩展,用零填充未写入的高位。
  • movs:符号扩展,根据源操作数的符号位(正数为0,负数为1)填充高位。
    一个特殊的符号扩展指令是 cltq,它高效地将 %eax 中的32位值符号扩展到 %rax 中的64位。

栈数据操作 ⬇️⬆️

除了寄存器和内存,程序栈是另一个关键的数据管理区域。栈是一种后进先出(LIFO)的数据结构。在x86-64中,程序栈位于由操作系统分配的特定内存区域,栈顶由 %rsp(栈指针)寄存器指示,栈向低地址方向增长。

主要的栈操作指令是 pushpop

  • pushq 源操作数:将源操作数(如寄存器值)压入栈顶。它等价于先减小栈指针 %rsp(因为栈向下增长),然后将数据存储到新的栈顶地址。
  • popq 目的操作数:从栈顶弹出一个数据到目的操作数(如寄存器)。它等价于先从栈顶地址加载数据,然后增加栈指针 %rsp

我们也可以使用之前介绍的寻址模式来访问栈中任意位置的数据,例如 8(%rsp) 可以访问栈顶下方8字节处的值。

算术与逻辑运算 ➕➖🔀

现在,让我们看看如何对数据进行计算。算术和逻辑运算指令是构建程序逻辑的基础。

加载有效地址指令 lea 非常强大。它用于计算地址而不进行内存访问。其格式为 lea 源内存地址, 目的寄存器。它实际上执行的是源操作数寻址模式的计算,并将结果(一个地址)存入目的寄存器。编译器常巧妙利用 lea 来执行简单的乘加运算,因为它不设置条件码,且速度快。

运算指令分为一元和二元操作:

  • 一元操作:只有一个操作数,它同时作为源和目的。例如:
    • inc:递增。
    • dec:递减。
    • neg:取负。
    • not:取补。
  • 二元操作:有两个操作数,格式为 指令 源操作数, 目的操作数,结果覆盖目的操作数。例如:
    • add:加法,addq %rbx, %rax 相当于 %rax = %rax + %rbx
    • sub:减法。
    • imul:有符号乘法。
    • xor, and, or:按位异或、与、或。

移位操作也是二元操作,用于移动位模式:

  • sal / shl:算术左移 / 逻辑左移(两者低位均补0)。
  • sar:算术右移(高位复制符号位)。
  • shr:逻辑右移(高位补0)。
    格式为 指令 移位位数, 被移位操作数

控制流:条件码与跳转 🚦

程序不仅仅是顺序执行,更需要根据条件做出决策,这就是控制流。在底层,这是通过条件码寄存器跳转指令实现的。

条件码是单个位的寄存器,记录最近一次算术或逻辑运算的属性:

  • CF:进位标志。最近操作使最高位产生了进位(无符号溢出)。
  • ZF:零标志。最近操作的结果为零。
  • SF:符号标志。最近操作的结果为负数。
  • OF:溢出标志。最近操作导致有符号溢出。

cmptest 指令用于设置条件码而不改变操作数:

  • cmp 操作数b, 操作数a:类似于计算 a - b,并根据结果设置条件码。
  • test 操作数b, 操作数a:类似于计算 a & b,并根据结果设置条件码。

set 指令根据条件码的组合,将一个字节设置为0或1。例如:

  • sete:相等时设置(ZF=1)。
  • setne:不相等时设置(ZF=0)。
  • setg:有符号大于时设置(~(SF^OF) & ~ZF)。
  • setl:有符号小于时设置(SF^OF)。

跳转指令 jmp 及其条件变体用于改变指令执行顺序,跳转到代码中由标签(如 .L2:)标记的位置。

  • jmp 标签:无条件跳转。
  • je 标签:相等时跳转(ZF=1)。
  • jne 标签:不相等时跳转。
  • jg, jge, jl, jle:有符号比较后的跳转。
  • ja, jae, jb, jbe:无符号比较后的跳转。

跳转也可以是间接的,目标地址来自寄存器或内存:jmp *%raxjmp *(%rax)

总结 📚

本节课中我们一起学习了汇编语言中数据操作与控制逻辑的核心内容。我们从数据的三种存储位置(立即数、寄存器、内存)和寻址模式开始,深入探讨了如何使用 mov 指令在它们之间传送数据。接着,我们了解了程序栈的操作 pushpop。然后,我们学习了各种算术、逻辑和移位运算指令,它们是进行数据计算的基础。最后,我们揭示了程序控制流的底层机制:通过 cmp/test 指令设置条件码,再根据条件码通过 set 指令设置值或通过 jmp 系列指令进行条件/无条件跳转。

理解这些从数据移动到条件分支的底层操作,是将高级语言逻辑映射到计算机系统实际执行步骤的关键。这为我们后续分析更复杂的程序行为(例如在 Bomb Lab 中)奠定了坚实的基础。

017:Bomb Lab 第一部分

在本节课中,我们将开始学习“Bomb Lab”。这是一个通过逆向工程和调试技术来拆除“二进制炸弹”的挑战性实验。我们将了解实验的基本结构、目标以及用于拆弹的初步工具。

概述与实验目标

Bomb Lab 是一个独特的程序挑战,包含六个阶段。每个阶段都需要用户输入一个特定的字符串。输入正确则拆除该阶段,输入错误则“炸弹”会“爆炸”。你的任务是通过分析、调试和逆向工程,找出每个阶段的正确密码,成功拆除所有六个阶段。

你的炸弹是独一无二的,需要通过课程服务器获取。实验的目标是培养你使用调试器、阅读汇编代码和理解程序在系统层面运行的能力。

获取你的炸弹

为了开始实验,你首先需要获取属于你自己的炸弹。

以下是获取炸弹的步骤:

  1. 登录到课程提供的 Web 终端环境。
  2. 打开浏览器(如 Firefox 或 Chrome)。
  3. 访问炸弹分发平台:bomblab.cs.uno.edu
  4. 在表单中输入你的用户名(不含 @uno.edu)和你的 UNO 邮箱地址。
  5. 点击提交按钮。服务器会为你生成一个唯一的炸弹文件(例如 bomb4.tar)。
  6. 下载完成后,解压该文件。你可以通过图形界面双击解压,或在终端使用命令:tar -xvf bomb4.tar

解压后,你会得到三个文件:

  • bomb:可执行的二进制炸弹程序。
  • bomb.c:包含 main 函数的 C 语言源文件。
  • README:包含炸弹标识和所属信息的文件。

注意:请避免多次提交请求,否则你会收到多个炸弹。如果发生这种情况,只需选择其中一个进行破解即可。

分析源代码:bomb.c

虽然我们无法获得炸弹的全部源代码,但提供的 bomb.c 文件是理解程序入口点的关键。让我们来仔细查看它。

文件开头是一份“邪恶博士”的幽默版最终用户许可协议。其中隐含地提示了我们可以使用的工具,例如 strings 和调试器。

接下来是头文件包含和全局变量声明。关键的全局变量是 FILE *infile,它用于决定程序是从标准输入还是从文件中读取数据。

main 函数的核心逻辑如下:

  1. 检查命令行参数数量 (argc)。
  2. 如果 argc 为 1,则从标准输入读取。
  3. 如果 argc 为 2,则将第二个参数作为文件名打开,并从中读取输入。这非常有用,因为它允许你将已知的答案保存在文件中,快速跳转到正在调试的阶段,而无需重复输入。
  4. 如果 argc 大于 2,则打印用法说明并退出。
  5. 调用 initialize_bomb 函数(具体实现在我们看不到的预编译代码中)。
  6. 依次调用六个阶段函数 (phase_1phase_6)。每个阶段都会先调用 read_line 读取一行输入,然后进行验证。
// 示例:阶段一的调用逻辑
input = read_line(); // 读取输入
phase_1(input);      // 验证输入
phase_defused();     // 如果通过,标记该阶段已拆除
printf("Phase 1 defused. How about the next one?\n");

如果 phase_1 函数判定输入错误,程序会调用 explode_bomb 函数,导致炸弹“爆炸”。

运行炸弹与基本操作

现在,让我们看看如何与炸弹程序交互。

你可以在终端中直接运行炸弹:

./bomb

程序启动后会打印欢迎信息,并等待你为第一阶段输入字符串。

重要提示

  • 你可以随时按 Ctrl+C 来安全地中断程序。
  • 直接按回车(输入空行)不会触发爆炸。
  • 输入错误答案会导致炸弹爆炸,并向服务器报告,每次爆炸扣除 0.5 分。每个炸弹有 40 分的基础分,最多可扣 20 分。
  • 如果分数扣得太多,你可以从分发平台重新获取一个新炸弹,但需要从头开始破解。

为了便于测试,你可以将答案保存在文件中:

echo “可能的答案” > solution.txt
./bomb solution.txt

这样,炸弹会从 solution.txt 中读取第一行的答案。

拆弹工具箱

要拆除炸弹,我们需要借助一系列工具来窥探这个二进制程序的内部。

1. strings 工具

strings 命令可以提取二进制文件中所有可打印的字符串序列。这对于寻找隐藏的密码或提示非常有效。

strings bomb

输出可能包含系统库字符串、程序中的提示信息,以及可能作为密码的“异常”字符串

2. objdump 工具

objdump 用于反汇编二进制文件或显示其符号表。

  • 查看符号表(函数、全局变量名):
    objdump -t bomb
    
  • 反汇编特定函数或所有代码:
    objdump -d bomb
    

3. gdb 调试器

gdb (GNU Debugger) 是本实验的核心工具。它允许你:

  • 逐条指令地执行程序。
  • 设置断点,在特定地址或函数处暂停执行。
  • 检查内存和寄存器的值。
  • 动态改变程序执行流程。
    启动 gdb
gdb bomb

gdb 提示符下,你可以使用 run 命令启动程序,使用 help 命令查看所有可用命令。

实战:拆除第一阶段

上一节我们介绍了拆弹的基本工具,本节中我们来看看如何利用 strings 工具快速破解第一阶段。

第一阶段的典型特点是,密码作为一个明文字符串直接隐藏在二进制程序中。我们的目标就是找到它。

以下是使用 strings 寻找密码的策略:

  1. 运行 strings 并保存输出:为了方便分析,我们将输出重定向到文件。
    strings bomb > bomb_strings.txt
    
  2. 分析字符串类型:打开 bomb_strings.txt 文件,字符串大致分为三类:
    • 系统级字符串:如 malloc__libc_start_main,来自链接的库。
    • 应用级字符串:如 “Welcome to my fiendish little bomb”“BOOM!!!”,来自 bomb.c 中的提示信息。
    • 异常字符串:那些看起来既不像系统调用,也不像正常用户提示的字符串。它们很可能就是隐藏的密码。
  3. 识别并测试:浏览文件,寻找看起来像是一句完整、独立且有些突兀的话。例如,在示例中,字符串 “If I knew then what I know now I wouldn’t have had my little accident.” 看起来就很可疑。将它作为第一阶段的输入进行测试。

操作示例

# 将可疑字符串存入答案文件
echo “If I knew then what I know now I wouldn’t have had my little accident.” > solution.txt
# 用答案文件运行炸弹
./bomb solution.txt

如果成功,你将看到 “Phase 1 defused. How about the next one?” 的消息。

调试器初探

虽然第一阶段可以用 strings 解决,但后续阶段将更多地依赖 gdb 调试器。

以下是 gdb 的基本使用流程:

  1. 启动调试器并加载炸弹程序:gdb bomb
  2. main 函数或 phase_1 函数处设置断点:
    (gdb) break main
    (gdb) break phase_1
    
  3. 运行程序,可以附带参数(如答案文件):
    (gdb) run solution.txt
    
  4. 程序会在断点处暂停。你可以使用 stepi (单步执行一条汇编指令) 或 nexti (执行完整个函数调用) 来逐步执行。
  5. 使用 info registers 查看寄存器状态,使用 x 命令检查内存内容。
  6. 通过分析汇编代码和程序状态,推断出正确的输入。

我们将在下一节课中详细演示如何使用 gdb 来系统性地分析和破解第二阶段。

总结与下节预告

本节课中我们一起学习了 Bomb Lab 的基本框架。我们了解了如何获取个人专属的炸弹,分析了提供的 bomb.c 源文件以理解程序结构,学习了运行炸弹的注意事项,并认识了三个关键工具:stringsobjdumpgdb。最后,我们利用 strings 工具成功拆除了第一阶段。

下节课,我们将深入使用 gdb 调试器。我会演示如何用调试的方法再次破解第一阶段,然后我们将共同挑战更具难度的第二阶段。请确保你已经成功获取了自己的炸弹,并尝试用 strings 方法破解了第一阶段。

018:炸弹实验室第二部分

在本节课中,我们将继续学习炸弹实验室,重点介绍如何利用调试器和反汇编工具来分析和解决炸弹程序的第二阶段。我们将学习两种不同的方法:一种是通过分析反汇编代码来理解程序逻辑,另一种是使用调试器进行动态探索。


设置安全网

上一节我们介绍了如何使用strings命令解决第一阶段。本节中,我们将学习如何使用调试器来更深入地分析程序。

首先,我们启动调试器并加载炸弹程序。

gdb bomb

为了防止炸弹意外爆炸,我们可以在explode_bomb函数处设置断点。这样,当程序试图调用该函数时,执行会暂停。

break explode_bomb

如果尝试设置一个不存在的标签作为断点,调试器会提示未定义。

break elo_bomb

设置好断点后,运行程序。如果输入错误的字符串,程序会调用explode_bomb,但断点会触发,从而阻止爆炸。但请注意,如果忽略断点并继续执行,炸弹仍会爆炸。

run

分析第一阶段(调试器方法)

除了设置安全断点,我们还可以在phase_1函数处设置断点,以分析其内部逻辑。

break phase_1

运行程序并输入测试字符串。

run

程序会在phase_1处暂停。此时,我们可以查看当前的调用栈、寄存器和汇编指令。

使用nexti命令可以单步执行下一条指令。当我们执行到strings_not_equal函数调用时,可以检查传递给它的参数。

nexti
nexti

在调用strings_not_equal之前,参数被加载到了RSI寄存器中。我们可以使用x/s命令查看该寄存器指向的字符串内容。

x/s $rsi

这将显示程序期望的密码字符串。复制该字符串,在程序提示时输入,即可通过第一阶段。


解决第二阶段(分析方法)

现在,我们进入第二阶段。我们将首先使用反汇编工具进行静态分析。

使用objdump生成炸弹程序的反汇编代码,并输出到文件。

objdump -d bomb > bomb.s

打开bomb.s文件,找到phase_2函数。以下是其核心逻辑的注释分析。

push   %rbp
push   %rbx
sub    $0x28,%rsp
mov    %rsi,%rsi
callq  0x4010c4 <read_six_numbers>
cmp    $0x0,(%rsp)
jns    0x4010af <phase_2+0x2b>
callq  0x4011a1 <explode_bomb>
...

函数首先调用read_six_numbers,确保输入是六个数字。然后检查第一个数字是否非负。接着,程序进入一个循环,检查数字序列是否满足特定关系。

通过分析汇编代码,我们可以将其逻辑翻译成C代码。

void phase_2(char *input) {
    int numbers[6];
    read_six_numbers(input, numbers);
    if (numbers[0] < 0) {
        explode_bomb();
    }
    for (int i = 0; i < 5; i++) {
        if (numbers[i+1] != numbers[i] + i + 1) {
            explode_bomb();
        }
    }
}

根据这个逻辑,如果第一个数字是1,那么序列应为:1, 2, 4, 7, 11, 16。我们可以用这个序列进行测试。


解决第二阶段(探索方法)

另一种方法是使用调试器动态探索。我们首先在phase_2和关键比较指令处设置断点。

break phase_2
break *0x4010f2

运行程序并输入一个测试序列,例如0 1 2 3 4 5。当程序在比较指令处暂停时,检查相关寄存器的值。

info registers eax
x/d $rbp

通过观察比较结果,我们可以推断出当前数字应为何值。重复此过程,依次找出所有六个数字,从而解除第二阶段。


总结

本节课中我们一起学习了两种解决炸弹实验室第二阶段的方法。分析方法通过反汇编和代码翻译来理解程序逻辑,而探索方法则利用调试器动态观察程序状态。掌握这些技能对于进行系统级编程和逆向工程至关重要。

019:Bomb Lab Part3 💣

在本节课中,我们将继续拆解Bomb Lab,重点分析第三阶段和第四阶段的炸弹。我们将学习如何使用调试器逐步分析汇编代码,通过检查寄存器和内存状态来推断正确的密码,从而避免炸弹爆炸。课程结束时,你将掌握分析更复杂逻辑(如递归函数)的基本技巧。


阶段三:输入格式与条件判断 🔍

上一节我们使用objdump和调试器解决了第二阶段。本节中,我们来看看第三阶段,它引入了更复杂的输入验证和条件跳转。

首先,在调试器中反汇编phase_3函数,查看其结构。

(gdb) disas phase_3

通过反汇编代码,我们注意到函数开头调用了scanf,并且其格式字符串提示我们需要两个整数。

// 从内存地址读取的格式字符串
x/s 0x40278a
"%d %d"

因此,第三阶段需要两个整数作为输入。接下来,函数会比较scanf的返回值(成功解析的参数数量)与数字1。如果返回值不大于1,程序将跳转到引爆炸弹的代码块。

cmp    $0x1,%eax
jg     0x400f6a  ; 如果大于1则跳转,避免爆炸

所以,我们必须输入两个整数才能通过第一项检查。


探索有效输入 🧪

仅仅知道需要两个整数还不够,我们还需要知道具体是哪两个数字。通过设置断点并单步执行,我们可以观察程序是如何比较我们输入的数字的。

我们在关键比较指令处设置断点,然后运行程序并输入测试值(例如12-5)。

(gdb) break *0x400f6a
(gdb) run < sol3.txt

单步执行时,程序会将第一个输入的值(位于rsp+0x14)与立即数0x7(十进制7)进行比较。

cmp    0x14(%rsp),%eax
cmp    $0x7,%eax
jl     0x400f83  ; 如果小于7则跳转

如果第一个数不小于7,程序将引爆炸弹。因此,我们得知第一个数必须小于7。

继续执行,程序会根据第一个数的值,通过一个跳转表计算出一个目标地址并跳转。最终,它会将计算出的一个值与我们的第二个输入进行比较。

cmp    %eax,0x10(%rsp)  ; 比较计算值和第二个输入
je     0x400fc9          ; 如果相等则跳转,避免爆炸

我们需要让第二个输入与程序根据第一个数计算出的值相等。通过反复测试和观察寄存器%eax的值,我们可以找到有效的配对。例如,当第一个输入是5时,计算出的值是-349,那么有效的输入就是5 -349

第三阶段的核心逻辑是:第一个输入是小于7的索引,用于选择计算路径;第二个输入必须与该路径计算出的特定值匹配。


阶段四:递归函数与二分查找 🔄

成功拆解第三阶段后,我们进入第四阶段。这个阶段引入了递归函数,逻辑更为复杂。

同样,我们先反汇编phase_4

(gdb) disas phase_4

我们发现它同样需要两个整数输入(通过scanf的返回值与2比较得知)。此外,它立即对第一个输入进行了检查:

  1. 不能为负数(通过test %eax, %eaxjs指令判断符号位)。
  2. 必须小于或等于0xe(十进制14)。
cmp    $0xe,%eax
jle    0x40102d  ; 如果小于等于14则跳转

如果第一个数满足 0 <= num1 <= 14,程序会调用一个名为func4的函数,参数为(num1, 0, 14)

// 函数调用大致对应
func4(num1, 0, 14);

调用func4后,其返回值被存放在%eax中,并与1进行比较。

cmp    $0x1,%eax
je     0x401058  ; 如果等于1则跳转,避免爆炸

因此,我们的目标是找到第一个数num1,使得func4(num1, 0, 14)的返回值等于1。


分析 func4 函数 🧮

要解决这个问题,必须理解func4的行为。我们反汇编它:

(gdb) disas func4

分析其汇编代码,可以发现它是一个递归函数,结构类似于二分查找算法。其C语言伪代码可能如下:

int func4(int edi, int esi, int edx) {
    int ecx = edx;
    ecx = ecx - esi;
    int eax = ecx;
    eax = (unsigned int)eax >> 31; // 逻辑右移
    ecx = ecx + eax;
    eax = ecx;
    eax = eax >> 1; // 算术右移
    eax = eax + esi;

    if (eax <= edi) {
        if (eax >= edi) {
            return 0;
        } else {
            esi = eax + 1;
            return 2 * func4(edi, esi, edx) + 1;
        }
    } else {
        edx = eax - 1;
        return 2 * func4(edi, esi, edx);
    }
}

函数逻辑解析

  1. 计算中点:mid = esi + (edx - esi) / 2(通过移位实现)。
  2. mid与目标值edi(即我们的第一个输入)比较。
  3. 如果mid == edi,返回0。
  4. 如果mid < edi,在右半部分递归搜索,返回 2 * func4(...) + 1
  5. 如果mid > edi,在左半部分递归搜索,返回 2 * func4(...)

我们的目标是让最终返回值为1。通过手动模拟或编写一个小程序测试014之间的所有输入,可以找出满足func4(num, 0, 14) == 1num值。例如,7是其中一个解。

找到num1(例如7)后,我们还需要第二个数。回顾phase_4的汇编,在func4调用成功后,它直接将第二个输入与0比较,要求相等。

cmp    0x10(%rsp),%eax  ; 此时%eax为0?
je     0x401058

因此,一个有效的第四阶段密码可能是 7 0


总结与下节预告 📚

本节课中我们一起学习了Bomb Lab的第三和第四阶段。

  • 第三阶段要求两个整数输入,第一个作为索引(<7),第二个必须与索引对应的特定计算结果匹配。
  • 第四阶段同样需要两个整数。第一个输入需在0到14之间,并作为参数传递给一个递归的func4函数,该函数的返回值必须为1。第二个输入则必须为0。

我们主要使用了调试器来设置断点、单步执行、检查寄存器和内存,从而动态推导出正确的输入。对于包含递归或循环的复杂函数,需要将汇编指令转化为高级逻辑来理解。

下一讲我们将完成Bomb Lab的最后阶段,并开始准备下一个实验——Shell Lab。请确保你已理解本节课中使用的调试技巧和逆向分析方法。

020:Shell Lab 第一部分 🐚

在本节课中,我们将开始学习如何实现一个简单的Unix Shell,名为TSH(Tiny Shell)。我们将了解Shell的基本工作原理,包括进程创建、信号处理和作业控制等核心概念。通过本实验,你将亲手编写一个能够执行命令、处理进程和管理作业的Shell程序。


概述:什么是Shell? 🖥️

Shell是一个交互式的命令行解释器,它充当用户与操作系统之间的接口。它的主要功能是解释用户输入的命令,并相应地运行程序。Shell既包含内置命令(如cdjobs),也能启动外部应用程序(如lsgcc)。

上一节我们介绍了课程和实验的整体安排,本节中我们来看看Shell的基本概念和我们将要构建的Tiny Shell的初始设置。


核心概念与准备工作 🔧

在开始编码之前,我们需要理解几个关键概念,并设置好开发环境。

关键概念

  1. 进程:进程是程序执行的一个实例,是Unix环境中的基本计算单位。它允许我们分离不同程序的执行,并提供并发执行的能力。

    • 公式/代码:在C语言中,进程通过fork()系统调用来创建。
  2. 父进程与子进程:当使用fork()创建新进程时,原始进程称为父进程,新创建的进程称为子进程。它们可以并发运行。

    • 代码
      pid_t pid = fork();
      if (pid == 0) {
          // 这里是子进程的代码
      } else {
          // 这里是父进程的代码
      }
      
  3. 信号:信号是一种软件中断,用于进程间通信,以通知进程发生特定事件(如用户按下Ctrl+C)。

    • 常见信号
      • SIGINT (Ctrl+C):中断前台作业。
      • SIGTSTP (Ctrl+Z):停止前台作业。
      • SIGCHLD:子进程状态改变(终止或停止)。
  4. 输入/输出重定向:这允许我们改变程序的标准输入/输出流。例如,将输出从屏幕重定向到一个文件。

    • 代码:使用 >< 符号,在Shell内部需要通过 dup2() 系统调用来实现。

实验初始设置

以下是获取和设置实验初始代码的步骤:

  1. 从课程网站(AutoLab)下载实验材料包(shelllab-handout.tar)。
  2. 在终端中,使用tar命令解压该文件。
  3. 进入解压后的目录,运行make命令来编译提供的框架代码和测试工具。

运行make后,会生成几个可执行文件,包括你的Shell程序tsh,以及一些用于测试的小工具程序(如myspinmysplit)。


Tiny Shell 规范说明 📝

我们的Tiny Shell需要实现以下功能:

  • 提示符:显示 tsh> 等待用户输入。
  • 命令行解释:能够解析以空格分隔的命令和参数。
  • 内置命令:支持 quit(退出)、jobs(列出作业)、bg <job>(后台继续)、fg <job>(前台继续)。
  • 执行外部程序:对于非内置命令,将其视为可执行程序的路径,并创建新进程(作业)来运行它。
  • 作业控制
    • 支持在命令末尾添加&符号以在后台运行作业。
    • 支持 Ctrl+C (SIGINT) 终止前台作业。
    • 支持 Ctrl+Z (SIGTSTP) 停止前台作业。
  • I/O重定向:支持使用 >< 进行基本的输入输出重定向。

测试与验证方法 🧪

我们将使用一套完整的测试文件(trace*.txt)来验证Shell的实现是否正确。

测试工具

  1. 参考解决方案 (tshref):这是一个已编译好的、功能完整的Tiny Shell。你的Shell输出需要与它的输出一致。
  2. Shell驱动脚本 (sdriver.pl):这是一个Perl脚本,它会根据trace文件的内容,向你的Shell发送命令和信号,并捕获其输出。
  3. 测试脚本 (checktsh.py):一个Python脚本,可以运行所有测试并给出当前得分。

运行测试

以下是运行测试的便捷方法:

  • 测试你自己的Shell:make test01 (测试trace01)
  • 测试参考Shell:make rtest01
  • 比较两者输出:make test01 > my.out && make rtest01 > ref.out && diff my.out ref.out
  • 使用检查脚本:./checktsh.py

你的最终得分取决于能通过多少个trace测试,每个测试通常值2分。


代码框架初探与第一个功能实现 💻

实验提供了一个C语言框架文件tsh.c。其中,我们需要实现几个关键的函数存根(stub functions)。

核心函数存根

  • eval: 解析并执行命令行。
  • builtin_cmd: 判断并执行内置命令。
  • do_bgfg: 实现bgfg命令的功能。
  • waitfg: 等待前台作业完成。
  • sigchld_handler, sigint_handler, sigtstp_handler: 信号处理函数。

实现第一个功能:quit 命令

让我们从最简单的quit命令开始,了解代码流程。

  1. 理解 eval 函数:这是Shell的主循环(read-eval-print loop)中的“评估”部分。它接收用户输入的命令行字符串。
  2. 使用 parseline 助手函数:框架提供的parseline函数可以帮助我们将命令行字符串解析成参数数组(argv),并判断该命令是否需要在后台运行(通过检查末尾的&)。
  3. builtin_cmd 中处理 quit
    • 检查argv[0](命令名)是否是字符串"quit"
    • 如果是,则调用exit(0)终止Shell进程。

代码示例 (builtin_cmd 函数片段)

int builtin_cmd(char **argv) {
    char *cmd = argv[0];
    if (strcmp(cmd, "quit") == 0) {
        exit(0); // 如果是quit命令,则退出Shell
    }
    return 0; // 不是内置命令,返回0
}
  1. eval 中调用:在eval函数中,先调用parseline解析,然后将得到的argv传递给builtin_cmd。如果builtin_cmd返回(表示不是内置命令),我们后续才需要去创建子进程执行外部程序(这是后续实验的内容)。

完成这一步后,你的Shell就已经能够响应quit命令并退出了。运行make test01,你应该能通过第一个测试用例。


总结与下一步 🚀

本节课中我们一起学习了Shell的基本概念,设置了实验环境,并了解了Tiny Shell需要实现的功能规格。我们初步探索了提供的代码框架,并成功实现了第一个内置命令——quit,使其能够通过最简单的测试用例(trace01)。

下一节课,我们将继续深入,学习如何让Shell能够执行外部命令(如/bin/ls -l),这是实现一个功能完整Shell的关键一步。我们将涉及到fork()execve()等系统调用的使用,并开始处理进程创建和基本的作业管理。

记住,本实验是循序渐进的,每个trace测试都引导你实现一个特定的功能。从简单开始,逐步构建,并充分利用调试器(如gdb)和测试工具来验证你的每一步实现。

021:Shell Lab 第二部分

概述

在本节课中,我们将继续构建一个简单的 Shell(Tiny Shell)。我们将从分析第一个测试用例(trace01)的实现开始,然后逐步实现第二个测试用例(trace02),学习如何处理外部命令的执行。我们将深入理解 Shell 如何解析命令、区分内置命令与外部程序,并使用系统调用(如 forkexecve)来创建子进程以运行外部应用程序。

回顾与代码分析

上一节我们介绍了 Shell 的基本循环结构(读取、解析、执行)并实现了 quit 这个内置命令。本节中,我们来看看如何执行外部命令。

首先,让我们回顾一下当前的代码结构。主函数 main 包含一个 reboot 循环,核心是 eval 函数。在 eval 函数中,我们通过 parseline 函数解析命令行输入,然后通过 builtin_command 函数判断是否为内置命令。

以下是 eval 函数中处理命令的核心流程框架:

void eval(char *cmdline) {
    char *argv[MAXARGS]; // 参数数组
    int bg = parseline(cmdline, argv); // 解析命令行,bg指示是否为后台任务
    if (!builtin_command(argv)) { // 如果不是内置命令
        // 需要执行外部程序(这部分将在本节实现)
    }
    return;
}

builtin_command 函数检查命令是否为 quit,如果是则退出程序。对于其他命令,目前它只是返回 0(假),表示需要进一步处理。

实现外部命令执行(Trace02)

现在,我们需要实现 eval 函数中处理非内置命令的部分。这涉及到创建子进程并在其中运行外部程序。以下是实现步骤:

  1. 创建子进程:使用 fork 系统调用。fork 会创建一个当前进程的副本(子进程)。在父进程中,fork 返回子进程的PID;在子进程中,fork 返回 0。

    • 公式/概念pid_t pid = fork();
    • 如果 pid < 0,表示 fork 失败。
    • 如果 pid == 0,当前代码在子进程中执行。
    • 如果 pid > 0,当前代码在父进程中执行。
  2. 在子进程中执行程序:在子进程分支中,使用 execve 系统调用加载并运行新的程序。execve 会用指定的程序替换当前子进程的内存映像。

    • 代码execve(argv[0], argv, environ);
    • 参数 argv[0] 是命令名(程序路径),argv 是参数数组,environ 是环境变量。
    • 如果 execve 成功,它不会返回;如果失败,会返回 -1,此时子进程应处理错误(例如打印错误信息并退出)。
  3. 在父进程中等待子进程:在父进程分支中,如果命令是前台任务(bg == 0),则需要使用 waitpid 系统调用等待子进程结束,以避免产生僵尸进程(Zombie Process)。如果命令是后台任务(bg == 1),则父进程可以不立即等待。

    • 代码(前台等待)waitpid(pid, &status, 0);

以下是实现 eval 函数中执行外部命令部分的代码示例:

void eval(char *cmdline) {
    char *argv[MAXARGS];
    pid_t pid;
    int bg = parseline(cmdline, argv);
    if (!builtin_command(argv)) {
        if ((pid = fork()) == 0) { // 子进程
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }
        // 父进程
        if (!bg) { // 前台任务
            int status;
            waitpid(pid, &status, 0);
        } else { // 后台任务
            printf("%d %s", pid, cmdline); // 打印后台任务信息
        }
    }
    return;
}

测试与验证

实现以上代码后,我们可以测试 trace02。trace02 包含一个简单的命令,如 /bin/echo

以下是测试步骤:

  1. 在 Shell Lab 目录下,运行 make 重新编译。
  2. 运行参考实现进行对比:make rtest02
  3. 运行自己的实现进行测试:make test02
  4. 使用 diff 命令比较两者输出是否一致:diff -u <(make rtest02) <(make test02)。如果没有输出,说明完全一致。
  5. 运行评分脚本查看得分:./sdriver.pl -t trace02.txt -s ./tsh -a "-p"。成功实现后,应该能获得 trace02 的分数。

总结

本节课中我们一起学习了 Shell 执行外部命令的核心机制。我们实现了 eval 函数的关键部分,使用 fork 创建子进程,在子进程中使用 execve 加载外部程序,并在父进程中使用 waitpid 管理子进程(特别是前台任务)。通过完成 trace02,你的 Tiny Shell 现在已经能够响应并执行像 /bin/echo 这样的简单外部命令了。在后续的课程中,我们将处理更复杂的情况,如输入/输出重定向、管道和作业控制。

022:Shell Lab Part 3

概述

在本节课中,我们将继续构建我们的简易Shell。我们将从上一节课结束的地方开始,学习如何执行外部命令、处理后台作业,并实现jobs内置命令。我们将通过完成trace02trace04来逐步实现这些功能。


回顾与目标

上一节我们介绍了Shell Lab的基本框架,并实现了quit内置命令以通过trace01。本节中,我们来看看如何执行外部命令和处理后台作业,目标是完成trace02trace03trace04


实现外部命令执行 (Trace 02)

目标分析

trace02要求我们的Shell能够执行一个前台命令(例如/bin/ls)多次。目前,我们的eval函数只能处理内置命令。我们需要扩展它以执行外部程序。

核心概念与系统调用

执行外部命令需要以下三个关键系统调用:

  1. fork(): 创建一个新的子进程。
    • 父进程获得子进程的PID。
    • 子进程获得返回值0。
    • 失败时返回-1。
  2. execv(): 在子进程中加载并运行新程序。
    • 原型:int execv(const char *pathname, char *const argv[]);
    • 成功时不返回,失败时返回-1。
  3. waitpid(): 父进程等待子进程结束(用于前台作业)。
    • 原型:pid_t waitpid(pid_t pid, int *wstatus, int options);

实现步骤

我们需要重构eval函数。以下是需要添加的逻辑流程:

  1. 解析命令行,填充argv数组并判断是否为后台作业(bg)。
  2. 检查命令是否为内置命令(目前只有quit)。如果是,执行并返回。
  3. 如果不是内置命令,则需fork一个子进程。
    • 在子进程中,调用execv执行命令。
    • 在父进程中,如果这是前台作业(!bg),则调用waitpid等待子进程结束。

以下是eval函数中需要添加的核心代码框架:

pid_t pid = fork();
if (pid == 0) { // 子进程
    // 尝试执行命令
    if (execv(argv[0], argv) < 0) {
        printf("%s: Command not found\n", argv[0]);
        exit(0);
    }
}
// 父进程
else {
    if (!bg) { // 前台作业
        // 等待子进程
        waitfg(pid);
    }
    else { // 后台作业 (trace03处理)
        // ...
    }
}

同时,我们需要实现waitfg函数,它使用waitpid来等待指定的子进程:

void waitfg(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
}

测试与验证

完成上述修改后,编译并运行Shell。输入/bin/ls,现在应该能正确列出目录内容,并且在命令执行完毕后,提示符会重新出现,等待下一条命令。运行测试脚本应能通过trace02


处理后台作业 (Trace 03)

目标分析

trace03要求Shell能够执行一个后台作业(命令以&结尾)。后台作业启动后,Shell应立即打印作业信息并返回提示符,而不等待作业完成。

核心概念与辅助函数

Shell需要跟踪所有后台作业。我们使用提供的全局jobs数组和辅助函数:

  • addjob(struct job_t *jobs, pid_t pid, int state, const char *cmdline): 将一个新作业添加到作业列表。
  • pid2jid(pid_t pid): 将进程PID转换为更易读的作业ID(JID)。

实现步骤

eval函数的父进程逻辑中,我们已区分了前台和后台作业。对于后台作业,我们需要:

  1. 调用addjob将该作业添加到jobs列表,状态设为BG
  2. 打印作业信息,格式为:[jid] (pid) cmdline

修改eval函数中处理后台作业的部分:

else { // 父进程,后台作业
    // 将后台作业添加到作业列表
    addjob(jobs, pid, BG, cmdline);
    // 打印作业信息
    printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
}

测试与验证

完成修改后,在Shell中输入myspin 1 &。你会立即看到类似[1] (1234) myspin 1 &的输出,并且提示符立刻返回。运行测试脚本应能通过trace03


实现 jobs 内置命令 (Trace 04)

目标分析

trace04要求实现jobs内置命令,用于列出所有当前的后台和已停止的作业。

核心概念与辅助函数

我们将使用另一个辅助函数:

  • listjobs(struct job_t *jobs): 遍历jobs数组并打印所有活动作业的详细信息(JID, PID, 状态,命令行)。

实现步骤

我们需要在builtin_cmd函数中添加对jobs命令的支持:

  1. 使用strcmp检查argv[0]是否为"jobs"
  2. 如果是,则调用listjobs(jobs)打印作业列表,并返回1(表示是内置命令)。

修改builtin_cmd函数:

int builtin_cmd(char **argv) {
    if (!strcmp(argv[0], "quit")) {
        exit(0);
    }
    else if (!strcmp(argv[0], "jobs")) { // 处理jobs命令
        listjobs(jobs);
        return 1;
    }
    return 0; // 不是内置命令
}

测试与验证

完成修改后,在Shell中依次输入:

  1. myspin 2 & (启动一个后台作业)
  2. myspin 3 & (启动另一个后台作业)
  3. jobs (列出作业)

你应该能看到两个运行中的后台作业被正确列出。运行测试脚本应能通过trace04


总结

本节课中我们一起学习了Shell Lab的三个核心功能:

  1. 执行外部命令:通过forkexecvwaitpid系统调用,使Shell能够运行非内置程序,并正确处理前台作业的等待。
  2. 处理后台作业:通过addjob函数将后台作业记录到全局jobs列表,并立即向用户反馈作业信息,而不阻塞Shell。
  3. 实现jobs内置命令:通过调用listjobs函数,使Shell能够查看当前所有后台作业的状态。

至此,我们已经完成了Shell Lab前20%的内容(共40分中的8分)。我们的Shell已经具备了基本的交互能力,可以执行命令、管理前后台作业。在后续的课程中,我们将继续实现更复杂的作业控制功能,如信号处理和前台/后台作业切换。

023:Shell Lab 第四部分 🐚

在本节课中,我们将继续深入实现我们的简易Shell,重点学习如何处理信号,特别是SIGCHLDSIGINT信号,以管理后台和前台作业的状态。

概述

上一节我们实现了基本的作业列表管理和后台作业启动。本节中,我们将重点关注信号处理。我们将实现SIGCHLD信号处理器来清理已完成的子进程,并实现SIGINT信号处理器来将中断信号(如Ctrl+C)转发给前台作业。这些功能对于构建一个健壮的、能够并发管理多个作业的Shell至关重要。

修正 eval 函数中的错误

在开始新内容之前,我们需要修正上一节代码中的一个错误。在eval函数中,我们错误地将添加作业到作业列表的代码放在了条件分支里。实际上,无论作业是前台还是后台,我们都应该将其添加到作业列表中。

以下是修正后的代码逻辑:

// 在父进程中,首先添加作业到列表
addjob(jobs, pid, (bg ? BG : FG), cmdline);

// 然后根据前台/后台决定等待方式
if (!bg) {
    // 等待前台作业
    waitfg(pid);
} else {
    // 打印后台作业信息
    printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
}

这个修正确保了所有作业都能被正确追踪。

实现 SIGCHLD 信号处理器 🧒

SIGCHLD信号在子进程终止、停止或恢复时发送给父进程。目前我们的Shell没有处理这个信号,导致作业列表状态更新不及时,甚至出现重复打印等问题。

SIGCHLD处理器的主要职责是“收割”(reap)已结束的子进程,更新作业列表,并避免僵尸进程。

以下是实现SIGCHLD处理器sigchld_handler的关键步骤:

  1. 使用waitpid系统调用来检查子进程的状态变化。
  2. 使用WIFEXITED宏判断进程是否正常退出。
  3. 如果进程正常退出,则使用deletejob辅助函数将其从作业列表中删除。

核心实现伪代码如下:

void sigchld_handler(int sig) {
    int status;
    pid_t pid;
    int has_next = 1;

    while (has_next) {
        // WNOHANG 表示非阻塞等待,立即返回
        pid = waitpid(-1, &status, WNOHANG | WUNTRACED);
        if (pid > 0) {
            // 还有更多子进程状态可能改变
            has_next = 1;
        } else {
            has_next = 0;
        }

        if (pid > 0) {
            if (WIFEXITED(status)) {
                // 子进程正常退出,删除作业
                deletejob(jobs, pid);
            }
            // 后续会在这里添加处理信号终止的逻辑
        }
    }
}

实现此处理器后,我们成功通过了trace05.txt测试,Shell能够正确清理已完成的作业。

重构 waitfg 函数 ⏳

之前的waitfg实现简单地使用waitpid阻塞等待,这在与作业列表配合时存在问题。我们需要重构它,使其基于作业列表工作,并能更好地处理并发。

新的waitfg函数逻辑如下:

  1. 使用getjobpid辅助函数根据PID从作业列表中获取对应的作业结构。
  2. 如果作业存在且状态为前台(FG),则使用sleep系统调用进行非阻塞的循环等待。
  3. 持续检查,直到该前台作业从作业列表中消失(表示已完成)。

核心实现代码如下:

void waitfg(pid_t pid) {
    struct job_t *job = getjobpid(jobs, pid);
    if (job == NULL) {
        return; // 作业不存在,直接返回
    }
    // 当作业存在且状态为前台时,循环等待
    while (job->pid == pid && job->state == FG) {
        sleep(1); // 休眠1秒,避免忙等待
        job = getjobpid(jobs, pid); // 重新获取作业状态
    }
}

重构后,我们的Shell能够正确区分并管理前台和后台作业,通过了trace06.txt测试。

实现 SIGINT 信号处理器 ⚡

SIGINT信号通常由用户在终端按下Ctrl+C产生,默认行为是终止前台进程。我们需要在Shell中捕获这个信号,并将其转发给当前的前台作业。

SIGINT处理器sigint_handler的实现步骤:

  1. 使用fgpid辅助函数获取当前前台作业的进程ID(PID)。
  2. 如果存在前台作业(pid > 0),则使用kill系统调用向整个进程组发送SIGINT信号。

这里的关键是向进程组(-pid)而非单个进程(pid)发送信号,这能确保信号传递给该作业创建的所有子进程。

核心实现代码如下:

void sigint_handler(int sig) {
    pid_t pid = fgpid(jobs); // 获取前台作业PID
    if (pid > 0) {
        // 向整个进程组发送SIGINT信号
        kill(-pid, SIGINT);
    }
}

同时,我们需要在eval函数中创建子进程后,立即使用setpgid(0, 0)将其设置为一个新的进程组。这样,我们才能用-pid来指向整个组。

// 在eval函数的子进程代码块中
if (pid == 0) { // 子进程
    setpgid(0, 0); // 设置为新的进程组
    // ... 其他设置(如重定向)
    execve(...);
}

完善 SIGCHLD 处理器以处理信号终止

现在,当SIGINT处理器终止前台作业后,SIGCHLD处理器会收到通知。我们需要扩展SIGCHLD处理器,使其能识别出因子进程被信号终止的情况,并打印相应的信息。

我们需要使用以下两个宏:

  • WIFSIGNALED(status): 判断子进程是否因未捕获的信号而终止。
  • WTERMSIG(status): 获取导致子进程终止的信号编号。

扩展后的sigchld_handler部分逻辑如下:

void sigchld_handler(int sig) {
    // ... 前面的循环和pid获取代码不变
    if (pid > 0) {
        if (WIFEXITED(status)) {
            // 正常退出
            deletejob(jobs, pid);
        } else if (WIFSIGNALED(status)) {
            // 因信号终止
            int jid = pid2jid(pid);
            if (deletejob(jobs, pid)) {
                // 删除成功,打印信息
                printf("Job [%d] (%d) terminated by signal %d\n",
                       jid, pid, WTERMSIG(status));
            }
        }
        // 后续可以添加处理停止(SIGTSTP)的逻辑
    }
    // ...
}

总结

本节课中我们一起学习了Shell信号处理的核心机制。我们修正了作业添加的逻辑,实现了SIGCHLD处理器来收割和清理已结束的子进程,重构了waitfg以更好地配合作业列表,并实现了SIGINT处理器来将中断信号转发给前台作业组。通过这些步骤,我们的Shell现在能够更稳健地管理并发作业,并对用户的中断请求做出正确响应。在接下来的课程中,我们将继续实现更多功能,如作业控制(SIGTSTP)和内置命令fg/bg

024:Shell Lab 第五部分

在本节课中,我们将继续完成 Shell Lab 项目。上一节我们实现了对中断信号的处理,本节我们将重点实现停止信号的处理,以及 bgfg 这两个内置命令,从而实现对作业的挂起、恢复和前后台切换控制。


概述与回顾

在上一节中,我们实现了 SIGINT 信号的处理,能够将中断信号转发给前台作业。本节我们将处理 SIGTSTP 信号(通常由 Ctrl+Z 触发),它用于挂起前台作业。此外,我们还将实现 bgfg 命令,用于在后台和前台之间切换作业状态。

我们的目标是让 tsh(tiny shell)能够像标准的 Unix shell 一样,响应这些信号和命令,管理作业的生命周期。


处理停止信号 (SIGTSTP)

首先,我们需要处理 SIGTSTP 信号。这个信号应该只发送给当前的前台作业,使其挂起。

实现 sigstp_handler

我们需要在信号处理函数中获取当前前台作业的进程ID,并向其发送 SIGTSTP 信号。

void sigtstp_handler(int sig) {
    pid_t pid = fgpid(jobs); // 获取前台作业的PID
    if (pid > 0) {
        kill(-pid, SIGTSTP); // 向整个进程组发送停止信号
    }
}

关键点

  • 我们使用 fgpid 辅助函数来获取前台作业的 PID。
  • 使用 kill 系统调用发送信号。注意,我们使用 -pid 来向整个进程组发送信号,这有助于通过后续的测试用例。
  • 发送信号后,子进程会进入停止状态,但我们需要在 sigchld_handler 中更新其状态并打印信息。

更新子进程处理器 (sigchld_handler)

当子进程因信号而停止时,waitpid 会返回,并设置相应的状态标志。我们需要在 sigchld_handler 中捕获这种情况,更新作业状态,并打印提示信息。

识别停止的进程

我们使用 WIFSTOPPED(status) 宏来判断子进程是否被信号停止。

void sigchld_handler(int sig) {
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        // 处理停止的进程
        if (WIFSTOPPED(status)) {
            struct job_t *job = getjobpid(jobs, pid);
            if (job != NULL) {
                job->state = ST; // 将作业状态更新为“停止”
                printf("Job [%d] (%d) stopped by signal %d\n",
                       pid2jid(pid), pid, WSTOPSIG(status));
            }
        }
        // 处理已终止的进程(之前已实现)
        else if (WIFEXITED(status)) {
            deletejob(jobs, pid);
        }
        // 处理被信号终止的进程(之前已实现)
        else if (WIFSIGNALED(status)) {
            printf("Job [%d] (%d) terminated by signal %d\n",
                   pid2jid(pid), pid, WTERMSIG(status));
            deletejob(jobs, pid);
        }
    }
}

关键点

  • waitpid 的选项 WUNTRACED 至关重要,它使得 waitpid 在子进程停止(而不仅仅是终止)时也立即返回。没有这个选项,shell 会在等待停止的作业时挂起。
  • WSTOPSIG(status) 用于获取导致停止的信号编号。
  • 我们更新作业状态为 ST(停止),而不是将其从作业列表中删除。

实现 bg 内置命令

bg 命令用于将一个已停止的作业在后台继续运行。它接收一个作业标识符(如 %1)作为参数。

1. 在 builtin_cmd 中添加命令识别

首先,我们需要在 builtin_cmd 函数中识别 bg 命令,并调用相应的处理函数。

int builtin_cmd(char **argv) {
    if (!strcmp(argv[0], "quit")) {
        exit(0);
    }
    if (!strcmp(argv[0], "jobs")) {
        listjobs(jobs);
        return 1;
    }
    // 识别 bg 和 fg 命令
    if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
        do_bgfg(argv); // 调用统一的处理函数
        return 1;
    }
    // 如果不是内置命令,返回 0
    return 0;
}

2. 实现 do_bgfg 函数

do_bgfg 函数负责解析参数,找到对应的作业,并发送 SIGCONT 信号使其继续运行,同时更新其状态。

以下是处理 bg 命令的核心逻辑:

void do_bgfg(char **argv) {
    struct job_t *job = NULL;
    char *cmd = argv[0]; // 命令名,是 "bg" 或 "fg"
    char *arg = argv[1]; // 参数,如 "%1" 或 "1234"

    // 1. 参数检查
    if (arg == NULL) {
        printf("%s command requires PID or %%jobid argument\n", cmd);
        return;
    }

    // 2. 解析参数:判断是作业ID(%开头)还是进程ID
    if (arg[0] == '%') { // 作业ID,如 %1
        int jid = atoi(&arg[1]); // 将字符串转换为整数,跳过'%'
        job = getjobjid(jobs, jid);
    } else if (isdigit(arg[0])) { // 进程ID
        pid_t pid = atoi(arg);
        job = getjobpid(jobs, pid);
    } else {
        printf("%s: argument must be a PID or %%jobid\n", cmd);
        return;
    }

    // 3. 检查作业是否存在
    if (job == NULL) {
        printf("(%s): No such job\n", arg);
        return;
    }

    // 4. 处理 bg 命令
    if (!strcmp(cmd, "bg")) {
        // 发送 SIGCONT 信号,让停止的作业在后台继续运行
        kill(-(job->pid), SIGCONT);
        // 更新作业状态为后台运行
        job->state = BG;
        // 打印作业信息
        printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
    }
    // 处理 fg 命令的逻辑将在下一部分添加
}

关键点

  • atoi 函数用于将字符串参数转换为整数。
  • kill(-(job->pid), SIGCONT) 向整个作业进程组发送继续运行的信号。
  • 将作业状态更新为 BG(后台运行)。

实现 fg 内置命令

fg 命令用于将一个作业(无论是停止的还是后台的)带到前台运行。其实现与 bg 类似,但有两个关键区别:

  1. 需要将作业状态改为前台运行 (FG)。
  2. 需要调用 waitfg 函数等待该前台作业完成。

do_bgfg 中添加 fg 逻辑

在同一个 do_bgfg 函数中,我们添加对 fg 命令的处理:

void do_bgfg(char **argv) {
    // ... (前面的参数解析和作业查找代码与上面相同)

    // 检查作业是否存在
    if (job == NULL) {
        printf("(%s): No such job\n", arg);
        return;
    }

    // 处理 bg 命令
    if (!strcmp(cmd, "bg")) {
        kill(-(job->pid), SIGCONT);
        job->state = BG;
        printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
    }
    // 处理 fg 命令
    else if (!strcmp(cmd, "fg")) {
        // 发送 SIGCONT 信号,如果作业是停止的,则使其继续运行
        kill(-(job->pid), SIGCONT);
        // 更新作业状态为前台运行
        job->state = FG;
        // 等待这个前台作业完成
        waitfg(job->pid);
    }
}

关键点

  • 对于 fg 命令,无论作业之前是停止(ST)还是后台运行(BG),我们都发送 SIGCONT 信号。
  • 将状态设置为 FG 后,调用 waitfg 函数。这个函数是我们之前实现的,它会阻塞 shell,直到指定的前台进程不再存在。

测试与通过多个追踪用例

完成以上实现后,我们的 shell 应该能通过多个测试用例:

  • trace09: 测试 SIGTSTP 信号处理。
  • trace10: 测试 bg 命令。
  • trace11: 测试 fg 命令。
  • trace12-14: 这些用例检查信号是否发送给了整个进程组。由于我们在 kill 调用中使用了 -pid(负的进程ID),我们的实现应该能自动通过这些测试。这体现了向进程组发信号的重要性。

此时,我们已经完成了 Shell Lab 大部分核心功能,能够处理作业的创建、终止、停止、继续以及前后台切换。


总结

本节课中我们一起学习了 Shell Lab 的关键部分:

  1. 停止信号处理: 实现了 sigtstp_handler,将 SIGTSTP 信号转发给前台作业。
  2. 更新子进程状态: 在 sigchld_handler 中使用 WIFSTOPPEDWUNTRACED 来捕获并处理停止的作业,更新其状态并打印信息。
  3. 实现作业控制命令
    • bg 命令: 让已停止的作业在后台继续运行。
    • fg 命令: 将作业带到前台运行,并等待其完成。
  4. 进程组信号: 强调了使用 kill(-pid, sig) 向整个进程组发送信号的重要性,这是通过多个测试用例的关键。

通过这些实现,我们的 tsh 已经具备了基本的作业控制功能。在接下来的课程中,我们将处理错误输入和I/O重定向,使我们的 shell 更加健壮和完整。

025:Shell Lab 第六部分

在本节课中,我们将继续学习 Shell Lab。上一节我们实现了处理进程组(而非单个进程ID)的能力,并通过了 trace 14。本节我们将重点关注错误处理,学习如何让我们的 tiny shell 在用户输入无效或格式错误的命令时,能够像参考 shell 一样给出恰当的提示信息,并通过 trace 15 和 trace 16。

概述:处理错误输入

到目前为止,我们的 tiny shell 实现了基本功能,但尚未处理用户可能输入的错误命令。trace 15 专门用于测试 shell 的错误处理能力。我们将逐步分析 trace 15 的预期输出,并修改我们的代码,使其能够处理诸如“命令未找到”、“参数缺失”、“参数格式错误”以及“指定了不存在的进程或作业”等情况。

分析 trace 15 的预期行为

首先,让我们查看参考 shell 运行 trace 15 的预期输出,以明确我们需要实现哪些错误信息。

以下是 trace 15 的关键测试点:

  1. 执行一个不存在的程序(如 ./bogus),应输出 ./bogus: Command not found
  2. 调用内置命令 fgbg 时不提供任何参数,应输出 fg command requires PID or %jobid argumentbg command requires PID or %jobid argument
  3. 调用 fgbg 时提供一个非数字且不以 % 开头的参数(如 a),应输出 fg: argument must be a PID or %jobid
  4. 调用 fgbg 时提供一个不存在的进程ID(如 9999),应输出 (9999): No such process
  5. 调用 fgbg 时提供一个不存在的作业ID(如 %2),应输出 %2: No such job

我们的目标是让 tiny shell 的输出与参考 shell 完全一致。

实现错误处理

我们将按照 trace 15 的执行顺序,逐步在代码中添加错误处理逻辑。

1. 处理“命令未找到”错误

当用户尝试执行一个不存在的程序时,execve 系统调用会失败。我们需要在 eval 函数中捕获这个错误。

修改位置:eval 函数中执行外部命令的部分。

if (execve(argv[0], argv, environ) < 0) {
    printf("%s: Command not found\n", argv[0]);
    fflush(stdout);
    exit(1); // 子进程因错误退出
}

关键点: 打印错误信息后,必须调用 exit(1) 让子进程退出。如果不退出,这个失败的子进程会继续执行父进程(shell)的代码,导致意外行为。

2. 处理 fg/bg 命令缺少参数的错误

当用户输入 fgbg 命令但没有提供参数时,我们需要提示用户正确的用法。

修改位置:do_bgfg 函数开头。

void do_bgfg(char **argv) {
    // ...
    char *cmd = argv[0]; // 命令是 "fg" 或 "bg"
    char *arg = argv[1]; // 参数

    if (arg == NULL) {
        printf("%s command requires PID or %%jobid argument\n", cmd);
        fflush(stdout);
        return; // 直接返回,不执行后续逻辑
    }
    // ... 后续处理逻辑
}

3. 处理 fg/bg 命令参数格式错误

参数必须是纯数字(进程ID)或以 % 开头后跟数字(作业ID)。否则应报错。

修改位置:do_bgfg 函数中,检查参数格式。

    // 检查参数格式:既不是数字开头,也不是 % 开头
    if (!isdigit(arg[0]) && arg[0] != ‘%’) {
        printf("%s: argument must be a PID or %%jobid\n", cmd);
        fflush(stdout);
        return;
    }

4. 处理指定了不存在的进程ID

如果参数是数字,我们将其解析为进程ID(PID),并尝试在作业列表中查找。如果找不到对应的作业,说明该进程不存在或不属于当前 shell 的子进程。

修改位置:do_bgfg 函数中,处理 PID 的逻辑之后。

    if (isdigit(arg[0])) {
        // 参数是 PID
        pid_t pid = atoi(arg);
        struct job_t *job = getjobpid(jobs, pid);

        if (job == NULL) {
            printf("(%d): No such process\n", pid);
            fflush(stdout);
            return;
        }
        // 找到作业,继续处理...
    }

5. 处理指定了不存在的作业ID

如果参数以 % 开头,我们将其解析为作业ID(JID),并尝试在作业列表中查找。如果找不到,应报错。

修改位置:do_bgfg 函数中,处理 JID 的逻辑之后。

    else if (arg[0] == ‘%’) {
        // 参数是 JID
        int jid = atoi(&arg[1]); // 跳过 ‘%‘ 字符
        struct job_t *job = getjobjid(jobs, jid);

        if (job == NULL) {
            printf("%%%d: No such job\n", jid);
            fflush(stdout);
            return;
        }
        // 找到作业,继续处理...
    }

验证与通过 trace 15

完成以上修改后,重新编译并运行 trace 15。你的 tiny shell 输出应该与参考 shell 的输出逐行匹配(除了进程ID可能不同)。确保所有错误信息的措辞和格式完全一致,包括空格和标点符号。

通过 trace 15 意味着你的 shell 具备了基本的错误处理能力。

关于 trace 16 和 trace 17

上一节我们介绍了如何通过 trace 15。本节中我们来看看 trace 16 和 17。

  • trace 16 是一个综合性测试,它串联了之前所有 trace(1-15)中的操作。如果你的 shell 成功通过了之前的 trace,那么 trace 16 应该会自动通过。它测试了前台/后台作业管理、信号处理(SIGINT, SIGTSTP)以及我们刚刚实现的错误处理功能的组合使用。
  • trace 17 测试 shell 处理来自其他进程(而非终端)发送的 SIGTSTP(停止)和 SIGINT(中断)信号的能力。由于我们在之前的课程中已经正确实现了信号处理程序(sigchld_handler, sigint_handler, sigtstp_handler),它们能够处理来自任何源的这些信号,因此 trace 17 通常也会自动通过。

总结

本节课中我们一起学习了如何为我们的 tiny shell 添加健壮的错误处理机制。我们实现了以下功能:

  1. 捕获并报告“命令未找到”错误,并正确终止出错的子进程。
  2. 检查内置命令 fgbg 的参数是否存在、格式是否正确。
  3. 验证用户提供的进程ID(PID)或作业ID(JID)是否真实存在于当前 shell 的作业列表中,并对无效情况给出精确的错误信息。

通过这些修改,我们成功通过了 trace 15(错误处理)、trace 16(综合测试)和 trace 17(外部进程信号测试)。这些工作为 shell 的稳定性和用户体验打下了重要基础。在接下来的课程中,我们将开始实现更高级的功能:输入/输出重定向。

026:Shell Lab 第七部分 - 输入/输出重定向 🚀

在本节课中,我们将完成Shell Lab的最后部分,实现输入和输出重定向功能。这是构建一个功能完整shell的关键步骤,允许用户将命令的输出写入文件,或从文件读取输入。

概述

上一节我们完成了信号处理和作业控制。本节中,我们将重点实现shell的输入/输出重定向功能。具体来说,我们将修改eval函数并完善do_redirect辅助函数,以解析并处理命令行中的 <(输入重定向)和 >(输出重定向)符号。

实现重定向逻辑

我们的核心任务是在eval函数中,为每个派生的子进程添加重定向逻辑。以下是需要遵循的步骤:

  1. fork子进程之后,调用do_redirect函数。
  2. do_redirect函数将扫描参数向量,寻找重定向符号。
  3. 根据找到的符号(><),执行相应的文件操作。

以下是需要在eval函数中添加的代码位置:

// 在 fork() 之后,execve() 之前
if (fork() == 0) {
    // 子进程
    // ... 设置进程组等代码 ...

    // 调用重定向处理函数
    do_redirect(argv);

    // 执行命令
    execve(argv[0], argv, environ);
    // ... 错误处理 ...
}

完善 do_redirect 函数

原始的do_redirect函数仅移除了重定向符号。现在我们需要为其添加实际的文件操作逻辑。

输出重定向 (>)

当检测到输出重定向符号 > 时,我们需要:

  1. 打开(或创建)指定的文件,准备写入。
  2. 使用 dup2 系统调用,将标准输出重定向到新打开的文件。
  3. 关闭不必要的文件描述符。

以下是实现输出重定向的核心代码逻辑:

if (strcmp(argv[i], “>”) == 0) {
    // 设置打开文件的标志:只写、若不存在则创建、若存在则清空
    int flags = O_WRONLY | O_CREAT | O_TRUNC;
    // 设置文件权限:用户可读可写
    mode_t mode = S_IRUSR | S_IWUSR;
    // 打开文件,argv[i+1] 是文件名
    int fd = open(argv[i+1], flags, mode);
    if (fd < 0) {
        // 错误处理
        perror(“open”);
        exit(1);
    }
    // 将标准输出重定向到新打开的文件
    dup2(fd, STDOUT_FILENO);
    // 关闭原始的文件描述符
    close(fd);
    // 清理参数向量中的重定向符号和文件名
    argv[i] = NULL;
    argv[i+1] = NULL;
}

关键系统调用与常量:

  • open(path, flags, mode): 打开文件,返回文件描述符。
  • O_WRONLY, O_CREAT, O_TRUNC: 控制文件打开方式的标志。
  • S_IRUSR, S_IWUSR: 设置文件权限(用户读/写)。
  • dup2(oldfd, newfd): 复制文件描述符,用于重定向。
  • STDOUT_FILENO: 标准输出的文件描述符(通常为1)。

输入重定向 (<)

当检测到输入重定向符号 < 时,逻辑与输出重定向类似,但方向相反:

  1. 以只读方式打开指定的文件。
  2. 使用 dup2 将标准输入重定向到该文件。

以下是实现输入重定向的核心代码逻辑:

if (strcmp(argv[i], “<”) == 0) {
    // 以只读方式打开文件
    int fd = open(argv[i+1], O_RDONLY);
    if (fd < 0) {
        // 错误处理
        perror(“open”);
        exit(1);
    }
    // 将标准输入重定向到新打开的文件
    dup2(fd, STDIN_FILENO);
    // 关闭原始的文件描述符
    close(fd);
    // 清理参数向量
    argv[i] = NULL;
    argv[i+1] = NULL;
}

关键系统调用与常量:

  • O_RDONLY: 以只读方式打开文件的标志。
  • STDIN_FILENO: 标准输入的文件描述符(通常为0)。

测试与完成

实现上述逻辑后,Shell Lab的20个跟踪测试应该全部通过。最后三个测试(18, 19, 20)专门验证重定向功能:

  • Trace 18: 测试输出重定向 (>).
  • Trace 19: 测试输入重定向 (<).
  • Trace 20: 测试同时使用输入和输出重定向。

成功通过这些测试意味着你已经实现了一个具备基本功能的Unix shell。

课程总结与最终项目

本节课中我们一起学习了如何为我们的tsh shell实现输入/输出重定向功能。我们深入探讨了opendup2close等关键系统调用,并理解了文件描述符在重定向中的作用。

至此,Shell Lab的基础部分全部完成。作为本课程的最终项目,你们将有机会以小组形式,在现有tsh代码基础上进行扩展和改进。例如,可以添加更多内置命令、改进错误处理、或实现更复杂的特性(如管道|)。最终需要通过一个简短的演示来展示你们的成果和对shell工作原理的深入理解。

记住,所有改进必须保证原有的20个跟踪测试依然能够通过。祝你们在最后的项目中取得成功!

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