在过去的几周里,我们讨论过图像是由更小的构建单元——像素——组成的。
本周,我们将:
- 深入探讨构成图像的二进制数据
- 研究文件在内存中的存储方式
- 学习如何直接访问和操作计算机内存中的数据
- 掌握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 (视频) - 强烈推荐!形象解释指针。