视频封面

视频元信息

  • 时长: 约 1 小时 40 分钟(估计)

内容摘要(TL;DR)

本节课深入计算机内存底层,介绍十六进制表示、指针(地址)、字符串的真实本质(char *)、malloc/free 手动内存管理、valgrind 排错、传值与传引用、栈与堆、缓冲区溢出及文件 I/O,为 PSet 4 的图像滤镜(BMP 文件处理)做铺垫。


1. 图像表示与像素艺术 [03:05]

  • 图像在计算机中最终是 bit 的网格:以位图为例,每个像素用 0/1 表示黑白,形成如笑脸等图案。
  • 彩色图像需要更多字节:通常每个像素用 24 位(3 字节)分别表示 RGB(红、绿、蓝)。
  • 课程动机:通过学生用便签纸拼成的像素画(棕榈树小岛)和历届学生的 Google Sheets 像素作品,引出“底层操作数据(包括图像)”的主题。
  • 鼓励学生访问 cs50.ly/art 自行体验像素画。

2. 十六进制(Hexadecimal) [07:15]

  • 引入原因:内存地址、RGB 颜色等常用十六进制表示。
  • 定义:基数为 16,数字为 0-9 加上 A-F(10-15)。
  • 数学原理:与十进制/二进制同理,从右往左为 16^0、16^1、16^2…位。
  • 关键换算:
    • 16 进制 0A = 十进制 10,0F = 15。
    • 10(十六进制)= 16(十进制),不是“十”。
    • 两位十六进制最大值 FF = 255,即 16*15 + 1*15。
  • 为什么用两位十六进制:一个字节(8 bits)正好可以用两位十六进制表示,而二进制需要 8 位 → 更紧凑、易读。
  • 歧义消除:十六进制书写时前缀 0x(如 0x123),表示后面的数字是十六进制。

3. 地址运算符、指针与 printf 格式代码 [~10:00]

  • & 运算符:取变量地址(“address of”)。
  • * 运算符(解引用,dereference):根据地址访问对应内存单元(“go there”)。
  • %p 格式代码:打印指针/地址。

代码示例:addresses.c

int n = 50;
printf("%p\n", &n);   // 打印变量 n 的地址
int *p = &n;          // p 是指向 int 的指针,存 n 的地址
printf("%p\n", p);    // 打印 p 的值(即 n 的地址)
printf("%i\n", *p);   // 解引用 p,得到 50
  • 指针大小:现代 64 位系统上指针为 8 字节(64 位),因为物理内存可能超过 4GB,需要更多位来表示地址。
  • 易错点:C 中 * 有三种用途:乘法、声明指针、解引用;声明时带类型,解引用时不需要重复类型。

指针指到哪里?

指针本身也是变量,也有自己的地址(存在“指向指针的指针”),但本节课不深入。释放或使用前务必判断是否为空。


4. 字符串的本质:char * [~15:00]

  • 现实:C 中并没有 string 类型,string 是 CS50 库用 typedef 提供的“训练轮”。
  • 真实定义:字符串就是第一个字符的地址,并以 \0(NUL)结尾。
  • 内存布局:如 "HI!" 占用 4 个字节:H(0x123)、I(0x124)、!(0x125)、\0(0x126)。
  • printf("%s", s) 的工作方式:它把 s 当作地址,从该地址开始逐字符输出,直到遇到 \0。

CS50.h 内部原理

typedef char *string;

这只是为 char * 起了一个别名。

  • 从本周起:应使用 char * 而不是 string,去掉训练轮。

5. 字符串比较:为什么不能直接用 == [~20:00]

  • 数字比较:i == j 比较值,正确。
  • 字符串比较:s == t 比较的是两个变量中存储的地址,而不是字符内容。
  • 即使输入相同文本,两个字符串也会存储在不同的内存位置,所以 == 永远 false。
  • 正确方法:使用 strcmp(s, t),相等时返回 0。
    • 内部实现:用循环逐一比较字符,直到 \0,同时支持 < 和 > 判断字母序。
  • 打印地址可以看到两个字符串开头不同:
    printf("%p\n", s);
    printf("%p\n", t);
    

6. 字符串拷贝:malloc 与 free [~25:00]

错误的拷贝

string s = get_string("s: ");
string t = s;          // 只是拷贝地址
t[0] = toupper(t[0]);  // 同时修改了 s 指向的那个字符串
  • s 和 t 指向同一块内存 → 一个变了另一个也变。

正确的拷贝

char *t = malloc(strlen(s) + 1);   // +1 为 '\0' 预留空间
for (int i = 0, n = strlen(s); i <= n; i++)
    t[i] = s[i];                   // 含 '\0'
if (strlen(s) > 0)
    t[0] = toupper(t[0]);
free(t);
  • malloc(n):从堆中分配 n 字节,返回首地址;失败时返回 NULL。
  • free(p):释放之前分配的块,避免内存泄漏。
  • 防御性编程:使用 malloc 后必须检查是否为 NULL;访问字符串前检查长度是否大于 0。

内存泄漏

只 malloc 不 free 会造成内存泄漏。程序退出时系统会自动回收,但长驻服务(服务器、时钟程序)如果不释放内存,最终会耗尽内存崩溃。


7. 使用 Valgrind 检测内存错误 [~32:00]

  • valgrind 是检测内存问题的命令行工具。
  • 示例:
    int *x = malloc(3 * sizeof(int));
    x[1] = 72;
    x[2] = 73;
    x[3] = 33;   // 错误:数组越界,应为 x[0], x[1], x[2]
    free(x);
    
  • 运行 valgrind ./program 会报告:
    • Invalid write of size 4(越界写)
    • 12 bytes in 1 blocks are definitely lost(内存泄漏)
  • 要点:不需要完全读懂所有输出,重点看“Invalid read/write”和“definitely lost”,并关注给出的源码行号。

8. 垃圾值(Garbage Values)与 Binky 动画 [~35:00]

  • 垃圾值:未初始化的内存中残留之前程序留下的随机数据。
  • 示例中未初始化的 scores[1024] 打印出乱七八糟的随机数。
  • 危险性:如果对未初始化指针解引用,例如:
    int *x;  // 未初始化
    int *y;
    x = malloc(sizeof(int));
    *x = 42;
    *y = 13;  // y 没有分配内存,包含垃圾地址 → 很可能会崩溃
    
  • Stanford Binky 动画生动展示了指针/内存分配的必要性:
    • 分配指针 ≠ 分配被指向的对象(“pointy”)。
    • 解引用前必须确保指针指向已分配的有效内存。

9. 交换函数与“传值 vs 传引用” [~40:00]

失败版本(Pass by Value)

void swap(int a, int b) {
    int tmp = a;
    a = b;
    b = tmp;
}
  • 在 main 中调用 swap(x, y) 后,x, y 不变。
  • 原因:a, b 是 x, y 的拷贝,修改的是 swap 自己的栈帧。

成功版本(Pass by Address / Reference)

void swap(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}
// 调用:swap(&x, &y);
  • 传入的是 x 和 y 的地址,通过解引用真正修改原变量。

理解:传引用本质是传地址值;变量在栈上各有独立帧,只有通过指针才能跨函数修改调用方的内容。


10. 栈与堆(Stack & Heap) [~47:00]

  • 内存布局(从上到下):机器代码 → 全局变量 → 堆(向下增长) → 栈(向上增长)。
  • 堆(Heap):malloc/free 管理的内存区域;每次 malloc 取走一部分堆内存。
  • 栈(Stack):每次调用函数,压入一个栈帧(frame),包含局部变量、参数、返回地址;函数返回时栈帧被“弹出”。
  • 作用域的本质:局部变量存在于当前栈帧中,离开函数即失效(内存内容可能还在,但不安全)。
  • 溢出风险:
    • heap overflow:堆使用过多,向上溢出到栈。
    • stack overflow:函数调用过深(如无限递归),栈向下溢出到堆。
    • 二者统称缓冲区溢出(buffer overflow)。

11. 真实世界案例:CrowdStrike 事故 [~53:00]

  • 事件:CrowdStrike 安全软件更新导致全球系统崩溃,包括航空等行业,损失数百万美元。
  • 根因:一个off-by-one 错误——内容解释器期望 20 个值,却越界读取第 21 个值(访问超出数组末尾的内存),触发崩溃。
  • 教训:
    • C 语言不阻止程序员越界访问内存。
    • Python/Java 等语言内置了防护,避免此类错误。
    • 更新软件前应充分测试;自动更新有一定风险。

12. 实现 get_int:scanf 的用法与危险 [~56:00]

使用 scanf 读整数

int n;
printf("n: ");
scanf("%i", &n);   // 必须传地址,否则无法修改 n
printf("%i\n", n);

用 scanf 读字符串(危险示范)

char *s;
printf("s: ");
scanf("%s", s);    // s 未初始化,指向垃圾地址,很可能崩溃
  • 问题:不知道用户会输入多少个字符,指针没有分配任何内存。
  • 解决方案:使用 get_string(动态扩容)或固定大小数组+限制长度;
  • 分配固定大小(如 malloc(4))也不安全:用户输入超过 4 字节就会溢出。

为什么 get_string 更安全

get_string 会根据用户输入逐字符 malloc 扩大内存,确保不会越界;自己写 scanf 极难保证安全。


13. 文件 I/O:通讯录与复制文件 [~60:00]

写入 CSV 文件

FILE *file = fopen("phonebook.csv", "a");   // "w" 会覆盖原文件,"a" 追加
if (file == NULL) {
    return 1;
}
char *name = get_string("Name: ");
char *number = get_string("Number: ");
fprintf(file, "%s,%s\n", name, number);
fclose(file);
  • 注意:使用文件前必须判断 file != NULL。

实现简易 cp 命令

typedef uint8_t BYTE;  // 1 字节

FILE *src = fopen(argv[1], "rb");
FILE *dst = fopen(argv[2], "wb");

BYTE b;
while (fread(&b, sizeof(BYTE), 1, src) != 0) {
    fwrite(&b, sizeof(BYTE), 1, dst);
}
fclose(dst);
fclose(src);
  • 文件读取函数会自动在文件中“前进”,类似播放进度。

14. PSet 4 预告:BMP 图像滤镜 [~70:00]

  • 本周作业将面对 BMP 位图文件,图像是像素的网格。
  • 需要实现滤镜:
    • 灰度(grayscale):去掉颜色,保留亮度。
    • 褐色(sepia):老照片风格。
    • 水平翻转(reflect):镜像翻转像素。
    • 模糊(blur):通过合并周围像素值降低清晰度。
    • 边缘检测(edge detection,可选):找出物体轮廓。
  • 核心仍然是操作文件和内存中的字节,与本节内容直接相关。

5. 关键标签/关键词

CS50x,计算机科学导论,Memory,Pointer,十六进制,Hexadecimal,char *,String,malloc,free,Valgrind,Stack,Heap,Buffer Overflow,CrowdStrike,scanf,File I/O,BMP,Pass by Reference,Garbage Values,地址运算符


生成时间:2026-09-11 13:15:29 生成平台:VideoNote www.xygy.top