实验1:C语言

截止日期:1月31日星期六晚上11:59:59 PT

在你来实验1之前,请确保你熟悉使用自己选择的文本编辑器在hive上编辑文件。本实验旨在让你熟悉基本的C语言概念,并为项目1做准备。

我们希望你的提交遵循问题的精神。如果你的提交不符合问题的精神(例如,硬编码正确输出而不应用本实验介绍的任何概念),我们可能会手动评分你的提交。如果你觉得可能违反问题的精神,请随时在Ed上提出私人问题或询问你的实验助教(TA)。

实验幻灯片


设置

你必须在hive机器上完成此实验。有关使用它们的复习,请参见实验0

在你的 labs directory, 拉取你在过去实验中可能做的任何更改:

git pull origin main

仍然在你的 labs 目录中,使用以下命令拉取此实验的文件:

git pull starter main

如果你遇到如下错误:

fatal: 'starter' does not appear to be a git repository
fatal: Could not read from remote repository.

请确保按如下方式设置starter远程仓库:

git remote add starter https://github.com/61c-teach/sp26-lab-starter.git

然后再次运行原始命令。

如果你遇到任何git错误,请查看常见错误页面。


练习1:你好!

编译和运行C程序

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

gcc ex1_hello.c

这将ex1_hello.c编译成一个名为a.out可执行文件。如果你上过CS61B或有Java经验,你可以把gcc看作是C语言版本的javac。可以使用以下命令运行此文件:

./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_varint变量,然后赋值为20。这个值20将被放置在内存中的某个位置。

第二行,我们声明了一个名为my_var_pint指针变量。请注意,你也可以写成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本身实际上是一个变量(变量类型是int指针),因此它也必须放置在内存中的某个位置。因此,打印&my_var_p可以让我们看到my_var_p变量在内存中的位置。

在前三个打印输出之后,我们使用*my_var_p间接改变了my_var的值。由于my_var_p是指向my_var的指针(即my_var_pmy_var的地址),执行*my_var_p允许我们修改my_var_p中地址处的内容。

第四行显示我们确实修改了my_var,因为值现在是22。 第五行确认*my_var_p确实等于my_var

如果我们执行以下操作会发生什么:my_var_p += 2

my_var_p是指向int的指针。sizeof(int) = 4。因此,指针算术将添加2 * 4 = 8。 my_var_p中的值更新为0x7fffebafb32c + 8 = 0x7fffebafb334

执行my_var_p += 2后,&my_var_p的值是多少?

my_var_p的地址将保持不变:0x7fffebafb330

执行my_var_p += 2后,如果我们尝试打印*my_var_p的值会发生什么?

由于my_var_p的值已更改,它现在指向内存中的不同位置。行为是未定义的。它要么打印该特定内存位置上的垃圾数据,要么如果您尝试访问受保护的内存段,程序将因段错误而崩溃。

练习2:指针和函数

编辑 ex2_pointers_and_functions.c(使用你选择的编辑器)并填空。如果卡住了,可以参考指针复习部分

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

复习:数组

数组是一种固定长度的数据结构,可以容纳一个或多个相同类型的元素。与列表不同,数组在添加元素时不会自动调整大小。 在C语言中,数组名称与第一个元素的地址是同义词。数组的每个元素都存储在内存中,并且它们存储在连续的内存位置(并排)。数组在函数中使用时会退化为指针。这个指针本身不足以推断数组的长度,因此如果需要跟踪数组的长度,必须使用另一个变量。 你可以对指针执行算术运算来访问数组的不同元素。回想一下,指针只是一个地址,因此如果你对地址进行加法或减法运算,就可以获得数组中后面或前面元素的地址。

练习3:数组

编辑 ex3_arrays.c(使用你选择的编辑器)并填空。如果卡住了,可以参考数组部分

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

阅读程序的输出。注意数组起始地址和索引2的地址之间的关系(提示:它们相差两个字节)。

复习:指针算术

在练习3中,你的程序在计算索引2的地址时执行了基本的指针算术。这之所以有效,是因为每个元素的大小是1字节(因为int8_t的大小是1字节)。然而,你将处理的大多数类型在内存中占用超过1字节。

执行指针算术时,C语言会自动考虑指针的类型并添加正确的字节数。例如,如果你写ptr + 5,C语言不会总是将5添加到ptr。相反,C语言将添加5乘以ptr指向的数据类型的大小。如果ptrint*,且int在内存中占用4字节,则ptr + 5将向ptr中保存的地址添加20。

练习4:指针算术

编辑 ex4_pointer_arithmetic.c(使用你选择的编辑器)并填空。如果卡住了,可以参考数组复习部分。你的解决方案应该与练习3类似。

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

阅读程序的输出。注意数组起始地址和索引2的地址之间的关系与上一个练习中的关系不同

复习:字符串

在C语言中,字符串表示为char数组。字符串是一种特殊类型的char数组,因为它们总是以空终止符(\0)结尾。回想一下,C语言中的数组不包含任何关于其长度的信息,因此空终止符允许我们确定字符串何时结束。

为字符串分配内存时,必须有足够的内存来存储字符串中的字符以及空终止符。否则,您可能会遇到未定义行为。然而,数组可能比它存储的字符串大。

C语言有一个用于操作字符串的函数库,例如:

  • strlen:通过计算空终止符之前的字符数来计算字符串的长度
  • strcpy:将字符串从一个内存位置复制到另一个内存位置,一次复制一个字符,直到到达空终止符(空终止符也会被复制)

练习5:字符串

编辑 ex5_strings.c(使用你选择的编辑器)并填空。如果卡住了,可以参考字符串复习部分

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

练习6:结构体

编辑 ex6_structs.c(使用你选择的编辑器)并填空。

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

可选:typedefs

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

typedef struct {
    int id;
} Student;

在这些情况下,您可以使用Student作为类型,而不是struct Student。我们不会在这里详细介绍,但如果您感兴趣,可以查看此链接

练习7:反思与反馈表

我们每周都在努力改进课程——请填写此调查,告诉我们您迄今为止在CS 61C课程中的体验!


提交

保存、提交并推送您的工作,然后在Gradescope上提交到实验1作业。


常见问题

什么是头文件?

头文件允许您在不同的源文件之间共享函数和宏。有关更多信息,请参阅GCC头文件文档

什么是空终止符?

空终止符是用于表示C语言中字符串结束的字符。空终止符写为'\0'。空终止符的ASCII值是0。当您创建字符数组时,应该像这样用空终止符终止数组

char my_str[] = {'e', 'x', 'a', 'm', 'p', 'l', 'e', '\0'};

如果您使用双引号创建字符串,空终止符会隐式添加,因此您不应该自己添加它。例如:

char *my_str = "example";

什么是可执行文件?

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

什么是strlen

有关完整说明,请参阅man手册页。在终端中输入以下内容

man strlen

要退出man手册页,按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文档中找到更多信息

什么是段错误?

当您尝试访问"不属于您"的内存时,就会发生段错误。有几种情况会导致段错误,包括:

  1. 访问数组越界。请注意,访问数组越界并不总是导致段错误。段错误发生的索引在某种程度上是不可预测的。
  2. 解引用空指针。
  3. 访问已被free的指针(free不在本实验的范围内)。
  4. 尝试写入只读内存。例如,使用以下语法创建的字符串是只读的。这意味着您无法在创建字符串后更改其值。换句话说,它是不可变的。
char *my_str = "Hello";

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

char my_str[] = "hello";

为什么第一个字符串是不可变的而第二个字符串是可变的?第一个字符串存储在内存的数据部分,这是只读的,而第二个字符串存储在堆栈上。