29、指针与C标准库:string.h中的指针操作、stdlib.h中的指针操作、stdio.h中的指针操作
说实话,很多C语言学习者学到指针就卡住了。更可惜的是,他们不知道标准库里已经封装好了大量指针操作。我当年带项目时,有个同事自己手写字符串拷贝函数,写了三天还带bug。我过去看了一眼,问他:你为啥不用 memcpy? 他愣住了。
嗯,这就是我们今天要聊的。标准库里的指针操作,说白了就是C语言给你的「武器库」。你不需要重新造轮子,但你必须知道这些轮子怎么用、什么时候用、用错了会怎样。
核心观点:指针不是语法糖,它是内存的地址。标准库函数就是帮你安全、高效地操作这些地址的工具。
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