讲座 2
欢迎!
- 在上一节课中,我们学习了 C,一种基于文本的编程语言。
- 这周,我们将更深入地探讨额外的构建模块,以支持我们从底层向上学习编程的目标。
- 从根本上说,除了编程的基础知识外,这门课程是关于解决问题的。因此,我们还将进一步关注如何解决计算机科学问题。
- 到这门课程结束时,你将学会如何使用上述构建模块来解决大量的计算机科学问题。
- 我们认为计算机科学提供的许多解决方案是理所当然的。
阅读水平
- 我们将在本课程中解决的现实世界问题之一是理解阅读水平。
- 在你们一些同学的帮助下,我们展示了不同阅读水平的阅读材料。
- 本周,我们将量化阅读水平,作为你众多编程挑战之一。
调试
- 每个人在编码时都会犯错。
- 调试是定位和移除代码中错误的过程。
- 你在这个课程中将使用的一种调试技术称为橡皮鸭调试法,你可以与无生命的物体(或自己)交谈,帮助自己思考代码及其为何没有按预期工作。当你在代码中遇到挑战时,考虑如何大声地、literally地向一只橡皮鸭描述代码问题。如果你不想和一只小塑料鸭子说话,欢迎与身边的人交谈!
- 我们创建了 CS50 Duck 和 CS50.ai 作为工具,可以帮助你调试代码。
-
考虑以下代码:
// Missing #include for stdio.h int main(void) { printf("hello, world\n"); }注意
#include指令 forstdio.h缺失了。这个头文件是printf函数正常工作所必需的。没有它,编译器将无法识别printf函数并会产生错误。 -
类似地,考虑以下代码:
// Misspelled stdio.h #include <studio.h> int main(void) { printf("hello, world\n"); }注意
stdio.h被拼写成了studio.h。这个拼写错误会导致编译错误,因为编译器找不到名为studio.h的文件。正确的头文件名是stdio.h,它代表”标准输入/输出”。 -
我们可能忘记声明变量的类型:
// Missing cs50.h, variable's type, semicolon, %s, and second printf argument. #include <stdio.h> int main(void) { name = get_string("What's your name? ") printf("hello, world\n"); }注意存在多个错误。首先,
name的类型没有声明。其次,缺少cs50.h库来让我们使用string。第三,get_string调用后缺少分号。第四,printf语句实际上没有使用name变量。 - 有些错误会提示错误信息。其他是逻辑错误,不会提示信息,但会导致程序出现意外行为。
printf语句可用于调试代码。考虑以下内容:-
考虑来自上周的以下图片:

-
考虑以下故意插入了一个错误的代码:
// Buggy example for printf #include <stdio.h> int main(void) { for (int i = 0; i <= 3; i++) { printf("#\n"); } }注意这段代码打印了四个块而不是三个。
- 在终端窗口中输入
code buggy.c并编写上述代码。 - 运行这段代码,会出现四个砖块而不是预期的三个。
-
printf是调试代码的一个非常有用的方法。你可以将代码修改如下:// Buggy example for printf #include <stdio.h> int main(void) { for (int i = 0; i <= 3; i++) { printf("i is %i\n", i); printf("#\n"); } }注意这段代码在每次循环迭代中输出
i的值,以便我们可以调试代码。 -
运行这段代码,你会看到许多输出语句,包括
i is 0、i is 1、i is 2和i is 3。看到这些,你可能会意识到需要将代码修正如下:#include <stdio.h> int main(void) { for (int i = 0; i < 3; i++) { printf("#\n"); } }注意
<=已被替换为<。 -
这段代码可以进一步改进如下:
// Buggy example for debug50 #include <cs50.h> #include <stdio.h> void print_column(int height); int main(void) { int h = get_int("Height: "); print_column(h); } void print_column(int height) { for (int i = 0; i <= height; i++) { printf("#\n"); } }注意编译并运行这段代码仍然存在错误。
- 为了解决这个错误,我们将使用一个新工具。
- 调试中的第二种工具称为调试器,这是一种由程序员创建的软件工具,用于帮助追踪代码中的错误。
- 在 VS Code 中,已经为你提供了一个预配置的调试器,称为
debug50。 -
要使用这个调试器,首先通过点击代码行左侧(行号的左边)来设置一个断点。当你在那里点击时,你会看到一个红点出现。把它想象成一个停车标志,要求调试器暂停,以便你可以思考代码的这一部分发生了什么。

- 其次,运行
debug50 ./buggy。你会注意到调试器启动后,你的某行代码会以金色高亮显示。实际上,代码在这个位置暂停了。注意左上角显示的所有局部变量,包括h,它当前没有值。在窗口顶部,你可以点击step over按钮,它会继续执行你的代码。注意当你逐步执行循环时,i的值是如何增加的。 - 虽然这个工具不会告诉你错误在哪里,但它会帮助你放慢速度,逐步查看代码的运行情况。你可以使用
step into来进一步查看有问题的代码的详细信息。 - 调试的第三种方式是向一只橡皮鸭、无生命的物体或一个人描述你面临的问题以及你为解决该问题所采取的具体步骤,以此来发现你的错误。
- 最后,
,也就是 *CS50 Duck*,可以帮助你调试代码。
编译
- 回忆一下上周,你了解了编译器,这是一种专门的计算机程序,可以将源代码转换为计算机可以理解的机器码。
-
我们使用一种非常特殊的软件——称为编译器——将源代码转换为机器码。今天,我们将向你介绍一种编译器,它可以让你将编程语言 C 的源代码转换为机器码。
flowchart LR in["源代码"] --> BOX[" compiler "] BOX --> out["machine code"] -
例如,你可能有一个如下所示的计算机程序:
#include <stdio.h> int main(void) { printf("hello, world\n"); } -
编译器会将上述代码转换为如下所示的机器码:
01010100 01001000 01001001 01010011 00100000 01001001 01010011 00100000 01000011 01010011 00110101 00110000注意上述内容仅为示意。上述问题的机器码会要长得多。
-
VS Code,作为 CS50 学生提供的编程环境,使用一个名为
clang的编译器(代表”C Language Family Frontend”)。 - 你可以在终端窗口中输入以下命令来编译代码:
clang -o hello hello.c。 -
命令行参数在命令行中作为
-o hello hello.c提供给clang。 - 在终端窗口中运行
./hello,你的程序按预期运行。 -
考虑来自上周的以下代码:
#include <cs50.h> #include <stdio.h> int main(void) { string name = get_string("What's your name? "); printf("hello, %s\n", name); } - 要编译这段代码,你可以输入
clang -o hello hello.c -lcs50。 - 如果你输入
make hello,它会运行一个命令来执行 clang,创建一个你可以作为用户运行的输出文件。 - VS Code 已经预编程好,使得
make会与 clang 一起运行多个命令行参数,以方便你作为用户使用。 - 虽然以上内容仅作为示例展示,以便你更深入地理解编译代码的过程和概念,但在 CS50 中使用
make是完全没问题且符合预期的! - 编译包含四个主要步骤,包括以下内容:
-
首先,预处理阶段是将代码中以
#开头的头文件(如#include <cs50.h>)有效地复制并粘贴到你的文件中。在这一步中,来自cs50.h的代码被复制到你的程序中。类似地,就像你的代码包含#include <stdio.h>一样,计算机上某处stdio.h中的代码也被复制到你的程序中。这一步可以可视化如下:string get_string(string prompt); int printf(string format, ...); int main(void) { string name = get_string("What's your name? "); printf("hello, %s\n", name); } -
其次,编译阶段是将你的程序转换为汇编代码。这一步可以可视化如下:
... main: .cfi_startproc # BB#0: pushq %rbp .Ltmp0: .cfi_def_cfa_offset 16 .Ltmp1: .cfi_offset %rbp, -16 movq %rsp, %rbp .Ltmp2: .cfi_def_cfa_注册 %rbp subq $16, %rsp xorl %eax, %eax movl %eax, %edi movabsq $.L.str, %rsi movb $0, %al callq get_string movabsq $.L.str.1, %rdi movq %rax, -8(%rbp) movq -8(%rbp), %rsi movb $0, %al callq printf ... -
第三,汇编阶段涉及汇编器(编译器工具链中的一个工具)将汇编代码转换为机器码。这一步可以可视化如下:
01111111010001010100110001000110 00000010000000010000000100000000 00000000000000000000000000000000 00000000000000000000000000000000 00000001000000000011111000000000 00000001000000000000000000000000 00000000000000000000000000000000 ... -
最后,在链接步骤中,来自你包含的库的预编译机器码与你的代码合并。然后输出最终的可执行文件。
01111111010001010100110001000110 00000010000000010000000100000000 00000000000000000000000000000000 00000000000000000000000000000000 00000001000000000011111000000000 00000001000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 10100000000000100000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 01000000000000000000000000000000 00000000000000000100000000000000 00001010000000000000000100000000 01010101010010001000100111100101 01001000100000111110110000010000 00110001110000001000100111000111 01001000101111100000000000000000 00000000000000000000000000000000 00000000000000001011000000000000 11101000000000000000000000000000 00000000010010001011111100000000 00000000000000000000000000000000 00000000000000000000000001001000 ...
数组
- 在第 0 周,我们讨论了诸如
bool、int、char、string等数据类型。 - 每种数据类型需要一定数量的系统资源(以下是在 CS50 环境中的典型大小):
-
bool1 byte -
int4 bytes -
long8 bytes -
float4 bytes -
double8 bytes -
char1 byte -
string? bytes
-
-
在你的计算机内部,你拥有有限的内存可用。

-
在物理层面,在你的计算机内存上,你可以想象特定类型的数据是如何存储的。你可以想象一个
char,它只需要 1 字节的内存,看起来如下:
-
类似地,一个
int,需要 4 字节,看起来可能如下:
-
我们可以创建一个程序来探索这些概念。在你的终端中,输入
code scores.c并编写如下代码:// Averages three (hardcoded) numbers #include <stdio.h> int main(void) { // Scores int score1 = 72; int score2 = 73; int score3 = 33; // Print average printf("Average: %f\n", (score1 + score2 + score3) / 3.0); }注意右边的数字是浮点值
3.0,所以计算结果最终以浮点值呈现。 - 运行
make scores编译程序。然后运行./scores执行它。 -
你可以想象这些变量是如何存储在内存中的:

- 数组是一系列连续存储在内存中的值。
-
int scores[3]是一种告诉编译器在内存中提供三个连续的int大小位置来存储三个scores的方式。考虑到我们的程序,你可以将代码修改如下:// Averages three (hardcoded) numbers using an array #include <cs50.h> #include <stdio.h> int main(void) { // Scores int scores[3]; scores[0] = 72; scores[1] = 73; scores[2] = 33; // Print average printf("Average: %f\n", (scores[0] + scores[1] + scores[2]) / 3.0); }注意
scores[0]通过索引到名为scores的数组的0位置来检查该内存位置存储的值。 -
你可以看到,虽然上述代码可以工作,但我们仍然有机会改进代码。将代码修改如下:
// Averages three numbers using an array and a loop #include <cs50.h> #include <stdio.h> int main(void) { // Get scores int scores[3]; for (int i = 0; i < 3; i++) { scores[i] = get_int("Score: "); } // Print average printf("Average: %f\n", (scores[0] + scores[1] + scores[2]) / 3.0); }注意我们通过使用
scores[i]来索引scores,其中i由for循环提供。 -
我们可以简化或抽象化平均值的计算。将代码修改如下:
// Averages three numbers using an array, a constant, and a helper function #include <cs50.h> #include <stdio.h> // Constant const int N = 3; // Prototype float average(int length, int array[]); int main(void) { // Get scores int scores[N]; for (int i = 0; i < N; i++) { scores[i] = get_int("Score: "); } // Print average printf("Average: %f\n", average(N, scores)); } float average(int length, int array[]) { // Calculate average int sum = 0; for (int i = 0; i < length; i++) { sum += array[i]; } return sum / (float) length; }注意声明了一个名为
average的新函数。此外,注意声明了一个const或常量值N。最重要的是,注意average函数接受int array[],这意味着该函数可以接收一个数组作为参数。 - 数组不仅可以是容器:它们还可以在函数之间传递。
字符串
- 一个
string只是一个char类型值的数组:一个字符数组。 -
要探索
char和string,在终端窗口中输入code hi.c并编写如下代码:// Prints chars #include <stdio.h> int main(void) { char c1 = 'H'; char c2 = 'I'; char c3 = '!'; printf("%c%c%c\n", c1, c2, c3); }注意这将输出一个字符串。
-
类似地,对代码进行以下修改:
// Prints chars' ASCII codes #include <stdio.h> int main(void) { char c1 = 'H'; char c2 = 'I'; char c3 = '!'; printf("%i %i %i\n", c1, c2, c3); }注意通过将
%c替换为%i来打印 ASCII 码。 -
考虑以下图片,你可以看到字符串是如何以第一个字符开始,并以一个称为
NUL 字符的特殊字符结束的字符数组(注意:一个 L 的 NUL 是 ‘\0’ 字符,与两个 L 的 NULL 不同):
-
用十进制想象这个,你的数组看起来如下:

-
我们可以想象以上内容如下:
// Prints string #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%s\n", s); }注意所有字符都表示在一个
string中。 -
为了进一步理解
string的工作原理,将代码修改如下:// Treats string as array #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%c%c%c\n", s[0], s[1], s[2]); }注意
printf语句展示了来自名为s的数组的三个值。 -
和之前一样,我们可以将
%c替换为%i如下:// Prints string's ASCII codes, including NUL #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%i %i %i %i\n", s[0], s[1], s[2], s[3]); }注意这将打印字符串的 ASCII 码,包括 NUL。
-
假设我们想说
HI!和BYE!。将代码修改如下:// Multiple strings #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; string t = "BYE!"; printf("%s\n", s); printf("%s\n", t); }注意在这个例子中声明并使用了两个字符串。
-
你可以将其可视化如下:

-
我们可以进一步改进这段代码。将代码修改如下:
// Array of strings #include <cs50.h> #include <stdio.h> int main(void) { string words[2]; words[0] = "HI!"; words[1] = "BYE!"; printf("%s\n", words[0]); printf("%s\n", words[1]); }注意两个字符串都存储在一个
string类型的数组中。 -
我们可以将两个字符串合并成一个字符串数组。
#include <cs50.h> #include <stdio.h> int main(void) { string words[2]; words[0] = "HI!"; words[1] = "BYE!"; printf("%c%c%c\n", words[0][0], words[0][1], words[0][2]); printf("%c%c%c%c\n", words[1][0], words[1][1], words[1][2], words[1][3]); }注意创建了一个
words数组。这是一个字符串数组。每个单词都存储在words中。
字符串长度
-
编程中一个常见的问题,也许在 C 中更为具体,是求字符串的长度。我们如何在代码中实现这一点?在终端窗口中输入
code length.c并编写如下代码:// Determines the length of a string #include <cs50.h> #include <stdio.h> int main(void) { // Prompt for user's name string name = get_string("Name: "); // Count number of characters up until '\0' (aka NUL) int n = 0; while (name[n] != '\0') { n++; } printf("%i\n", n); }注意这段代码会循环直到找到 NUL 字符。
-
这段代码可以通过将计数抽象为函数来改进,如下所示:
// Determines the length of a string using a function #include <cs50.h> #include <stdio.h> int string_length(string s); int main(void) { // Prompt for user's name string name = get_string("Name: "); int length = string_length(name); printf("%i\n", length); } int string_length(string s) { // Count number of characters up until '\0' (aka NUL) int n = 0; while (s[n] != '\0') { n++; } return n; }注意一个名为
string_length的新函数会对字符进行计数,直到找到 NUL。 -
由于这在编程中是一个如此常见的问题,其他程序员已经在
string.h库中创建了获取字符串长度的代码。你可以通过如下修改代码来获取字符串的长度:// Determines the length of a string using a function #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Prompt for user's name string name = get_string("Name: "); int length = strlen(name); printf("%i\n", length); }注意这段代码使用了
string.h库,在文件顶部声明。此外,它使用了该库中一个名为strlen的函数,该函数计算传入的字符串的长度。 - 我们的代码可以站在前辈程序员的肩膀上,使用他们创建的库。
-
ctype.h是另一个非常有用的库。假设我们想要创建一个将所有小写字符转换为大写字符的程序。在终端窗口中,输入code uppercase.c并编写如下代码:// Uppercases a string #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { if (s[i] >= 'a' && s[i] <= 'z') { printf("%c", s[i] - 32); } else { printf("%c", s[i]); } } printf("\n"); }注意这段代码遍历字符串中的每个值。程序查看每个字符。如果字符是小写,则从该字符的 ASCII 值中减去 32 以将其转换为大写。
-
回顾我们上周的工作,你可能记得这个 ASCII 值表:
0 NUL 16 DLE 32 SP 48 0 64 @ 80 P 96 ` 112 p 1 SOH 17 DC1 33 ! 49 1 65 A 81 Q 97 a 113 q 2 STX 18 DC2 34 ” 50 2 66 B 82 R 98 b 114 r 3 ETX 19 DC3 35 # 51 3 67 C 83 S 99 c 115 s 4 EOT 20 DC4 36 $ 52 4 68 D 84 T 100 d 116 t 5 ENQ 21 NAK 37 % 53 5 69 E 85 U 101 e 117 u 6 ACK 22 SYN 38 & 54 6 70 F 86 V 102 f 118 v 7 BEL 23 ETB 39 ’ 55 7 71 G 87 W 103 g 119 w 8 BS 24 CAN 40 ( 56 8 72 H 88 X 104 h 120 x 9 HT 25 EM 41 ) 57 9 73 I 89 Y 105 i 121 y 10 LF 26 SUB 42 * 58 : 74 J 90 Z 106 j 122 z 11 VT 27 ESC 43 + 59 ; 75 K 91 [ 107 k 123 { 12 FF 28 FS 44 , 60 < 76 L 92 \ 108 l 124 | 13 CR 29 GS 45 - 61 = 77 M 93 ] 109 m 125 } 14 SO 30 RS 46 . 62 > 78 N 94 ^ 110 n 126 ~ 15 SI 31 US 47 / 63 ? 79 O 95 _ 111 o 127 DEL - 当 ASCII 小写字母(a-z)减去
32时,会得到该字母的大写版本。注意这只适用于 ASCII 字母 a-z,不适用于带重音或非 ASCII 字符。 -
虽然程序完成了我们想要的功能,但使用
ctype.h库有一种更简单的方法。将程序修改如下:// Uppercases string using ctype library (and an unnecessary condition) #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { if (islower(s[i])) { printf("%c", toupper(s[i])); } else { printf("%c", s[i]); } } printf("\n"); }注意程序遍历字符串的每个字符。将
s[i]传递给toupper函数。每个字符(如果小写)被转换为大写。 -
值得一提的是,
toupper会自动知道只转换小写字符为大写。因此,你的代码可以简化为如下:// Uppercases string using ctype library #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { printf("%c", toupper(s[i])); } printf("\n"); }注意这段代码使用
ctype库将字符串转换为大写。 - 你可以在手册页面上了解
ctype库的所有功能。
命令行参数
-
命令行参数是在命令行传递给程序的参数。例如,你在clang之后输入的所有语句都被视为命令行参数。你可以在自己的程序中使用这些参数! -
在你的终端窗口中,输入
code greet.c并编写如下代码:// Uses get_string #include <cs50.h> #include <stdio.h> int main(void) { string answer = get_string("What's your name? "); printf("hello, %s\n", answer); }注意这是向用户说
hello。 -
不过,能够在程序运行之前就获取参数不是很好吗?将代码修改如下:
// Prints a 命令行参数 #include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { if (argc == 2) { printf("hello, %s\n", argv[1]); } else { printf("hello, world\n"); } }注意这个程序既知道
argc(命令行参数的数量),也知道argv(在命令行作为参数传递的字符串数组)。 - 因此,使用这个程序的语法,执行
./greet David将导致程序输出hello, David。 -
你可以用以下方式打印每个命令行参数:
// Prints 命令行参数s #include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { for (int i = 0; i < argc; i++) { printf("%s\n", argv[i]); } }注意这段代码如何在一行中打印出每个命令行参数。第一个参数 (argv[0]) 总是程序本身的名字,后面跟着你在运行程序时提供的任何参数。
退出状态
- 当程序结束时,会向计算机提供一个特殊的退出码。
- 当程序无错误退出时,会向计算机提供一个状态码
0。通常,当发生导致程序结束的错误时,会向计算机提供一个状态码1。 -
你可以通过输入
code status.c并编写如下代码来演示这一点:// Returns explicit value from main #include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { if (argc != 2) { printf("Missing 命令行参数\n"); return 1; } printf("hello, %s\n", argv[1]); return 0; }注意如果你没有提供
./status David,你将得到退出状态码1。但是,如果你提供了./status David,你将得到退出状态码0。 - 你可以在终端中输入
echo $?来查看上一个运行命令的退出状态。 - 你可以想象如何使用上述程序的部分内容来检查用户是否提供了正确数量的命令行参数。
总结
在这节课中,你学习了关于编译以及数据如何在计算机中存储的更多细节。具体来说,你学到了……
- 大致了解编译器的工作原理。
- 如何使用四种方法调试代码。
- 如何在代码中使用数组。
- 数组如何在连续的内存部分中存储数据。
- 字符串只是字符数组。
- 如何在代码中与数组交互。
- 如何将命令行参数传递给程序。
下次见!