5.1. 第 4 周:内存¶
5.1.1. 欢迎!¶
今天,我们将卸下许多你在本课程入门阶段所使用的辅助轮。
在前几周,我们讨论了图像由称为像素的更小构建块组成。
本周,我们将更深入地探讨构成这些图像的零和一。特别是,我们将深入探究构成文件(包括图像)的基本构建块。
此外,我们将讨论如何访问存储在计算机内存中的底层数据。
在我们开始今天的内容时,请注意,本讲座涵盖的概念可能需要一些时间才能完全 理解 。
5.1.2. 像素艺术¶
像素是排列在上下、左右网格上的彩色方块,即单个点。
你可以将图像想象为位图,其中 0 代表黑色,1 代表白色。

5.1.3. 十六进制¶
RGB ,即 红、绿、蓝 ,是表示每种颜色含量的数值。在 Adobe Photoshop 中,你可以看到如下设置:

注意红色、蓝色和绿色的量如何改变所选颜色。
从上图可以看出,颜色并非仅由三个值表示。在窗口底部,有一个由数字和字符组成的特殊值。
255被表示为FF。这是为什么呢?十六进制 是一种拥有 16 个计数值的计数系统。它们如下所示:
0 1 2 3 4 5 6 7 8 9 A B C D E F
注意
F表示15。十六进制也被称为 base-16 。
在十六进制计数中,每一列都是 16 的幂。
数字
0表示为00。数字
1表示为01。数字
9由09表示。数字
10表示为0A。数字
15表示为0F。数字
16表示为10。数字
255表示为FF,因为 16 x 15(或F)是 240. 再加 15 得到 255。这是使用两位十六进制系统可以计数的最大数字。十六进制很有用,因为它可以用更少的数字来表示。十六进制允许我们更简洁地表示信息。
5.1.4. 内存¶
在过去的几周里,你可能还记得我们绘制的并发内存块示意图。为每个内存块应用十六进制编号后,你可以将其可视化如下:

你可以想象,对于上述
10块究竟表示内存中的一个位置还是值10,可能会产生混淆。因此,按照惯例,所有十六进制数通常都带有0x前缀,如下所示:
在终端窗口中,输入
code addresses.c并按如下方式编写代码:// Prints an integer #include <stdio.h> int main(void) { int n = 50; printf("%i\n", n); }
注意
n是如何以值50存储在内存中的。你可以按如下方式可视化该程序如何存储此值:

5.1.5. 指针¶
C 语言有两个与内存相关的强大运算符:
& Provides the address of something stored in memory. * Instructs the compiler to go to a location in memory.
addresses0.c
我们可以利用这一知识,按如下方式修改代码:
// Prints an integer's address #include <stdio.h> int main(void) { int n = 50; printf("%p\n", &n); }
注意
%p,它允许我们查看内存中某个位置的地址。&n可以直译为"n的地址”。执行此代码将返回一个以0x开头的内存地址。你可以在此处 here 下载此代码。指针 是存储某物地址的变量。最简洁地说,指针是你计算机内存中的一个地址。
考虑以下代码:
int n = 50; int *p = &n;
注意
p是一个指针,它包含整数n的地址。addresses1.c
按以下方式修改你的代码:
// Stores and prints an integer's address #include <stdio.h> int main(void) { int n = 50; int *p = &n; printf("%p\n", p); }
请注意,这段代码的效果与我们之前的代码相同。我们只是利用了关于
&和*运算符的新知识。你可以在此处 here 下载此代码。addresses2.c
为了说明
*运算符的用法,考虑以下示例:// Stores and prints an integer's address #include <stdio.h> int main(void) { int n = 50; int *p = &n; printf("%p\n", p); }
请注意,
printf行打印了该整数的地址。int *p创建了一个指针,其作用是存储一个整数的内存地址。你可以在此处 here 下载此代码。你可以将我们的代码可视化如下:

请注意,指针看起来相当大。事实上,指针通常存储为 8 字节的值。
p正在存储50的地址。你可以更准确地将指针可视化为一个指向另一个地址的地址:

5.1.6. 字符串¶
现在我们已经建立了指针的心智模型,可以揭开本课程早期提供的一层简化。
addresses4.c
按以下方式修改你的代码:
// Prints a string #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%s\n", s); }
注意,会打印一个字符串
s。你可以下载此代码 here 。回想一下,字符串只是一个字符数组。例如,
string s = "HI!"可以表示如下:
然而,
s究竟是什么?s存储在内存中的何处?正如你所想象的那样,s需要被存储在某个地方。你可以将s与该字符串的关系可视化如下:
注意,一个名为
s的指针告诉编译器字符串的第一个字节在内存中的位置。addresses5.c
按以下方式修改你的代码:
// Prints a string's address as well the addresses of its chars #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%p\n", s); printf("%p\n", &s[0]); printf("%p\n", &s[1]); printf("%p\n", &s[2]); printf("%p\n", &s[3]); }
注意上述代码打印了字符串中每个字符的内存地址
s。&符号用于显示字符串中每个元素的地址。运行此代码时,请注意元素0、1、2和3在内存中彼此相邻。你可以在此处下载此代码 here 。addresses6.c
同样地,你可以按如下方式修改代码:
// Declares a string with CS50 Library #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%s\n", s); }
注意,此代码使用
cs50.h库创建了一个字符串。你可以在此处 here 下载此代码。addresses7.c
拆掉辅助轮后,你可以再次修改代码:
// Declares a string without CS50 Library #include <stdio.h> int main(void) { char *s = "HI!"; printf("%s\n", s); }
请注意
cs50.h已被移除。字符串被实现为char *。此代码将呈现从s位置开始的字符串。此代码有效地移除了由cs50.h提供的string数据类型的辅助轮。这是原始 C 代码,没有 cs50 库的支撑结构。你可以在此处 here 下载此代码。你可以想象字符串作为一种数据类型是如何创建的。
上周,我们学习了如何使用结构体创建自己的数据类型。
cs50 库包含如下类型定义:
typedef char *string使用 cs50 库时,该类型定义是一种简化表示,允许使用名为
string的自定义数据类型。
5.1.7. 指针算术¶
指针算术是指能够对内存地址进行数学运算的能力。
addresses8.c
你可以修改代码,按如下方式打印字符串中的每个内存位置:
// Prints a string's chars #include <stdio.h> int main(void) { char *s = "HI!"; printf("%c\n", s[0]); printf("%c\n", s[1]); printf("%c\n", s[2]); }
请注意,我们是在
s的位置打印每个字符。你可以下载此代码 here 。addresses9.c
此外,你可以按如下方式修改代码:
// Prints a string's chars via pointer arithmetic #include <stdio.h> int main(void) { char *s = "HI!"; printf("%c\n", *s); printf("%c\n", *(s + 1)); printf("%c\n", *(s + 2)); }
请注意,位于
s处的第一个字符会被打印。然后,位于s + 1处的字符会被打印,依此类推。你可以下载此代码 here 。addresses10.c
同样地,考虑以下情况:
// Prints substrings via pointer arithmetic #include <stdio.h> int main(void) { char *s = "HI!"; printf("%s\n", s); printf("%s\n", s + 1); printf("%s\n", s + 2); }
请注意,此代码会打印从
s开始的各种内存位置中存储的值。你可以在此处 here 下载此代码。
5.1.8. 字符串比较¶
字符串只是由其首个字节位置标识的字符数组。
compare0.c
在课程前期,我们探讨了整数的比较。我们可以通过在终端窗口中输入
code compare.c来用代码表示这一点,如下所示:// Compares two integers #include <cs50.h> #include <stdio.h> int main(void) { // Get two integers int i = get_int("i: "); int j = get_int("j: "); // Compare integers if (i == j) { printf("Same\n"); } else { printf("Different\n"); } }
请注意,此代码从用户处获取两个整数并进行比较。你可以在此处 here 下载此代码。
对于字符串而言,不能使用
==运算符来比较两个字符串。尝试使用
==运算符比较字符串时,实际比较的是字符串的内存地址,而非其中的字符。因此,我们推荐使用strcmp。compare1.c
为了说明这一点,请按以下方式修改你的代码:
// Compares two strings' addresses #include <cs50.h> #include <stdio.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Compare strings' addresses if (s == t) { printf("Same\n"); } else { printf("Different\n"); } }
注意到为两个字符串都输入
HI!仍然会输出Different。你可以在此 here 下载此代码。为什么这些字符串看似不同?你可以使用以下内容来直观理解原因:

因此,上述
compare.c的代码实际上是在尝试判断内存地址是否不同,而非字符串本身。compare2.c
使用
strcmp,我们可以修正代码:// Compares two strings using strcmp #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Compare strings if (strcmp(s, t) == 0) { printf("Same\n"); } else { printf("Different\n"); } }
请注意,
strcmp在字符串相同时可以返回0。你可以从此处 here 下载此代码。compare3.c
为了进一步说明这两个字符串如何存在于两个不同的位置,请按如下方式修改你的代码:
// Prints two strings #include <cs50.h> #include <stdio.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Print strings printf("%s\n", s); printf("%s\n", t); }
请注意,我们现在有两个独立的字符串被存储,很可能位于两个不同的位置。你可以在此处 here 下载此代码。
compare4.c
通过稍作修改,你可以看到这两个存储字符串的位置:
// Prints two strings' addresses #include <cs50.h> #include <stdio.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Print strings' addresses printf("%p\n", s); printf("%p\n", t); }
注意,打印语句中的
%s已更改为%p。你可以在此处 here 下载此代码。
5.1.9. 复制与 malloc¶
编程中一个常见的需求是将一个字符串复制到另一个字符串。
copy0.c
在终端窗口中,输入
code copy.c并按如下方式编写代码:// Capitalizes a string #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { // Get a string string s = get_string("s: "); // Copy string's address string t = s; // Capitalize first letter in string t[0] = toupper(t[0]); // Print string twice printf("s: %s\n", s); printf("t: %s\n", t); }
注意,
string t = s将s的地址复制到了t。这并未实现我们的目标。字符串本身未被复制——仅复制了地址。此外,请注意其中包含了ctype.h。你可以在此处 here 下载此代码。你可以将上述代码可视化如下:

请注意,
s和t仍然指向相同的内存块。这并不是字符串的真实副本;相反,这是两个指向同一字符串的指针。copy1.c
在应对这一挑战之前,重要的是确保我们的代码不会发生 段错误 ,即避免尝试将
string s复制到string t,而string t并不存在。我们可以如下使用strlen函数来协助解决这一问题:// Capitalizes a string, checking length first #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { // Get a string string s = get_string("s: "); // Copy string's address string t = s; // Capitalize first letter in string if (strlen(t) > 0) { t[0] = toupper(t[0]); } // Print string twice printf("s: %s\n", s); printf("t: %s\n", t); }
注意
strlen用于确保string t有内容。如果没有,则不会复制任何内容。你可以在此处 here 下载此代码。为了能够制作字符串的真实副本,我们需要引入两个新的构建块。首先,
malloc允许你(程序员)分配一块特定大小的内存。其次,free允许你告诉编译器 释放 之前分配的那块内存。copy2.c
我们可以修改代码,如下所示创建字符串的真实副本:
// Capitalizes a copy of a string #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); // Allocate memory for another string char *t = malloc(strlen(s) + 1); // Copy string into memory, including '\0' for (int i = 0; i <= strlen(s); i++) { t[i] = s[i]; } // Capitalize copy t[0] = toupper(t[0]); // Print strings printf("s: %s\n", s); printf("t: %s\n", t); }
注意
malloc(strlen(s) + 1)创建了一块长度为字符串s加一的内存。这允许在我们的最终复制字符串中包含 空\0字符。然后,for循环遍历字符串s并将每个值赋给字符串t上的相同位置。你可以在此处 here 下载此代码。copy3.c
事实证明,我们的代码效率低下。请按如下方式修改你的代码:
// Capitalizes a copy of a string, defining n in loop too #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); // Allocate memory for another string char *t = malloc(strlen(s) + 1); // Copy string into memory, including '\0' for (int i = 0, n = strlen(s); i <= n; i++) { t[i] = s[i]; } // Capitalize copy t[0] = toupper(t[0]); // Print strings printf("s: %s\n", s); printf("t: %s\n", t); }
注意
n = strlen(s)现在定义在for loop的左侧。最好不要在for循环的中间条件中调用不必要的函数,因为它会反复运行。当将n = strlen(s)移到左侧时,函数strlen仅运行一次。你可以下载此代码 here 。copy4.c
C语言有一个名为strcpy的内置函数用于复制字符串。它可以按如下方式实现:// Capitalizes a copy of a string using strcpy #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); // Allocate memory for another string char *t = malloc(strlen(s) + 1); // Copy string into memory strcpy(t, s); // Capitalize copy t[0] = toupper(t[0]); // Print strings printf("s: %s\n", s); printf("t: %s\n", t); }
注意
strcpy完成了我们之前for循环所做的相同工作。你可以下载此代码 here 。copy5.c
在出现错误时,
get_string和malloc都会返回NULL,这是内存中的一个特殊值。你可以编写如下代码来检查此NULL条件:// Capitalizes a copy of a string without memory errors #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); if (s == NULL) { return 1; } // Allocate memory for another string char *t = malloc(strlen(s) + 1); if (t == NULL) { return 1; } // Copy string into memory strcpy(t, s); // Capitalize copy if (strlen(t) > 0) { t[0] = toupper(t[0]); } // Print strings printf("s: %s\n", s); printf("t: %s\n", t); // Free memory free(t); return 0; }
请注意,如果获得的字符串长度为
0或 malloc 失败,则返回NULL。此外,请注意free会让计算机知道你已完成对通过malloc创建的这块内存的使用。你可以在此处 here 下载此代码。
5.1.10. Valgrind¶
Valgrind 是一个工具,可用于检查你的程序中是否存在与
malloc相关的内存问题。具体来说,它会检查你是否free了所有已分配的内存。memory.c
考虑以下
memory.c的代码:// Demonstrates memory errors via valgrind #include <stdio.h> #include <stdlib.h> int main(void) { int *x = malloc(3 * sizeof(int)); x[1] = 72; x[2] = 73; x[3] = 33; }
注意,运行此程序不会导致任何错误。虽然
malloc用于为数组分配足够的内存,但代码未能free所分配的内存。你可以在此处下载此代码 here 。如果你输入
make memory后跟valgrind ./memory,你将收到一份来自 valgrind 的报告,该报告会指出你的程序导致内存丢失的位置。valgrind 揭示的一个错误是,我们试图在3的x处赋值33,而我们仅分配了大小为3(x[0]、x[1]和x[2])的数组。另一个错误是我们从未释放x。你可以修改代码以释放
x的内存,方法如下:// Demonstrates memory errors via valgrind #include <stdio.h> #include <stdlib.h> int main(void) { int *x = malloc(3 * sizeof(int)); x[0] = 72; x[1] = 73; x[2] = 33; free(x); }
请注意,现在再次运行 valgrind 将不再出现内存泄漏或错误
5.1.11. 垃圾值¶
当你向编译器请求一块内存时,无法保证这块内存是空的。
garbage.c
你分配的内存很可能之前已被计算机使用过。因此,你可能会看到 垃圾 或 无用值 。这是由于你获取了一块内存却未对其进行初始化所致。例如,考虑以下
garbage.c的代码:#include <stdio.h> #include <stdlib.h> int main(void) { int scores[1024]; for (int i = 0; i < 1024; i++) { printf("%i\n", scores[i]); } }
请注意,运行此代码将为你的数组在内存中分配
1024个位置,但for循环可能会显示其中并非所有值都是0。当你未将内存块初始化为零或其他值时,始终最好注意可能存在垃圾值。你可以在此处 here 下载此代码。
5.1.12. Binky 的指针乐趣¶
我们观看了一段 video from Stanford University ,它帮助我们可视化并理解指针。
5.1.13. 交换¶
swap0.c
在现实世界中,编程中一个常见的需求是交换两个值。自然,如果没有一个临时的存储空间,很难交换两个变量。在实践中,你可以输入
code swap.c并编写如下代码来观察这一过程:// Fails to swap two integers #include <stdio.h> void swap(int a, int b); int main(void) { int x = 1; int y = 2; printf("x is %i, y is %i\n", x, y); swap(x, y); printf("x is %i, y is %i\n", x, y); } void swap(int a, int b) { int tmp = a; a = b; b = tmp; }
请注意,虽然这段代码可以运行,但它无法正常工作。即使这些值被传递给了
swap函数,它们也没有发生交换。为什么?你可以下载此代码 here 。当你向函数传递值时,你提供的只是副本。
x和y的 作用域 仅限于 main 函数,因为代码目前是这样编写的。也就是说,在main函数的花括号{}中创建的x和y的值仅具有main函数的作用域。在我们上面的代码中,x和y是通过 值 传递的。考虑以下图像:

请注意,全局 变量(本课程尚未使用)存储在内存的某一处。各种函数则存储在另一块内存区域的
stack中。现在,考虑以下图像:

注意
main和swap拥有两个独立的 帧 或内存区域。因此,我们无法简单地将值从一个函数传递到另一个函数来修改它们。swap1.c
按以下方式修改你的代码:
// Swaps two integers using pointers #include <stdio.h> void swap(int *a, int *b); int main(void) { int x = 1; int y = 2; printf("x is %i, y is %i\n", x, y); swap(&x, &y); printf("x is %i, y is %i\n", x, y); } void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }
请注意,变量不是按 值 传递,而是按 引用 传递。也就是说,
x和y的地址被提供给函数。因此,swap函数可以知道从哪里修改主函数中的实际x和y。你可以下载此代码 here 。你可以将其可视化如下:

5.1.14. 溢出¶
堆溢出 是指堆发生溢出,触及了本不应访问的内存区域。
栈溢出 是指调用了过多函数,导致可用内存量被耗尽。
这两者都被视为 缓冲区溢出 。
5.1.15. scanf¶
在 CS50 中,我们创建了诸如
get_int之类的函数,以简化从用户获取输入的操作。scanf是一个可以获取用户输入的内置函数。get1.c
我们可以使用
scanf相当容易地重新实现get_int,如下所示:// Gets an int from user using scanf #include <stdio.h> int main(void) { int n; printf("n: "); scanf("%i", &n); printf("n: %i\n", n); }
请注意,值
n存储在位置n处,位于行scanf("%i", &n)。你可以在此处 here 下载此代码。get3.c
然而,尝试重新实现
get_string并不容易。考虑以下情况:// Dangerously gets a string from user using scanf with array #include <stdio.h> int main(void) { char s[4]; printf("s: "); scanf("%s", s); printf("s: %s\n", s); }
注意,由于 C 语言中的数组名充当指针,因此不需要
&。然而,该程序并非每次运行都能正常工作。程序中没有任何地方为我们的目标字符串分配足够的内存。事实上,我们不知道用户会输入多长的字符串!此外,我们也不知道该内存位置可能存在哪些垃圾值。你可以下载此代码 here 。此外,你的代码可以修改如下。然而,我们必须为字符串预先分配一定量的内存:
// Using malloc #include <stdio.h> #include <stdlib.h> int main(void) { char *s = malloc(4); if (s == NULL) { return 1; } printf("s: "); scanf("%s", s); printf("s: %s\n", s); free(s); return 0; }
注意,如果提供一个四字节长的字符串,你 可能 会收到错误。
简化我们的代码如下,我们可以进一步理解这个预分配的本质问题:
#include <stdio.h> int main(void) { char s[4]; printf("s: "); scanf("%s", s); printf("s: %s\n", s); }
请注意,如果我们预先分配一个大小为
4的数组,我们可以输入cat,程序也能正常运行。然而,比这更长的字符串 可能 会引发错误。有时,编译器或其运行的系统可能会分配比我们指示的更多的内存。但从根本上说,上述代码是不安全的。我们无法相信用户会输入一个适合我们预分配内存的字符串。
5.1.16. 文件输入/输出¶
phonebook0.c
你可以读取并操作文件。虽然此主题将在未来的周次中进一步讨论,但请考虑以下
phonebook.c的代码:// Saves names and numbers to a CSV file #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Open CSV file FILE *file = fopen("phonebook.csv", "a"); // Get name and number char *name = get_string("Name: "); char *number = get_string("Number: "); // Print to file fprintf(file, "%s,%s\n", name, number); // Close file fclose(file); }
请注意,此代码使用指针来访问文件。你可以在此处 here 下载该代码。
你可以提前创建一个名为
phonebook.csv的文件,或者下载 phonebook.csv 。运行上述程序并输入姓名和电话号码后,你会发现这些数据已持久化到你的 CSV 文件中。phonebook1.c
如果我们希望确保在运行程序之前
phonebook.csv存在,可以按如下方式修改代码:// Saves names and numbers to a CSV file, checking for NULL #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Open CSV file FILE *file = fopen("phonebook.csv", "a"); if (file == NULL) { return 1; } // Get name and number char *name = get_string("Name: "); char *number = get_string("Number: "); // Print to file fprintf(file, "%s,%s\n", name, number); // Close file fclose(file); }
请注意,该程序通过调用
return 1来防止NULL指针。你可以在此处 here 下载此代码。cp.c
我们可以通过输入
code cp.c并编写如下代码来实现自己的复制程序:// Copies a file #include <stdio.h> typedef unsigned char BYTE; int main(int argc, char *argv[]) { FILE *src = fopen(argv[1], "rb"); FILE *dst = fopen(argv[2], "wb"); BYTE b; while (fread(&b, sizeof(b), 1, src) != 0) { fwrite(&b, sizeof(b), 1, dst); } fclose(dst); fclose(src); }
请注意,此文件创建了我们自己的数据类型 BYTE,其大小等同于 uint8_t。然后,该文件读取一个
BYTE并将其写入文件。你可以在此处 here 下载此代码。BMP 文件也是我们可以检查和处理的数据集合。本周,你将在习题集中进行此类操作!
5.1.17. 总结¶
在本课中,你学习了能够提供访问和操作特定内存位置数据能力的指针。具体来说,我们深入探讨了…
像素艺术
十六进制
内存
指针
字符串
指针算术
字符串比较
复制
malloc 与 Valgrind
垃圾值
交换
溢出
scanf文件输入/输出
下次再见!
