CS50 Week 4: 内存(Memory)
在过去的几周里,我们讨论过图像是由更小的构建单元——像素——组成的。
本周,我们将:
- 深入探讨构成图像的二进制数据
- 研究文件在内存中的存储方式
- 学习如何直接访问和操作计算机内存中的数据
- 掌握C语言中的指针概念
重要提示:本周的内容可能是整个课程中最具挑战性的部分之一。涉及的概念(特别是指针)需要时间消化和理解,这是完全正常的。不要气馁,慢慢来!
像素(Pixels)
什么是像素?
像素(Pixel)是图像的最小单位,是排列在上下、左右网格上的方形色点。
简单理解:
- 像素 = Picture Element(图片元素)
- 每个像素都是一个独立的彩色点
- 成千上万的像素组成了我们看到的图像
黑白图像
最简单的图像是黑白图像,可以用位图(bitmap)表示:
0代表黑色1代表白色

通过排列0和1,就能创建简单的图案和图像!
十六进制(Hexadecimal)
RGB颜色模型
彩色图像使用RGB(Red, Green, Blue)颜色模型:
- 每种颜色由三个数值组成
- R(红色)、G(绿色)、B(蓝色)
- 每个值的范围:0 到 255
在Adobe Photoshop中,RGB设置如下所示:

示例:
- 纯红色:RGB(255, 0, 0)
- 纯绿色:RGB(0, 255, 0)
- 纯蓝色:RGB(0, 0, 255)
- 白色:RGB(255, 255, 255)
- 黑色:RGB(0, 0, 0)
问题:为什么用十六进制?
注意图片底部有个特殊值:#FFFFFF
疑问:为什么255被表示成 FF?这就引出了十六进制的概念!
十六进制基础
十六进制(Hexadecimal)是一种以16为基数的计数系统,也叫 base-16。
十六进制的”数字”
十六进制使用16个符号:
0 1 2 3 4 5 6 7 8 9 A B C D E F
| 十六进制 | 十进制 |
|---|---|
| 0 | 0 |
| 1 | 1 |
| … | … |
| 9 | 9 |
| A | 10 |
| B | 11 |
| C | 12 |
| D | 13 |
| E | 14 |
| F | 15 |
数字表示对比
| 十进制 | 二进制 | 十六进制 |
|---|---|---|
| 0 | 0000 | 0 |
| 1 | 0001 | 1 |
| 9 | 1001 | 9 |
| 10 | 1010 | A |
| 15 | 1111 | F |
| 16 | 10000 | 10 |
| 255 | 11111111 | FF |
为什么255是FF?
计算过程:
FF (十六进制)
= F × 16¹ + F × 16⁰
= 15 × 16 + 15 × 1
= 240 + 15
= 255 (十进制)
位值理解:
- 十六进制的每一位代表16的某次方
- 右边第一位:16⁰ = 1
- 右边第二位:16¹ = 16
- 右边第三位:16² = 256
- …
为什么使用十六进制?
优势1:简洁
对比:
- 二进制:
11111111(8位) - 十六进制:
FF(2位)
用更少的字符表示相同的值!
优势2:与二进制的完美对应
关键:1个十六进制位 = 4个二进制位
二进制: 1111 1111
十六进制: F F
这让程序员在二进制和十六进制之间转换非常方便!
优势3:表示内存地址
计算机内存地址通常用十六进制表示:
0x前缀表示十六进制数- 示例:
0xFF、0x1A2B、0x7FFF
RGB颜色的十六进制表示:
- 纯红色:
#FF0000= RGB(255, 0, 0) - 纯绿色:
#00FF00= RGB(0, 255, 0) - 白色:
#FFFFFF= RGB(255, 255, 255) - 黑色:
#000000= RGB(0, 0, 0)
每两位十六进制数代表一个0-255的颜色分量!
内存(Memory)
内存的可视化
回顾之前的课程,我们把内存想象成一排连续的”格子”。现在让我们用十六进制来标记这些内存位置:

问题:看到10这个格子,它是:
- 内存地址10?
- 还是存储的值10?
容易混淆!
0x 前缀
为了避免混淆,约定:所有十六进制数都加上 0x 前缀

现在:
0x10明确表示:十六进制的10(等于十进制的16)10默认是十进制的10
示例:
0x0= 00xF= 150x10= 160xFF= 2550x100= 256地址与指针(Addresses & Pointers)
这是C语言中最强大但也最容易让人困惑的概念。让我们放慢速度,一步步拆解。
两个关键运算符
在C语言中,有两个与内存直接相关的”魔法”运算符:
1. 取地址运算符 & (Ampersand)
- 作用:获取某个变量在内存中的地址
- 口诀:”在这个变量在什么地方?”
2. 解引用运算符 * (Asterisk)
- 作用:访问某个地址指向的内容
- 口诀:”去这个地址,看看里面有什么?”
实践:获取变量的地址
让我们看一段简单的代码:
// addresses.c
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%i\n", n);
}
这段代码会在内存中开辟一个空间(4个字节)来存储整数50。

问题:这个变量n具体在内存的哪个位置(门牌号)?
我们可以修改代码来打印它的地址:
#include <stdio.h>
int main(void)
{
int n = 50;
// %p 是专门用来打印指针/地址的格式符 (pointer)
// &n 表示 "获取变量 n 的地址"
printf("%p\n", &n);
}
运行结果(示例):
0x7ffda0a476fc
这是一个十六进制数,代表了n在计算机内存中的具体位置。
什么是指针?
指针(Pointer)其实非常简单:它就是一个专门用来存储内存地址的变量。
- 普通变量(如
int n)存储的是数据(如 50)。 - 指针变量(如
int *p)存储的是地址(如 0x7ffda0a476fc)。
定义指针
int n = 50;
int *p = &n;
这行代码 int *p = &n; 发生了什么?
int *p:定义了一个指针变量,名字叫p。int *表示这个指针是专门用来存 int类型变量 的地址的。
&n:获取了变量n的地址。=:把n的地址赋值给p。- 结论:现在 p 指向了 n。
可视化理解
想象内存是一个巨大的储物柜:
n是一个柜子,里面放着数字 50。p是另一个柜子,里面放着一张纸条,纸条上写着n那个柜子的编号。

使用指针访问数据
既然 p 存了 n 的地址,我们就可以通过 p 找到 n。
#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n; // p 指向 n
// 打印 p 存储的地址
printf("%p\n", p);
// 打印 p 指向的地址里的值 (即 n 的值)
// *p 的意思是:"去 p 记录的地址看看,那里存了什么?"
printf("%i\n", *p);
}
输出:
0x7ffda0a476fc
50
总结:
p是地址。*p是该地址处的值。
字符串的真相(Strings are Pointers)
CS50库的”谎言”
在之前的课程中,我们使用string类型来定义字符串:
string s = "HI!";
但其实,C语言原生并没有string这个类型! 这只是CS50库为了方便初学者而定义的一个别名(typedef)。
字符串到底是什么?
字符串本质上是字符数组,而所谓的string变量,实际上是一个指向该字符数组第一个字符的指针。
让我们揭开面纱:
#include <stdio.h>
int main(void)
{
// 不用 cs50.h 库,使用 C 语言原生的写法
char *s = "HI!";
printf("%s\n", s);
}
详细解析 char *s = "HI!";:
- 内存中分配了一块连续空间存储字符
'H','I','!','\0'。 s是一个指针,类型是char *(指向字符的指针)。s存储了第一个字符'H'的地址。

验证字符串就是指针
我们可以打印出字符串中每个字符的地址来验证:
#include <stdio.h>
int main(void)
{
char *s = "HI!";
// 打印 s 本身(它存储的是 'H' 的地址)
printf("s: %p\n", s);
// 打印每个字符的地址
printf("&s[0]: %p\n", &s[0]);
printf("&s[1]: %p\n", &s[1]);
printf("&s[2]: %p\n", &s[2]);
printf("&s[3]: %p\n", &s[3]);
}
输出示例:
s: 0x402004
&s[0]: 0x402004 <-- 注意,s 和 &s[0] 是一样的!
&s[1]: 0x402005
&s[2]: 0x402006
&s[3]: 0x402007
关键发现:
s的值就是第一个字符s[0]的地址。- 字符在内存中是连续存储的(地址每次+1)。
指针算术(Pointer Arithmetic)
既然指针存储的是数字(地址),我们自然可以对它进行数学运算!
访问字符串的另一种方式
通常我们用 s[0], s[1] 来访问字符。但在底层,编译器是这样理解的:
s[0]等同于*s(去 s 指向的地址取值)s[1]等同于*(s + 1)(去 s 的下一个地址取值)s[2]等同于*(s + 2)(去 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]);
// 指针算术方式
printf("%c\n", *s);
printf("%c\n", *(s + 1));
printf("%c\n", *(s + 2));
}
这两种写法效果完全一样!*(s+1) 的意思是:“取出 s 里的地址,加上 1 个单位(这里是 1 个字节),然后去那个新地址看看里面有什么。”
字符串比较的陷阱
回顾之前的疑问:为什么不能用 == 比较两个字符串?
char *s = get_string("s: "); // 假设输入 "HI!"
char *t = get_string("t: "); // 假设输入 "HI!"
if (s == t) ... // 结果是 Different!
原因分析
因为 s 和 t 是指针!
s存储的是第一个 “HI!” 在内存中的地址(比如 0x123)。t存储的是第二个 “HI!” 在内存中的地址(比如 0x456)。s == t比较的是地址是否相同。- 因为它们存储在内存的不同位置,地址肯定不同,所以结果是
false。

正确做法:使用 strcmp(s, t),它会去这两个地址,逐个字符比较里面的内容。
字符串复制与内存分配(malloc)
如果我们想复制一个字符串,简单的赋值是不行的:
string s = get_string("s: ");
string t = s; // 错误!这只是复制了指针(地址)
这样做之后,t 和 s 指向同一个内存地址。如果你修改 t[0],s[0] 也会跟着变!这称为浅拷贝(Shallow Copy)。
真正的复制(深拷贝)
要制作一个真正的副本,我们需要:
- 向计算机申请一块新的内存空间。
- 把
s里的字符一个个复制到新空间里。
我们使用两个新函数:
malloc(size):Memory Allocation,向系统申请指定大小的内存。free(pointer):释放之前申请的内存(用完必须还!)。
#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h> // malloc 和 free 在这里
#include <string.h>
int main(void)
{
char *s = get_string("s: ");
if (s == NULL) return 1; // 安全检查
// 1. 申请内存
// strlen(s) + 1 是为了给结尾的 '\0' 留位置
char *t = malloc(strlen(s) + 1);
if (t == NULL) return 1; // 申请失败检查
// 2. 复制字符
// strcpy(t, s) 是标准库函数,相当于写了一个循环
strcpy(t, s);
// 3. 修改副本
if (strlen(t) > 0)
{
t[0] = toupper(t[0]);
}
// 4. 打印结果
printf("s: %s\n", s); // 原始字符串不变
printf("t: %s\n", t); // 副本首字母变大写
// 5. 释放内存
free(t);
return 0;
}
重要原则:有借有还,再借不难。每一个 malloc 都必须对应一个 free,否则会导致内存泄漏(Memory Leak)。
垃圾值(Garbage Values)
什么是垃圾值?
当你向计算机申请一块内存(例如定义一个数组)时,你得到的内存不一定是空的。
int scores[1024]; // 申请了1024个int的空间
这块内存之前可能被其他程序使用过,里面残留着旧数据(比如 848, 1927100, -42 等)。这些毫无意义的旧数据被称为垃圾值。
演示垃圾值
// 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]);
}
}
运行结果(示例):
4
848
0
1927100
...
教训:永远不要假设未初始化的变量是0!定义变量后,最好立即给它赋初值。
内存交换(Swapping)
这是一个经典的面试题:如何交换两个变量的值?
错误尝试:值传递(Pass by Value)
#include <stdio.h>
void swap(int a, int b);
int main(void)
{
int x = 1;
int y = 2;
printf("Before swap: x=%i, y=%i\n", x, y);
swap(x, y);
printf("After swap: x=%i, y=%i\n", x, y);
}
void swap(int a, int b)
{
int tmp = a;
a = b;
b = tmp;
}
结果:x 和 y 的值没有变!
原因:
- C语言函数调用默认是传值(Pass by Value)。
main函数把x的副本传给了swap。swap里的a和b只是局部变量,它们交换了,但完全没影响到main里的x和y。

正确做法:引用传递(Pass by Reference)
如果我们想改变 main 里的变量,必须告诉 swap 函数这些变量的地址。
#include <stdio.h>
// 接收两个整数的地址(指针)
void swap(int *a, int *b);
int main(void)
{
int x = 1;
int y = 2;
printf("Before swap: x=%i, y=%i\n", x, y);
// 传递 x 和 y 的地址
swap(&x, &y);
printf("After swap: x=%i, y=%i\n", x, y);
}
void swap(int *a, int *b)
{
// *a 意味着:去地址 a 看看,把那里的值取出来
int tmp = *a;
// 把地址 b 里的值,赋给地址 a 指向的位置
*a = *b;
// 把 tmp 的值,赋给地址 b 指向的位置
*b = tmp;
}
结果:交换成功!
原理:
swap拿到了x和y的钥匙(地址)。- 它直接打开了
main函数的”柜子”进行了交换。

内存布局:堆与栈(Heap vs Stack)
计算机内存被划分为不同的区域,其中两个最重要的是:
1. 栈(Stack)
- 用途:存储函数的局部变量、参数。
- 特点:
- 自动管理(函数结束自动释放)。
- 空间较小。
- 不仅向下增长(高地址 -> 低地址)。
- 问题:如果递归太深(如无限递归),会耗尽栈空间,导致栈溢出(Stack Overflow)。
2. 堆(Heap)
- 用途:存储动态分配的内存(
malloc)。 - 特点:
- 手动管理(需要
malloc和free)。 - 空间很大。
- 向上增长(低地址 -> 高地址)。
- 手动管理(需要
- 问题:如果只借不还,会耗尽堆空间,导致堆溢出(Heap Overflow)或内存泄漏。

scanf 的使用与风险
我们用过 get_int,那是CS50库封装好的。C语言原生的输入函数是 scanf。
获取整数
int x;
printf("x: ");
// 必须给地址 &x,因为 scanf 需要修改 x 的值
scanf("%i", &x);
获取字符串(危险!)
char s[4]; // 只分配了4个字节
printf("s: ");
// s 本身就是地址,不需要 &
scanf("%s", s);
风险:
- 如果用户输入 “hello”(5个字符 + \0 = 6字节)。
- 数组
s只有4个字节。 scanf会继续往后写,覆盖掉不属于s的内存!- 这可能导致程序崩溃(段错误)或安全漏洞(缓冲区溢出攻击)。
更安全的做法:使用 malloc 分配足够的空间,或者限制读取长度。
文件操作(File I/O)
C语言可以读写文件,这是持久化存储数据的关键。
写入文件(Phonebook示例)
#include <cs50.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
// 1. 打开文件
// "a" 表示 append(追加模式)
// 如果文件不存在,会自动创建
FILE *file = fopen("phonebook.csv", "a");
if (file == NULL) return 1; // 打开失败检查
char *name = get_string("Name: ");
char *number = get_string("Number: ");
// 2. 写入文件
// fprintf 是 "file printf",向文件打印
fprintf(file, "%s,%s\n", name, number);
// 3. 关闭文件
fclose(file);
}
读取文件(复制图片示例)
我们可以写一个程序 cp.c 来复制文件(即便是二进制图片)。
// cp.c - 复制文件
#include <stdio.h>
#include <stdint.h>
// 定义一个字节类型
typedef uint8_t BYTE;
int main(int argc, char *argv[])
{
// 检查参数
if (argc != 3)
{
printf("Usage: ./cp SOURCE DESTINATION\n");
return 1;
}
// 打开源文件(二进制读模式 "rb")
FILE *src = fopen(argv[1], "rb");
if (src == NULL) return 1;
// 打开目标文件(二进制写模式 "wb")
FILE *dst = fopen(argv[2], "wb");
if (dst == NULL) return 1;
BYTE buffer; // 缓冲区,每次读1个字节
// 循环读取,直到文件结束
// fread 返回成功读取的块数,为0表示读完了
while (fread(&buffer, sizeof(BYTE), 1, src) != 0)
{
// 写入目标文件
fwrite(&buffer, sizeof(BYTE), 1, dst);
}
// 关闭所有文件
fclose(dst);
fclose(src);
}
说明:
fread(&buffer, size, qty, file):从文件读数据到内存。fwrite(&buffer, size, qty, file):从内存写数据到文件。- 这种方式可以复制任何类型的文件(文本、图片、视频),因为它操作的是最底层的字节。
总结
本周我们揭开了内存的神秘面纱。我们学习了:
- 十六进制:更简洁地表示二进制数据。
- 地址与指针:
&取地址,*解引用。- 指针就是存储地址的变量。
- 字符串的本质:
char *,即指向首字符的指针。 - 指针算术:
*(s+1)等同于s[1]。 - 动态内存:
malloc申请,free释放。- 避免内存泄漏和段错误。
- 内存交换:必须使用指针(引用传递)。
- 文件操作:
fopen,fclose,fread,fwrite。
下周,我们将利用这些知识,学习数据结构(Data Structures),看看如何在内存中构建更复杂、更高效的数据组织形式!
参考资料:
- CS50 Week 4 官方笔记
- Pointer Fun with Binky (视频) - 强烈推荐!形象解释指针。