在过去的几周里,我们讨论过图像是由更小的构建单元——像素——组成的。

本周,我们将:

  • 深入探讨构成图像的二进制数据
  • 研究文件在内存中的存储方式
  • 学习如何直接访问和操作计算机内存中的数据
  • 掌握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颜色选择器

示例

  • 纯红色: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 前缀表示十六进制数
  • 示例:0xFF0x1A2B0x7FFF

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 = 0
  • 0xF = 15
  • 0x10 = 16
  • 0xFF = 255
  • 0x100 = 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

问题:这个变量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; 发生了什么?

  1. int *p:定义了一个指针变量,名字叫 p
    • int * 表示这个指针是专门用来存 int类型变量 的地址的。
  2. &n:获取了变量 n 的地址。
  3. =:把 n 的地址赋值给 p
  4. 结论:现在 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!";

  1. 内存中分配了一块连续空间存储字符 'H', 'I', '!', '\0'
  2. s 是一个指针,类型是 char *(指向字符的指针)。
  3. 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!

原因分析

因为 st指针

  • s 存储的是第一个 “HI!” 在内存中的地址(比如 0x123)。
  • t 存储的是第二个 “HI!” 在内存中的地址(比如 0x456)。
  • s == t 比较的是地址是否相同。
  • 因为它们存储在内存的不同位置,地址肯定不同,所以结果是 false

字符串比较示意图

正确做法:使用 strcmp(s, t),它会去这两个地址,逐个字符比较里面的内容


字符串复制与内存分配(malloc)

如果我们想复制一个字符串,简单的赋值是不行的:

    string s = get_string("s: ");
string t = s;  // 错误!这只是复制了指针(地址)

这样做之后,ts 指向同一个内存地址。如果你修改 t[0]s[0] 也会跟着变!这称为浅拷贝(Shallow Copy)。

真正的复制(深拷贝)

要制作一个真正的副本,我们需要:

  1. 向计算机申请一块的内存空间。
  2. 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;
}

结果xy 的值没有变

原因

  • C语言函数调用默认是传值(Pass by Value)。
  • main函数把 x副本传给了 swap
  • swap 里的 ab 只是局部变量,它们交换了,但完全没影响到 main 里的 xy

值传递示意图

正确做法:引用传递(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 拿到了 xy钥匙(地址)。
  • 它直接打开了 main 函数的”柜子”进行了交换。

引用传递示意图


内存布局:堆与栈(Heap vs Stack)

计算机内存被划分为不同的区域,其中两个最重要的是:

1. 栈(Stack)

  • 用途:存储函数的局部变量、参数。
  • 特点
    • 自动管理(函数结束自动释放)。
    • 空间较小。
    • 不仅向下增长(高地址 -> 低地址)。
  • 问题:如果递归太深(如无限递归),会耗尽栈空间,导致栈溢出(Stack Overflow)。

2. 堆(Heap)

  • 用途:存储动态分配的内存(malloc)。
  • 特点
    • 手动管理(需要 mallocfree)。
    • 空间很大。
    • 向上增长(低地址 -> 高地址)。
  • 问题:如果只借不还,会耗尽堆空间,导致堆溢出(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):从内存写数据到文件。
  • 这种方式可以复制任何类型的文件(文本、图片、视频),因为它操作的是最底层的字节

总结

本周我们揭开了内存的神秘面纱。我们学习了:

  1. 十六进制:更简洁地表示二进制数据。
  2. 地址与指针
    • & 取地址,* 解引用。
    • 指针就是存储地址的变量。
  3. 字符串的本质char *,即指向首字符的指针。
  4. 指针算术*(s+1) 等同于 s[1]
  5. 动态内存
    • malloc 申请,free 释放。
    • 避免内存泄漏和段错误。
  6. 内存交换:必须使用指针(引用传递)。
  7. 文件操作fopen, fclose, fread, fwrite

下周,我们将利用这些知识,学习数据结构(Data Structures),看看如何在内存中构建更复杂、更高效的数据组织形式!


参考资料