Lecture 4 - CS50x 2023
欢迎!
- 在之前的几周,我们讨论了图像是由称为像素的更小的构建块组成的。
- 今天,我们将更详细地介绍构成这些图像的 0 和 1。
- 此外,我们将讨论如何访问存储在计算机内存中的底层数据。
内存
你可以想象在犯罪剧中,图像被不断增强,但这在现实中并不完全准确。实际上,如果你一直放大图像,你最终会看到像素。
你可以把图像想象成一个位图,其中0代表黑色,1代表白色。
RGB,即红、绿、蓝,是表示每种颜色数量的数字。 在 Adobe Photoshop 中,你可以看到如下设置:
注意红色、绿色和蓝色的数值如何影响最终的颜色。
从上图可以看出,颜色不仅仅由三个数值表示。在窗口的底部,有一个由数字和字母组成的特殊数值。例如,
255
被表示为FF
。这是为什么呢?
十六进制
十六进制 是一种计数系统,具有 16 个计数数值。 它们如下:
0 1 2 3 4 5 6 7 8 9 a b c d e f
请注意,
F
代表15
。十六进制也被称为 16进制。
在十六进制中,每一位的权重都是16的幂。
数字
0
表示为00
。数字
1
表示为01
。数字
9
由09
表示。数字
10
表示为0A
。数字
15
表示为0F
。数字
16
表示为10
。数字
255
表示为FF
,因为 16 x 15(或F
)是 240。再加 15 得到 255。这是使用两位十六进制系统可以计数的最高数字。十六进制很有用,因为它可以使用更少的数字来表示。 十六进制允许我们更简洁地表示信息。
地址
你可能还记得前几周我们用艺术手法展现的并发内存块。 将十六进制编号应用于每个内存块,你可以按如下方式可视化它们:
可以想象,上面的
10
既可以代表内存地址,也可以表示数值10
,这可能会造成混淆。 因此,按照惯例,所有的十六进制数通常会加上0x
前缀,如下所示:在终端窗口中输入
code addresses.c
,然后编写以下代码:#include <stdio.h>
int main(void)
{
int n = 50;
printf("%i\n", n);
}请注意,变量
n
在内存中存储的值是50
。程序存储这个值的过程可以用下图表示:
C语言提供了两个强大的内存操作符:
& 用于获取变量在内存中的地址。
* 用于访问内存中某个地址所存储的值。我们可以通过修改代码来应用这些知识:
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%p\n", &n);
}请注意,
%p
格式化输出可以用来显示内存地址。&n
的含义是“取变量n
的地址”。执行这段代码会返回一个以0x
开头的内存地址。
指针
指针 是用于存储变量地址的变量。简单来说,指针就是内存地址。
考虑以下代码:
请注意,
p
是一个指针,它存储了整数n
的内存地址。如下修改您的代码:
#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n;
printf("%p\n", p);
}这段代码和之前的代码效果相同。我们只是运用了新学的
&
和*
操作符。可以通过下图来理解这段代码:
注意指针的值看起来很大。实际上,指针变量通常占用8个字节的存储空间。
更准确地说,指针可以理解为一个指向另一个内存地址的地址:
为了演示
*
操作符的用法,请看下面的代码:#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n;
printf("%i\n", *p);
}请注意,
printf
语句打印的是指针p
所指向的内存地址中存储的整数值。
字符串
现在我们对指针有了一个基本的理解,我们可以进一步理解之前简化过的一些概念。
回顾一下,字符串本质上就是一个字符数组。例如,
string s = "HI!"
可以表示如下:可以想到,变量
s
也需要存储在内存中的某个位置。变量s
和字符串的关系可以用下图表示:注意,指针
s
存储的是字符串首字符的内存地址。如下修改您的代码:
#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
中每个字符的内存地址。同样地,你可以按如下方式修改你的代码:
#include <stdio.h>
int main(void)
{
char *s = "HI!";
printf("%s\n", s);
}注意这段代码会呈现从
s
的位置开始的字符串。
指针运算
你可以通过修改代码,用更长的形式来实现相同的功能,如下所示:
#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
指向的内存位置的字符。此外,你可以如下修改你的代码:
#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
位置的字符,依此类推。你能想象一下,如果你尝试访问
s + 50
这个内存地址会发生什么吗?黑客有时会试图访问他们不应该访问的内存区域。如果你尝试这样做,程序可能会出于安全考虑而退出。
比较字符串
字符串本质上是由其首字节地址标识的字符数组。
回想一下,上周我们提出我们不能使用
==
运算符比较两个字符串。使用
==
运算符比较字符串时,实际比较的是字符串在内存中的地址,而不是字符串的内容。因此,我们建议使用strcmp
。为了演示这一点,请创建名为
compare.c
的文件,并输入以下代码:#include <cs50.h>
#include <stdio.h>
int main(void)
{
// 获取两个字符串
char *s = get_string("s: ");
char *t = get_string("t: ");
// 比较字符串的地址
if (s == t)
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}注意到为两个字符串都输入
HI!
仍然会导致输出Different
。为什么这两个字符串看似不同?可以通过以下方式来理解原因:
为了清楚起见,你可以看到下图说明了指向内存中两个单独位置的指针:
如下修改你的代码:
#include <cs50.h>
#include <stdio.h>
int main(void)
{
// 获取两个字符串
char *s = get_string("s: ");
char *t = get_string("t: ");
// 打印字符串
printf("%s\n", s);
printf("%s\n", t);
}请注意,我们现在有两个单独的字符串,可能存储在两个单独的位置。
通过稍作修改,你可以查看这两个字符串在内存中的存储地址:
#include <cs50.h>
#include <stdio.h>
int main(void)
{
// 获取两个字符串
char *s = get_string("s: ");
char *t = get_string("t: ");
// 打印字符串的地址
printf("%p\n", s);
printf("%p\n", t);
}注意,在打印语句中,
%s
被改为了%p
。
复制
在编程中,一个常见的需求是将一个字符串复制到另一个字符串。
在您的终端窗口中,键入
code copy.c
并编写如下代码:#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
// 获取一个字符串
string s = get_string("s: ");
// 复制字符串的地址
string t = s;
// 将字符串的第一个字母大写
t[0] = toupper(t[0]);
// 打印字符串两次
printf("s: %s\n", s);
printf("t: %s\n", t);
}请注意,
string t = s
将s
的地址复制到t
。 这并没有达到我们想要的效果。 字符串没有被复制 - 只有地址被复制。在解决这个问题之前,重要的是要确保我们的代码不会出现段错误。段错误通常发生在我们尝试将
string s
复制到string t
时,而string t
尚未被分配内存。 我们可以使用strlen
函数来避免这个问题:#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
// 获取一个字符串
string s = get_string("s: ");
// 复制字符串的地址
string t = s;
// 将字符串的第一个字母大写
if (strlen(t) > 0)
{
t[0] = toupper(t[0]);
}
// 打印字符串两次
printf("s: %s\n", s);
printf("t: %s\n", t);
}请注意,
strlen
用于确保string t
存在。 如果string t
不存在,则不会进行复制操作。上面的代码可以用下图来表示:
请注意,
s
和t
仍然指向相同的内存块。 这不是字符串的真实副本。 相反,这是两个指向同一字符串的指针。为了能够制作字符串的真实副本,我们需要引入两个新的构建块。 首先,
malloc
允许程序员分配指定大小的内存块。 其次,free
允许程序员释放之前通过malloc
分配的内存块。我们可以修改我们的代码以创建字符串的真实副本,如下所示:
#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
// 获取一个字符串
char *s = get_string("s: ");
// 为另一个字符串分配内存
char *t = malloc(strlen(s) + 1);
// 将字符串复制到内存中,包括'\0'
for (int i = 0; i <= strlen(s); i++)
{
t[i] = s[i];
}
// 将副本大写
t[0] = toupper(t[0]);
// 打印字符串
printf("s: %s\n", s);
printf("t: %s\n", t);
}请注意,
malloc(strlen(s) + 1)
创建一个内存块,其长度为字符串s
的长度加一。 这确保了复制后的字符串包含空字符\0
。 然后,for
循环遍历字符串s
,并将每个字符复制到字符串t
的相应位置。实际上,我们的代码有个效率问题。请修改你的代码如下:
#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
循环的左边定义了。最好不要在for
循环的条件判断部分调用不必要的函数,因为这样会重复执行。把n = strlen(s)
移到左边后,strlen
函数就只会执行一次了。C语言提供了一个内置的字符串复制函数,叫做
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
循环是一样的。如果
get_string
或者malloc
出现问题,它们会返回NULL
。NULL
在内存中是一个特殊的值。你可以通过以下代码来检查NULL
的情况:#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(t)
告诉计算机,你已经用完了通过malloc
申请的这块内存,可以释放它了。
Valgrind
Valgrind
是一个工具,用来检查使用了malloc
的程序是否存在内存相关的问题。具体来说,它会检查你是否释放了所有通过malloc
分配的内存。考虑以下代码:
```
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *x = malloc(3 * sizeof(int));
x[0] = 72;
x[1] = 73;
x[2] = 33;
}
```请注意,运行此程序不会导致任何错误。虽然
malloc
用于为数组分配足够的内存,但代码没有释放通过malloc
分配的内存。如果您执行
make memory
命令后再执行valgrind ./memory
命令,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 现在不会导致任何内存泄漏。
垃圾值
当您向编译器请求一块内存时,不能保证该内存是空的。
您所分配的内存块很可能之前被其他程序使用过,因此可能包含垃圾数据或无意义的值。这是因为您获得了一块内存但没有对其进行初始化。例如,考虑以下代码:
#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
。当您不将内存块初始化为零或其他值时,始终最好注意垃圾值的可能性。
Binky 指针讲解视频
- 我们观看了斯坦福大学的视频,它帮助我们可视化和理解指针。
交换
在编程中,交换两个变量的值是很常见的需求。通常,这需要借助一个临时变量。 实践中,您可以输入
code swap.c
并编写如下代码来观察这一现象:#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
函数后,这些值也不会交换。 为什么?当您将值传递给函数时,您只是提供副本。 在前几周,我们讨论了作用域的概念。 在
main
函数的大括号{}
中定义的变量x
和y
,其作用域仅限于main
函数内部。 考虑下图:请注意,我们在此课程中未使用的全局变量位于内存中的一个位置。 各个函数调用时所使用的栈内存位于内存的另一个区域。
现在,考虑下图:
请注意,
main
函数和swap
函数拥有各自独立的栈帧,这意味着无法简单地通过值传递的方式在函数间修改变量的值。如下修改您的代码:
#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;
}请注意,变量不是按值传递,而是按引用传递的。也就是说,函数接收的是
a
和b
的地址。因此,swap
函数就能知道如何修改main
函数中a
和b
的值了。你可以这样理解:
溢出
- 堆溢出 指的是程序尝试写入超出堆内存分配范围的数据,导致覆盖了不应该被覆盖的内存区域。
- 栈溢出 指的是函数调用层级过深,导致栈空间耗尽。
- 这两种情况都属于缓冲区溢出。
scanf
在 CS50 中,我们创建了像
get_int
这样的函数来简化从用户获取输入的操作。scanf
是一个内置函数,可以获取用户输入。我们可以使用
scanf
轻松地重新实现get_int
,如下所示:#include <stdio.h>
int main(void)
{
int x;
printf("x: ");
scanf("%i", &x);
printf("x: %i\n", x);
}请注意,
scanf("%i", &x)
将用户输入的值存储到变量x
的内存地址中。但这个程序仍然无法正常运行,因为我们没有为字符串分配足够的内存空间。请考虑以下代码:
#include <stdio.h>
int main(void)
{
char *s;
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}请注意,因为字符串是特殊的,所以不需要
&
。我们可以如下修改我们的代码:
#include <stdio.h>
int main(void)
{
char s[4];
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}请注意,如果预先分配一个大小为 4 的字符数组,输入 'cat' 程序就能正常运行。但是,大于此值的字符串会产生错误。
文件
您可以读取和操作文件。关于文件操作的更多内容将在后续课程中介绍。现在,请参考以下
phonebook.c
的代码:#include <cs50.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
// 打开 CSV 文件
FILE *file = fopen("phonebook.csv", "a");
// 获取姓名和号码
char *name = get_string("Name: ");
char *number = get_string("Number: ");
// 打印到文件
fprintf(file, "%s,%s\n", name, number);
// 关闭文件
fclose(file);
}请注意,此代码使用指针来访问文件。
在运行代码前,请先创建名为
phonebook.csv
的文件。运行程序并输入姓名和电话号码后,这些信息将会被保存到 CSV 文件中。
总结
在本课程中,您学习了指针,指针使您能够访问和操作特定内存位置的数据。具体来说,我们深入研究了以下内容……
- 内存
- 十六进制
- 地址
- 指针
- 字符串
- 指针运算
- 字符串比较
- 复制
- Valgrind
- 垃圾值
- 交换
- 溢出
scanf
- 存储器 (Memory)
- 十六进制数 (Hexadecimal)
- 地址 (Addresses)
- 指针 (Pointers)
- 字符串 (Strings)
- 指针运算 (Pointer Arithmetic)
- 字符串比较 (Comparing strings)
- 复制 (Copying)
- Valgrind
- 垃圾值 (Garbage values)
- 交换 (Swap)
- 溢出 (Overflow)
scanf
下回见!