实验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
所指向数据类型的大小。如果 ptr
是 int*
类型且 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";
什么是可执行文件¶
可执行文件是一个由二进制组成的文件,可以在你的计算机上执行。可执行文件是由编译源代码创建的。
什么是 strlen
? strlen
是什么?¶
查看手册页以获取完整描述。在终端中输入以下内容
$ 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)上。