内存操作 上

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

355.2.3.1 内存操作(上)

/* 引入 printf 函数的声明。 */
#include <stdio.h>
/* 引入 memset 函数的声明。 */
#include <string.h>

/* 定义本节用于演示 memset 的测试函数。 */
void Memset_Test()
{
    /* 定义 8 字节的局部字符数组;视频末尾版本没有初始化它。 */
    char buf[8];
    /* 输出当前测试项目的提示文字。 */
    printf("memset test func\r\n");

    /* 视频末尾临时注释此调用,以演示读取未初始化数组的错误现象。 */
    // memset(buf, 0, 8);

    /* 依次遍历下标 0~7;循环变量 i 只在该循环内有效。 */
    for (char i = 0; i < 8; i++)
    {
        /* 把 buf[i] 按十进制整数输出;char 会先提升为 int。 */
        printf("%d ", buf[i]);
    }
    /* 输出回车和换行。 */
    printf("\r\n");
}

/* 程序从 main 函数开始执行。 */
int main()
{
    /* 输出整个内存函数实验的标题。 */
    printf("GNU Memory Function Test\r\n");

    /* 调用上面定义的 memset 测试函数。 */
    Memset_Test();
    /* 返回 0,表示程序正常结束。 */
    return 0;
}

上面是视频结束时实际显示的 main.c。本节只编写了 Memset_Test();课程配套源码中的 Memcmp_Test()、Memcpy_Test() 和 Memmove_Test() 属于后续课程内容,本节不提前收录。

视频最后故意注释了 memset(buf, 0, 8);,然后打印未初始化的 buf。这只能作为错误用法演示,不能用于正常程序:局部自动数组未显式初始化时,其元素具有不确定值,程序不能依赖打印结果。实际使用时应恢复该调用,最好把固定的 8 写成 sizeof(buf);相关原因在下一节继续讲解。

一、内存操作函数

视频把内存比作临时盛放食物的盘子,这个类比可以帮助入门,但并不完整。对 Linux 应用程序而言,应区分三个层次:

  1. C 语言对象:变量、数组和结构体等对象具有类型、大小、存储期和值;程序通过表达式访问这些对象。
  2. 进程虚拟地址空间:进程使用的是虚拟地址,内核和处理器的内存管理单元再把它们映射到物理页、文件映射或其他后备存储。
  3. 物理内存及缓存:数据在运行时可能经过寄存器、各级缓存和 DRAM。优化后的变量甚至可能只存在于寄存器中,或被编译器完全消除。

因此,“使用变量、指针和数组是在操作内存”适合作为直观理解,但 C 源码并不能直接说明某个值此刻一定存放在物理 DRAM 的哪个位置。

视频列举的准确函数名是 memset、memcmp、memcpy 和 memmove,均声明在 <string.h> 中。它们是 ISO C 标准库函数,不是 GNU 独有接口;GNU/Linux 上通常由所用 C 库和编译器共同提供、优化。

这四个函数都按字节数处理任意对象表示,不会寻找字符串末尾的 \0:

函数核心作用最重要的边界
memset将一段区域的前 n 个字节设为同一字节值目标区域必须至少可写 n 字节
memcmp按无符号字节顺序比较两段区域的前 n 个字节只保证返回值的正负或为零
memcpy从源区域复制 n 字节到目标区域源、目标重叠时行为未定义
memmove从源区域移动 n 字节到目标区域允许源、目标重叠

这些函数的通用参数使用 void *,所以可接收不同对象类型的地址;长度参数类型是 size_t,单位是字节。调用者必须自己确保指针有效、范围足够且长度计算正确,函数本身不知道数组边界。

原讲解中“它们一定由汇编编写,比 for 循环快很多倍,而且更安全”的说法过于绝对:

  • GCC 可能把这些标准函数识别为内建操作并直接内联,也可能生成库函数调用;glibc 等库还可能根据处理器能力选择不同实现,不等于所有平台都固定调用一段手写汇编。
  • 编译器也可能把语义等价的简单循环优化成批量操作;数据很少时,函数调用或内联代码的性能差异未必明显。应先选择语义正确的接口,再通过基准测试判断热点性能。
  • 这组函数不检查越界。长度错误会导致越界读写,memcpy 用于重叠区域也会产生未定义行为,所以不能称为天然“更安全”。

1.1 内存操作函数介绍

本节实验在 VS Code 中通过 Remote - SSH 打开 Ubuntu 虚拟机的 Desktop 目录,新建 GNU_Mem 目录和 main.c 文件。视频实际使用的编译、运行命令是:

cd ~/Desktop/GNU_Mem
gcc main.c -o main
./main

第一次编译失败,是因为终端还停留在 Desktop,而 main.c 位于其下的 GNU_Mem 目录。gcc 按给出的相对路径从当前工作目录查找文件;出现“没有那个文件或目录”时,应先用 pwd、ls 检查当前位置和文件名,而不是反复执行同一命令。

视频先编写最小 main 框架并打印 GNU Memory Function Test,确认编译链路正常,再加入 Memset_Test()。第一次运行新函数没有任何输出,是因为只定义了函数却未在 main 中调用;加入 Memset_Test(); 后,函数体才会在运行时执行。

1.1.1 memcpy 内存拷贝

原笔记中的“拷⻉”是文字识别错误,应为“拷贝”。其经典接口可写为:

void *memcpy(void *dest, const void *src, size_t n);

它把 src 开始的 n 个字节复制到 dest,并返回 dest。源区域只读,目标区域可写,两段区域都必须至少覆盖 n 个有效字节。若两段区域有任何重叠,标准不保证结果,应改用 memmove。

本节视频只在开头列举了 memcpy 的名称,没有编写或运行它;具体示例留在后续课程。

1.1.2 memmove 内存移动

其经典接口可写为:

void *memmove(void *dest, const void *src, size_t n);

memmove 同样复制 n 个字节并返回 dest,但即使源区域与目标区域重叠,也保证结果等同于先把源字节保存到不重叠的临时区域,再写入目标区域。这个描述规定的是结果语义,不代表实现一定真的申请一块临时数组。

“move”也不表示源区域会被自动清零;它完成复制后,源区域仍保留执行过程中形成的内容。本节没有演示该函数的代码。

1.1.3 memset 内存设置

视频通过 man memset 查到其声明。便于初学者阅读的经典形式是:

#include <string.h>

void *memset(void *s, int c, size_t n);

三个参数和返回值的含义如下:

项目含义
s要写入的内存区域起始地址
c要重复写入的值;写入前转换为 unsigned char
n要写入的字节数,不是数组元素个数
返回值原样返回 s;没有专门的失败返回值

视频先执行 memset(buf, 2, 8);。因为 buf 是含 8 个 char 的数组,每个元素恰好占 1 字节,所以打印得到八个 2。随后把第二个参数改为 0,便得到八个 0。

必须牢记 memset 是逐字节填充,不是“把每个任意类型的元素赋成 c”。例如在常见的 32 位 int 实现中:

int values[2];
memset(values, 2, sizeof(values));

每个字节都会变成 0x02,一个 int 的常见位模式因而是 0x02020202,通常并不等于整数 2。memset(..., 0, ...) 很适合清空字节缓冲区;但从严格可移植的 C 语义看,也不应想当然地用全零字节构造所有可能类型的有效“零值”,尤其是指针、浮点对象或含填充字节的复杂对象。

课程末尾注释初始化语句后,终端出现了 -96 80 -98 ... 一类不规则数字。准确解释是:

  • char buf[8]; 是未初始化的自动存储期数组,其元素值不确定;读取并依赖这些值不是有效的程序逻辑,在实际项目中应当禁止。
  • printf("%d", buf[i]) 会把 char 提升为 int。当前实现若把普通 char 视为有符号类型,最高位为 1 的字节可能显示成负数;这只能解释输出形式,不能让未初始化读取变得可靠。
  • C 语言不承诺这些字节来自哪里,不能根据一次输出认定它们是“上一轮程序”或“另一个进程”遗留的数据。现代操作系统必须隔离进程,重新提供给用户进程的页面不会任由其读取其他进程的敏感残留;当前栈区域也可能只是被本进程先前的调用使用过。
  • 编译器优化还可以利用“程序读取了不确定值”这一事实做出看似意外的变换,因此调试版偶然打印出某组数字,不代表以后仍会如此。

正常版本应初始化后再读取:

memset(buf, 0, sizeof(buf));

这里用 sizeof(buf) 可让填充长度随数组大小同步变化,避免把 8 散落在多处。仅当 buf 在当前作用域中仍是数组时,sizeof(buf) 才得到整个数组的字节数;若函数参数中的 buf 已退化为指针,sizeof(buf) 得到的是指针大小,而非调用者数组大小。

代码使用 \r\n 输出回车和换行。在 Linux 终端中通常只写 \n 就能正常换行;保留 \r\n 是课程源码的写法,并不是 memset 所必需的内容。

1.1.4 memcmp 内存比较

其经典接口可写为:

int memcmp(const void *s1, const void *s2, size_t n);

它按 unsigned char 的方式逐字节比较两段区域:全部相同返回 0;遇到第一个不同字节时,若 s1 的该字节较小则返回负值,较大则返回正值。标准只保证返回值的符号,不能依赖它一定等于两个字节直接相减的结果。

memcmp 比较的是对象表示,不等于对所有 C 类型进行“值比较”。结构体可能含有取值不确定的填充字节,浮点类型也可能存在多个表示,因此不应普遍用 memcmp 代替逐成员比较。常见实现也不保证恒定时间,不能直接用于需要抵抗计时侧信道的秘密数据比较。本节尚未编写该函数的实验代码。

1.2 内存操作函数查询

视频使用的是:

man memset

显示页标题中的 MEMSET(3) 表示这份页面位于手册第 3 节,并不是函数名带有数字 3。若同名页面不止一份,可显式指定节号:

man 3 memset
man 1 printf
man 3 printf

printf 既有第 1 节的命令行程序,也有第 3 节的 C 库函数;直接执行 man printf 通常按系统配置的搜索顺序显示第一份匹配页面。man 3 memset 页面会给出头文件、函数原型、参数语义、返回值、标准来源、线程安全属性和相关接口,比不注明来源的博客更适合作为接口依据。CSDN 是社区内容平台,不能称为“官方文档”。

Linux 手册的传统分节如下:

节号主要内容例子或说明
1用户命令和可执行程序printf(1)、ps(1)
2系统调用进入内核完成服务的接口
3库函数memset(3)、printf(3) 等 libc 接口
4特殊文件和设备常见为 /dev 下的设备接口
5文件格式和配置文件配置语法、文本格式等
6游戏游戏及趣味程序
7概览、约定及杂项协议、字符集、文件系统层次等
8系统管理命令如 mount(8),许多命令需要管理员权限

原讲解把 ps 归入第 8 节,但常见 Linux 系统中的 ps 是 ps(1)。标准 Linux man-pages 项目列出的传统分节是 1~8;某些系统或额外文档包会使用第 9 节保存内核例程,但它不是每个发行版默认都有的统一用户空间手册分节。

常用的查询补充命令包括:

man -a printf       # 依次查看所有同名手册页
man -f memset       # 显示名称和一句话说明,近似 whatis memset
apropos memory      # 按描述关键字搜索相关手册页

man 页面是否存在取决于系统安装了哪些程序和开发手册包;查不到某个库函数时,不代表该函数一定不存在,也可能只是缺少对应手册。

参考资料