Skip to content

实验1:C语言

MOLI:本次实验你将会学到什么

  • 指针与函数操作
  • 结构体与typedef
  • 头文件、空休止符、可执行文件、宏、段错误

练习1:hello

编译并运行一个C程序

在本实验中,我们将使用命令行程序 gcc 在 C 语言中编译程序。使用以下命令编译练习 1 的代码(确保你已 cd 到正确的目录中)

$ gcc ex1_hello.c

这会将 ex1_hello.c 编译为名为 a.out 的可执行文件 。如果你学过 CS61B 或有 Java 经验,则可以将 gcc 视为 javac 的 C 等价物。可以使用以下命令运行此文件:

$ ./a.out

可执行文件是 a.out,那么 ./ 是干什么用的呢?答:当你要执行可执行文件时,你需要在可执行文件名称前加上路径。该点是指 “当前目录”。双点 (..) 将引用上一级的目录。

gcc 有各种命令行选项,我们鼓励你探索。但是,在本实验中,我们将仅使用 -o,它用于指定 gcc 创建的可执行文件的名称。默认情况下,gcc 生成的可执行文件的名称为 a.out。你可以使用以下命令将 ex1_hello.c 编译为名为 ex1_hello 的程序,然后运行它。如果你不希望所有可执行文件都命名为 a.out,这将非常有用。

$ gcc -o ex1_hello ex1_hello.c
$ ./ex1_hello

此时,你应该会看到打印出的字符串 Hello World。如果编辑源代码(例如 ex1_hello.c),则必须使用 gcc 重新编译程序以生成新的可执行文件,否则可执行文件仍将运行旧的源代码。

使用你选择的编辑器 编辑 ex1_hello.c,并使程序打印出字符串 Hello 61C 而不是 Hello World。确保保存编辑后的文件,但不要重新编译。

使用 ./ex1_hello 运行 可执行文件。你仍应看到 Hello World,这是旧输出。

现在,使用 gcc -o ex1_hello ex1_hello.c 重新编译 你的程序,然后再次使用 ./ex1_hello 运行可执行文件。你现在应该看到 Hello 61C

知识回顾:指针

指针是一种变量,其值为另一个变量的内存地址。需要注意的是,每个变量声明后都存在于内存中,而内存中的每个元素都对应一个地址。可以将这种关系想象成数组:每个变量的值存储在特定的数组索引(地址)位置,而指向该变量的指针则是同一数组中的另一个变量,这个指针变量存储着它所指向变量的索引(地址)。

考虑以下示例:

int main() {
    int my_var = 20;
    int* my_var_p;
    my_var_p = &my_var;
}

在第一行,我们声明了一个名为 my_var 的 int 变量,然后为其赋值 20。该值 20 将放置在内存中的某个位置。

在第二行,我们声明了一个名为 my_var_p 的整型指针变量。需要注意的是,你也可以写成 int \*my_var_p,此时星号*紧贴着变量名而非类型名。

在第三行,我们将 my_var_p 赋值为 my_var 的地址。这是通过在 my_var 变量前使用取址运算符(&)实现的。此时,变量 my_var_p 中存储的值就是变量 my_var 在内存中的地址。

请注意,每当你想要更改 my_var 的值时,你都可以通过直接更改 my_var 来实现。

my_var += 2;

或者,你也可以通过解引用 my_var_p 来更改 my_var 的值

*my_var_p += 2;

简而言之, &x 获取 x 的地址,而 *x 获取地址 x 处的内容。

在本节中,假设 sizeof(int) == 4 。这是一个更完整的示例:

int main() {
    int my_var = 20;
    int* my_var_p;
    my_var_p = &my_var;

    printf("Address of my_var: %p\n", my_var_p);
    printf("Address of my_var: %p\n", &my_var);
    printf("Address of my_var_p: %p\n", &my_var_p);

    *my_var_p += 2;

    printf("my_var: %d\n", my_var);
    printf("my_var: %d\n", *my_var_p);
}

该代码的示例执行结果如下:

Address of my_var: 0xebafb32c
Address of my_var: 0xebafb32c
Address pf my_var_p: 0xebafb330
my_var: 22
my_var: 22

第一行输出了 my_var_p 的值,该值被赋为变量 my_var 的地址。

第二行验证了 my_var_p 确实等于 &my_var(即 my_var 的地址)。

第三行输出了 my_var_p 自身的地址。需要注意的是,由于 my_var_p 本身也是一个变量(类型为整型指针),因此它也必须存储在内存的某个位置。通过打印 &my_var_p,我们可以看到该变量在内存中的具体存储位置。

在前三次输出后,我们通过 *my_var_p 间接修改了 my_var 的值。由于 my_var_p 是指向 my_var 的指针(即它保存了 my_var 的地址),因此对 *my_var_p 的操作实际修改了该地址存储的内容。

第四行显示我们成功修改了 my_var ,现在的值是 22。

第五行进一步确认 *my_var_p 的值与 my_var 的值完全相同。

如果我们执行以下操作会发生什么: my_var_p += 2 ? my_var_p 是一个指向整型(int)的指针。已知 sizeof(int) = 4(即 int 类型占 4 字节),因此进行指针运算时,增加 2 个单位相当于增加 2 * 4 = 8 字节。此时,my_var_p 的值从 0x7fffebafb32c 更新为 0x7fffebafb334(原地址 + 8 字节)。
在先前执行 my_var_p += 2 之后, &my_var_p 的值是多少? my_var_p 自身的地址将保持不变,仍为 0x7fffebafb330
在先前执行了 my_var_p += 2 之后,如果我们尝试打印 *my_var_p 的值,会发生什么情况? 由于 my_var_p 的值已被修改,它现在指向内存中的一个不同位置。此行为是未定义的(undefined behavior):若该内存位置可访问,程序可能输出该地址上的垃圾数据(即未被初始化的随机值);若该内存属于受保护段(如操作系统保留区域),尝试访问将触发段错误(segmentation fault),导致程序崩溃。

练习2:指针与函数

使用你选择的编辑器 打开并编辑 ex2_pointers_and_functions.c 文件,补全其中的空白部分。如果遇到困难,可以随时回头参考前面的指针复习章节。

编译并运行 程序,检查输出是否符合预期。如果你需要复习 gcc ,请参考练习 1

知识回顾:数组

数组是一种固定长度的数据结构,能够存储一个或多个相同类型的元素。与链表不同,数组不会在添加元素时自动调整大小。

在 C 语言中,数组本质上是一个指向首个元素的指针。数组的每个元素都存储在内存中,且这些元素占据连续的内存空间(即相邻存放)。由于数组仅通过指向首个元素的指针表示,因此仅凭指针本身无法推断数组的长度。如果需要记录数组的长度,必须额外使用一个变量来存储。

通过指针算术(pointer arithmetic),可以访问数组中的不同元素。指针本质上是内存地址,因此对地址进行加减操作,可以获取数组中后续或先前元素的地址。

练习3:数组

使用你选择的编辑器打开并 编辑 ex3_arrays.c 文件,补全其中的空白代码部分。如果遇到困难,可以随时回顾之前关于数组的章节内容。

编译并运行 程序,检查输出结果是否符合你的预期。

仔细 阅读 程序输出。注意观察数组起始地址(首元素地址)与索引为 2 的元素地址之间的关系(提示:它们相隔两个字节)。

知识回顾:指针算术

在练习3中,当计算索引2的地址时,你的程序执行了基础的指针算术操作。这种方式有效是因为每个元素的大小为1字节(由于 int8_t 类型占1字节)。然而,实际开发中大多数类型占用的内存空间会超过1字节。

在进行指针算术时,C语言会自动根据指针的类型计算并添加正确的字节数。例如,若你编写 ptr + 5,C语言并非总是简单地将 ptr 的地址值加5,而是会添加 5 倍的 ptr 所指向数据类型的大小。如果 ptrint* 类型且 int 在内存中占4字节,那么 ptr + 5 会将 20 加到 ptr 中存储的地址上。。

练习4:指针算术

使用你选择的编辑器打开并编辑 ex4_pointer_arithmetic.c 文件,补全其中的空白代码部分。如果遇到困难,可以随时回顾之前关于数组的章节内容。你的实现方式应与练习3类似。

编译并运行程序,检查输出结果是否符合你的预期。

仔细阅读程序输出。注意观察数组起始地址(首元素地址)与索引为2的元素地址之间的关系,这一关系与前一个练习中的结果不同

知识回顾:字符

在 C 语言中,字符串以 字符数组char arrays)的形式表示。字符串是一种特殊的字符数组,因为它们总是以 空终止符\0)结尾。需要记住的是,C 中的数组本身不包含任何长度信息,因此空终止符的存在让我们能够确定字符串的结束位置。

为字符串分配内存时,必须确保足够的空间来存储字符串中的所有字符 和空终止符。否则可能会导致未定义行为。不过,数组的实际容量可以大于存储的字符串长度。

C 语言标准库提供了一系列字符串操作函数,例如:

  • strlen:通过计算空终止符前的字符数量来获取字符串长度
  • strcpy:将字符串从一块内存复制到另一块内存,逐字符复制直到遇到空终止符(空终止符也会被复制)

练习5:字符

使用你选择的编辑器打开并 编辑 ex5_strings.c 文件,补全其中的空白代码部分。如果遇到困难,可以随时回顾之前关于字符串的章节内容。

编译并运行 程序, 检查 输出结果是否符合你的预期。

练习6:结构体

使用你选择的编辑器打开并 编辑 ex6_structs.c 文件,补全其中的空白代码部分。

编译并运行 程序 ,检查 输出结果是否符合你的预期。

扩展内容:typedefs

有时,在声明结构体时可能会看到使用 typedef

typedef struct {
    int id;
} Student;

在这种情况下,可以直接使用 Student 作为类型名,而无需写成 struct Student。此处不深入探讨其原理,但如果你感兴趣,可以参考此链接了解更多细节。

FAQ

什么是头文件(header file)?

头文件允许你在不同源文件之间共享函数和宏定义。更多信息可参考 GCC 头文件文档

什么是空终止符(Null Terminator)?

空终止符是在 C 语言中用于表示字符串结束的字符。空终止符写作 '\0' 。空终止符的 ASCII 值是 0 。当你创建一个字符数组时,你应该在数组末尾加上一个空终止符,如下所示

char my_str[] = {'e', 'x', 'a', 'm', 'p', 'l', 'e', '\0'}; // 需显式添加

如果你使用双引号来创建字符串,空终止符会隐式添加,所以你不需要自己添加。例如:

char *my_str = "example";

什么是可执行文件

可执行文件是一个由二进制组成的文件,可以在你的计算机上执行。可执行文件是由编译源代码创建的。

什么是 strlenstrlen 是什么?

查看手册页以获取完整描述。在终端中输入以下内容

$ man strlen

要退出手册,请按 q

什么是宏?

宏是一段带有名称的文本。当代码中出现该名称时,预处理器会将名称替换为对应的文本。宏使用 #define 来定义。例如:

#define ARR_SIZE 1024
#define min(X, Y)  ((X) < (Y) ? (X) : (Y))

int main() {
    int arr1[ARR_SIZE];
    int arr2[ARR_SIZE];
    int arr3[ARR_SIZE];

    for (int i = 0; i < ARR_SIZE; ++i) {
        arr3[i] = min(arr1[i], arr2[i]);
    }
}

在这段代码中,预处理器会将 ARR_SIZE 替换为 1024,并且会将:

arr3[i] = min(arr1[i], arr2[i]);

替换为:

arr3[i] = ((arr1[i]) < (arr2[i]) ? (arr1[i]) : (arr2[i]));

宏的功能可以比上面示例更复杂。更多信息可以参考 GCC 文档

什么是段错误(Segfault)?

段错误发生在你尝试访问“不属于你”的内存时。可能导致段错误的情况包括:

  • 访问数组越界 注意,访问数组越界并不总是会导致段错误;触发段错误的具体索引具有一定的不可预测性。
  • 解引用空指针
  • 访问已被释放的指针(本实验范围内不涉及 free
  • 试图写入只读内存

例如,使用以下语法创建的字符串是只读的,这意味着创建后你不能更改它的值(不可变):

char *my_str = "Hello";

而使用以下语法创建的字符串是可变的:

char my_str[] = "hello";

为什么第一个字符串不可变而第二个字符串可变? 第一个字符串存储在数据段(只读内存)中,而第二个字符串存储在栈(stack)上。