29、指针与C标准库:string.h中的指针操作、stdlib.h中的指针操作、stdio.h中的指针操作

说实话,很多C语言学习者学到指针就卡住了。更可惜的是,他们不知道标准库里已经封装好了大量指针操作。我当年带项目时,有个同事自己手写字符串拷贝函数,写了三天还带bug。我过去看了一眼,问他:你为啥不用 memcpy? 他愣住了。

嗯,这就是我们今天要聊的。标准库里的指针操作,说白了就是C语言给你的「武器库」。你不需要重新造轮子,但你必须知道这些轮子怎么用、什么时候用、用错了会怎样。

核心观点:指针不是语法糖,它是内存的地址。标准库函数就是帮你安全、高效地操作这些地址的工具。

C标准库指针操作知识体系 string.h • memcpy 内存拷贝 • memmove 安全拷贝 • strcpy/strncpy 字符串拷贝 • strcat 字符串拼接 • strcmp 字符串比较 • strstr 子串查找 • memset 内存设置 • memcmp 内存比较 stdlib.h • malloc 动态分配 • calloc 清零分配 • realloc 重新分配 • free 释放内存 • qsort 快速排序 • bsearch 二分查找 • atoi/atof 字符串转数字 • strtol 字符串转长整型 stdio.h • fopen/fclose 文件打开关闭 • fread/fwrite 二进制读写 • fgets/fputs 行读写 • fscanf/fprintf 格式化 • fseek/ftell 文件定位 • feof/ferror 状态检查 • setvbuf 缓冲区设置 • tmpfile 临时文件 字符串 & 内存操作 动态内存 & 算法 文件 & IO 操作

29.1 string.h:内存与字符串的指针操作

string.h 是我用得最多的头文件,没有之一。嵌入式开发里,你天天跟缓冲区、协议包、字符串打交道。这里面的函数,本质上都是指针操作。

29.1.1 memcpy 与 memmove:拷贝的陷阱

先看 memcpy。原型很简单:

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

它把 src 指向的内存拷贝 n 个字节到 dest。但有个坑——它不处理内存重叠

我曾经在一个通信协议栈里,用 memcpy 把数据从缓冲区的后半部分拷贝到前半部分。结果数据全乱了。查了两天才发现,源和目标区域重叠了。memcpy 是逐字节往前拷贝的,重叠时会把源数据覆盖掉。

解决方案?用 memmove:

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

memmove 内部会判断 dest 和 src 的位置关系。如果 dest 在 src 后面,它会从后往前拷贝。这样就不会覆盖了。我个人习惯是:只要不确定是否重叠,一律用 memmove。性能损失微乎其微,安全收益巨大。

29.1.2 strcpy 与 strncpy:字符串拷贝的安全红线

strcpy 是初学者最爱用的函数之一。但也是缓冲区溢出漏洞的头号元凶。

char buf[10];
strcpy(buf, "Hello, this is a long string!"); // 缓冲区溢出!

你想想看,buf 只有 10 个字节,源字符串有 30 多个字节。strcpy 会一直拷贝直到遇到 '\0'。结果就是 buf 后面的内存被踩了。

我建议:永远用 strncpy 代替 strcpy

char buf[10];
strncpy(buf, "Hello, this is a long string!", sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0'; // 手动加结束符

注意:strncpy 如果源字符串比 n 长,它不会自动加 '\0'。所以一定要手动在末尾置零。这是很多老手也会犯的错。

29.1.3 strcmp 与 memcmp:比较的粒度

strcmp 比较字符串,遇到 '\0' 就停。memcmp 比较内存,按指定字节数比较。

char s1[] = "Hello\0World";
char s2[] = "Hello\0World";

if (strcmp(s1, s2) == 0) {
    // 这里会判定为相等,因为 strcmp 只比较到第一个 '\0'
}

if (memcmp(s1, s2, sizeof(s1)) == 0) {
    // 这里才会比较完整的 11 个字节
}

我在项目中遇到过一个问题:两个结构体用 memcmp 比较,结果总是对不上。后来发现结构体里有填充字节(padding),那些字节的值是未初始化的。所以比较结构体时,要么用 memset 清零后再赋值,要么逐个成员比较。

29.1.4 memset:快速清零

memset 用起来简单,但有个细节:它按字节赋值。

int arr[10];
memset(arr, 0, sizeof(arr)); // 正确:全部清零
memset(arr, 1, sizeof(arr)); // 错误:每个字节变成 0x01,不是每个 int 变成 1

说白了,memset 只适合置 0 或置 0xFF(全1)。想赋其他值,老老实实用循环。

29.2 stdlib.h:动态内存与算法中的指针

stdlib.h 里的指针操作,核心就是动态内存管理。嵌入式开发里,动态分配要谨慎,但不是不能用。

29.2.1 malloc、calloc、realloc:三兄弟的区别

函数 行为 初始化 典型场景
malloc 分配 n 字节 不初始化(垃圾值) 需要原始内存
calloc 分配 n 个大小为 size 的元素 全部置 0 数组、结构体
realloc 调整已有内存块大小 保留原有数据 动态扩容

我个人的习惯是:能用 calloc 就不用 malloc。因为清零后的内存,行为是可预测的。你想想看,malloc 返回的指针指向的内存里可能藏着上一个人的数据,万一你忘了初始化,那就是一个隐蔽的 bug。

我曾经在一个产品里用 malloc 分配了一个结构体数组,忘了初始化。结果有个字段恰好是之前某个函数留下的非零值,导致条件判断出错。那个 bug 查了整整一个下午。

29.2.2 free 与野指针

free 之后,指针不会自动置 NULL。这就是野指针的来源。

int *p = (int *)malloc(sizeof(int) * 10);
free(p);
// p 仍然指向原来的地址,但内存已经归还了
*p = 100; // 未定义行为!可能崩溃,可能静默写坏其他数据

我建议:free 之后立即把指针置 NULL

free(p);
p = NULL;

这样如果再误用 p,程序会立即崩溃(空指针解引用),而不是在某个遥远的地方出现诡异 bug。崩溃是好事,因为它让你立刻知道问题在哪。

29.2.3 qsort 与 bsearch:函数指针的经典应用

qsort 和 bsearch 都用到了函数指针。它们的第四个参数都是一个比较函数。

int compare_int(const void *a, const void *b) {
    return *(int *)a - *(int *)b;
}

int arr[] = {5, 2, 8, 1, 9};
qsort(arr, 5, sizeof(int), compare_int);

这里有个细节:比较函数的参数是 const void *,你需要先转换成实际类型再解引用。void * 是 C 语言里最通用的指针类型,说白了就是「我不知道你是什么类型,你自己看着办」。

注意:比较函数返回负数表示 a 小于 b,0 表示相等,正数表示 a 大于 b。不要写成 return *(int *)a > *(int *)b;,那样只返回 0 或 1,在某些实现里会导致排序结果不对。

29.3 stdio.h:文件操作中的指针

stdio.h 里的指针操作,核心是 FILE * 指针。它指向一个不透明的结构体,你不需要知道里面有什么,只需要通过标准函数操作它。

29.3.1 fopen 与 fclose:打开与关闭

FILE *fp = fopen("data.bin", "rb");
if (fp == NULL) {
    // 处理错误
    perror("fopen");
    return -1;
}
// ... 读写操作 ...
fclose(fp);

记住:每次 fopen 都要配对 fclose。否则文件描述符会泄漏。在嵌入式系统里,文件描述符是有限的资源,泄漏多了就打不开新文件了。

29.3.2 fread 与 fwrite:二进制读写

这两个函数直接操作内存块,非常适合读写结构体或数组。

typedef struct {
    int id;
    char name[32];
    float value;
} Record;

Record rec;
// 从文件读取一个 Record
fread(&rec, sizeof(Record), 1, fp);
// 写入一个 Record
fwrite(&rec, sizeof(Record), 1, fp);

但要注意:结构体可能有填充字节。如果你在不同平台间交换文件,填充字节可能不同。我建议用 #pragma pack(1) 或手动序列化。

29.3.3 fseek 与 ftell:随机访问

fseek 可以移动文件指针到任意位置。ftell 返回当前偏移量。

// 跳到文件末尾
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
// 跳回文件开头
fseek(fp, 0, SEEK_SET);

这个技巧常用来获取文件大小。但注意:ftell 返回 long 类型,在 32 位系统上最大只能表示 2GB 的文件。处理大文件时要用 fgetpos/fsetpos 或平台相关的函数。

29.3.4 setvbuf:缓冲区控制

默认情况下,stdio 函数会使用缓冲区。但在某些场景下,你可能想关闭缓冲或改成行缓冲。

// 关闭缓冲,每次写入都直接输出
setvbuf(fp, NULL, _IONBF, 0);

// 行缓冲,遇到换行符才输出
setvbuf(fp, NULL, _IOLBF, 0);

// 全缓冲,指定缓冲区大小
char buf[4096];
setvbuf(fp, buf, _IOFBF, sizeof(buf));

我在嵌入式调试时,经常把 stdout 设置成无缓冲模式。这样 printf 能立即输出,方便定位崩溃前最后一刻的状态。

总结一下:标准库里的指针操作,不是让你炫技的。它们是经过千锤百炼的工具。你用得对,代码就稳;用得不对,bug 就藏得深。记住三点:检查返回值、注意边界、及时释放。这三点做到了,指针就不再是噩梦。


公众号:蓝海资料掘金营,微信deep3321