如果你去参加 C 语言笔试,最常见的两类题是什么?第一类是"下面代码输出什么"——打印各种 sizeof 和 strlen 的结果;第二类是"下面指针运算的结果是什么"——让你推演地址加减后的值。这两类题之所以高频,是因为它们恰好触及了 C 语言中最容易混淆的两组概念:sizeof vs strlen,以及数组在不同语境下的身份切换。
本篇我们就正面攻克这两个知识点。同时,我会带你实现一个"玩具版 qsort"——用冒泡算法模拟标准库的通用排序函数。在这个过程中,你会真正体会到 void* 为什么要这样设计、逐字节交换内存为什么是通用算法的基石。这既是前四篇知识的综合运用,也是下一篇笔试题实战的热身。本篇还会专门补两块内容:指针运算的常见陷阱和指针与字符串的纠缠——它们和 sizeof/strlen 一样,是笔试的必考地带。
sizeof和strlen
sizeof 和 strlen 只差一个字母,但本质上一个是操作符、一个是库函数,完全在两条轨道上运行。
sizeof 是 C 语言的关键字/操作符(不是函数!),它在编译期就能确定结果,只关心操作数占用多少内存字节,完全不关心里面存的是什么值:
#include <stdio.h>
int main()
{
int a = 10;
// 三种写法,效果相同
printf("%zu\n", sizeof(a)); // 计算变量a的大小 → 4
printf("%zu\n", sizeof a); // 括号可以省略(操作符特权)
printf("%zu\n", sizeof(int)); // 计算int类型的大小 → 4
// sizeof不关心值是什么
int b = 0;
int c = 999999;
printf("%zu\n", sizeof(b)); // 4,不关心值是0
printf("%zu\n", sizeof(c)); // 4,不关心值是999999
return 0;
}sizeof 的几个关键特性:它是操作符而非函数(括号可以省略说明一切);它在编译期计算(VLA 除外);它对表达式只推导类型大小而不实际计算。这就是为什么 sizeof(a[3]) 即使 a 只有 3 个元素也不会越界崩溃——编译器只看 a[3] 的类型是 int,返回 4,根本没执行内存访问。
"不实际计算表达式"这一点值得用一个经典例子彻底证明——sizeof 里的 i++ 不会执行:
#include <stdio.h>
int main()
{
int i = 0;
int n1 = sizeof(i++); // sizeof 只看类型,不计算表达式
printf("i = %d, n1 = %zu\n", i, n1); // i = 0!(i++ 没执行)
int n2 = i++; // 对比:普通表达式会执行
printf("i = %d, n2 = %d\n", i, n2); // i = 1
return 0;
}同理,sizeof(fun()) 不会调用函数,sizeof(arr[100]) 不会访问越界内存——sizeof 永远不会产生运行时副作用。这是笔试超级喜欢考的隐藏点。
strlen 则是 <string.h> 中的库函数,在运行期工作。它从传入的地址开始,一个字节一个字节往后找,直到遇到 \0,统计 \0 之前的字符个数:
#include <stdio.h>
#include <string.h>
int main()
{
// 1. 正常使用
char str1[] = "abc"; // 实际存储: 'a','b','c','\0'
printf("strlen(str1) = %zu\n", strlen(str1)); // 3
printf("sizeof(str1) = %zu\n", sizeof(str1)); // 4 (含\0)
// 2. 没有\0会怎样?
char str2[3] = {'a', 'b', 'c'}; // 没有\0!
// printf("%zu\n", strlen(str2)); // ❌ 越界!会一直找到内存中的某个\0
// 结果是不确定的,可能是3,可能是100,可能崩溃
printf("sizeof(str2) = %zu\n", sizeof(str2)); // 3 (就是数组大小)
return 0;
}| 维度 | sizeof | strlen |
|---|---|---|
| 身份 | C 语言操作符 | C 标准库函数 |
| 头文件 | 不需要 | <string.h> |
| 计算时机 | 编译期(大多数情况) | 运行期 |
| 功能 | 计算类型/变量占用的内存字节数 | 统计 \0 之前的字符数 |
包含 \0 | 包含 | 不包含 |
没有 \0 时 | 正常(只算类型大小) | 越界查找,行为不确定 |
| 参数 | 类型或表达式 | const char* 指针 |
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[] = "abc"; // 4字节: 'a','b','c','\0'
char arr2[3] = {'a', 'b', 'c'}; // 3字节: 'a','b','c'(无\0)
printf("=== arr1 (\"abc\") ===\n");
printf("sizeof(arr1) = %zu\n", sizeof(arr1)); // 4
printf("strlen(arr1) = %zu\n", strlen(arr1)); // 3
printf("\n=== arr2 ({'a','b','c'}) ===\n");
printf("sizeof(arr2) = %zu\n", sizeof(arr2)); // 3
// strlen(arr2) 是未定义行为,结果不可预测
return 0;
}内存对比一目了然:
arr1: ┌───┬───┬───┬───┐
│ a │ b │ c │\0 │ sizeof=4, strlen=3
└───┴───┴───┴───┘
arr2: ┌───┬───┬───┐
│ a │ b │ c │ sizeof=3, strlen=???(越界)
└───┴───┴───┘
strlen会从这里一直往后找\0...
再强调一个和 sizeof 相关的类型细节:sizeof 的结果类型是 size_t(无符号整数,通常就是 unsigned long/unsigned long long),打印必须用 %zu。用 %d 打印在某些平台上会把大数组大小打成负数(类型不匹配,未定义行为)。同理,strlen 的返回类型也是 size_t。
还有 VLA 例外:C99 的变长数组(int n; scanf("%d",&n); int a[n];)的 sizeof(a) 是运行期才确定的(因为 n 运行时才知道)——这是 sizeof 唯一的运行期场景。
思考题(sizeof 与 strlen)
sizeof("abc")是多少?(4,含\0;strlen("abc")才是 3)sizeof里的i++会执行吗?(不会,sizeof 只推导类型不计算表达式)strlen对没有\0的数组为什么危险?(运行期一直往后找\0,越界访问,未定义行为)
参考答案与详解(sizeof 与 strlen)
sizeof("abc")是 4,strlen("abc")是 3:sizeof计算的是这个字符串字面量所占的字节数,字符串字面量"abc"实际存储是'a','b','c','\0'共 4 个字节,所以sizeof("abc")= 4;strlen统计\0之前的字符个数(不包含\0),所以是 3。一个包含\0、一个不包含,这是两者最直观的区别。sizeof里的i++不会执行:sizeof是编译期操作符,它只推导操作数的类型,从不计算表达式本身。sizeof(i++)只看i++的类型是int、返回sizeof(int) = 4,那个i++根本不会真的执行——所以i的值保持原样(还是 0)。同理sizeof(fun())不会真正调用fun()、sizeof(arr[100])不会真的越界访问。记住:sizeof不产生任何运行时副作用,这是笔试高频陷阱。strlen对没有\0的数组为什么危险:strlen是运行期库函数,它从接收的地址开始一个字节一个字节地往前找,直到遇到\0才停。如果数组里没有\0,strlen就会一直读下去,越过数组边界去访问相邻的未知内存,直到在别处偶然碰到一个\0才停止——这是未定义行为,返回的长度是垃圾值,甚至可能触发段错误。所以char arr[3] = {'a','b','c'};(无\0)绝不能让strlen处理。
指针运算深入:qsort的模拟实现
理解了 sizeof 和 strlen 的区别之后,我们来用指针运算实现一个通用排序函数。要排序任意类型的数据,面临三个核心问题:
- 函数不知道数据的类型 → 用
void*接收 - 函数不知道怎么比较两个元素 → 用回调函数(函数指针)
- 函数不知道每个元素多大,怎么交换 → 逐字节交换!
交换两个元素时,因为不知道元素类型,不能直接 tmp = *a。解决思路是把每个元素当作一串字节,逐字节交换:
#include <stdio.h>
// 通用交换函数:交换任意类型的两个元素
void generic_swap(void *p1, void *p2, int size)
{
int i;
for (i = 0; i < size; i++)
{
// (char*)p1 + i → 第i个字节的地址
// *( (char*)p1 + i ) → 第i个字节的内容
char tmp = *((char *)p1 + i);
*((char *)p1 + i) = *((char *)p2 + i);
*((char *)p2 + i) = tmp;
}
}
int main()
{
int a = 100, b = 200;
printf("交换前: a=%d, b=%d\n", a, b);
generic_swap(&a, &b, sizeof(int));
printf("交换后: a=%d, b=%d\n", a, b);
double x = 3.14, y = 2.71;
printf("交换前: x=%.2f, y=%.2f\n", x, y);
generic_swap(&x, &y, sizeof(double));
printf("交换后: x=%.2f, y=%.2f\n", x, y);
return 0;
}(char *)p1 是关键——转成 char* 后,+i 就是逐字节精确移动。char 恰好 1 字节,所以 char* 是天然的"单位步长指针"。这是 C 语言泛型编程的基础模式,memcpy、memmove、memset 等标准库函数都依赖它。顺带一提:标准库的 memcpy 内部就是一个高度优化的逐字节(或逐字)拷贝循环,我们这里手写的 generic_swap 正是它的思想雏形。
现在把冒泡排序和逐字节交换组合起来,实现一个完整的通用排序:
#include <stdio.h>
// ===== 1. 比较函数(由调用者提供)=====
int cmp_int(const void *p1, const void *p2)
{
return *(int *)p1 - *(int *)p2;
}
// ===== 2. 通用交换函数 =====
void swap_bytes(void *p1, void *p2, int size)
{
int i;
for (i = 0; i < size; i++)
{
char tmp = *((char *)p1 + i);
*((char *)p1 + i) = *((char *)p2 + i);
*((char *)p2 + i) = tmp;
}
}
// ===== 3. 通用冒泡排序 =====
void bubble_sort(
void *base, // 数组首地址(泛型)
int count, // 元素个数
int size, // 每个元素的大小(字节)
int (*cmp)(const void *, const void *) // 比较函数的指针
)
{
int i, j;
for (i = 0; i < count - 1; i++)
{
for (j = 0; j < count - i - 1; j++)
{
// (char *)base + j * size → 第j个元素的起始地址
// (char *)base + (j+1) * size → 第j+1个元素的起始地址
if (cmp((char *)base + j * size,
(char *)base + (j + 1) * size) > 0)
{
swap_bytes((char *)base + j * size,
(char *)base + (j + 1) * size,
size);
}
}
}
}
int main()
{
int arr[] = {3, 1, 7, 5, 8, 9, 0, 2, 4, 6};
int sz = sizeof(arr) / sizeof(arr[0]);
int i;
printf("排序前: ");
for (i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
bubble_sort(arr, sz, sizeof(int), cmp_int);
printf("排序后: ");
for (i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
return 0;
}核心在这一行:(char *)base + j * size。把 base 转成 char*(以字节为单位),然后偏移 j × size 个字节,正好定位到数组第 j 个元素的起始地址。这就是 void* + size 参数的组合威力。
base → ┌──────┬──────┬──────┬──────┐
│ elem0│ elem1│ elem2│ elem3│ size = 4(每个int占4字节)
└──────┴──────┴──────┴──────┘
↑ ↑ ↑ ↑
(char*)base base+4 base+8 base+12
+0*4 +1*4 +2*4 +3*4
对照标准库 qsort,我们的 bubble_sort 只是把排序算法从快排换成了冒泡,接口完全一致——这就是"算法与数据类型分离"的精髓。有一个实现细节值得注意:swap_bytes 逐字节交换对任何类型都成立,因为任何对象的内存都是一串字节,交换字节序列等于交换对象本身。这个"万物皆字节"的思想是整个 C 语言泛型编程的基石。
思考题(模拟 qsort)
- 为什么必须把
base强转成char*才能做+ j * size?(void* 不能做算术运算,char* 以 1 字节为单位才能精确定位) swap_bytes能交换两个double吗?(能,逐字节交换不关心类型)- 如果
size传错了(比如传sizeof(double)但元素是 int),会怎样?(交换错位,数据全乱——size 是调用者的契约)
参考答案与详解(模拟 qsort)
- 为什么必须把
base强转成char*:void*是"无类型指针",标准 C 里不能对void*做算术运算(它不携带"每步跳几字节"的信息)。而char*的步长恰好是 1 字节,把void*强转成char*后,(char*)base + j*size就能以"字节"为最小单位精确地定位到数组第 j 个元素的起始地址。这一步是"按字节操作泛型内存"的关键,也是所有通用算法(memcpy 等)的地基。 swap_bytes能交换两个double吗:能。swap_bytes不看类型、只看size,它把一个对象当作size个连续的字节,用循环一对一对地交换。double占 8 字节,传sizeof(double)就是把两个 double 各自的 8 个字节逐一交换——内存字节序列互换后,对象的位模式就互换,等价于值互换。"任何对象的内存都是一串字节",这就是它通用的根本原因。size传错会怎样:如果元素其实是int(4 字节)却传了sizeof(double)= 8,swap_bytes就会把一个"元素"当成 8 字节去搬动——它会把两个不相关的 int 的内存一起搬走,导致数据完全错位、排序结果错乱(甚至越界写到数组之外)。所以size是通用的核心契约:必须与元素实际大小严格一致,这是调用者的责任(qsort也无法替你验证)。
数组参数的sizeof规则
这是笔试中的重灾区。计算 sizeof 结果时,记住三条黄金规则:
sizeof(数组名):数组名表示整个数组 → 结果是整个数组的大小&数组名:取整个数组的地址 → 类型是数组指针T (*)[N]- 其他所有情况:数组名表示首元素地址 → 退化为指针
一维数组 sizeof 全测试:
#include <stdio.h>
int main()
{
int a[] = {1, 2, 3, 4}; // 4个int,共16字节
printf("sizeof(a) = %zu // 整个数组, 4×4=16\n", sizeof(a));
printf("sizeof(a+0) = %zu // 首元素地址, 退化为指针\n", sizeof(a+0));
printf("sizeof(*a) = %zu // 首元素, int大小=4\n", sizeof(*a));
printf("sizeof(a+1) = %zu // 第二个元素的地址, 指针\n", sizeof(a+1));
printf("sizeof(a[1]) = %zu // 第二个元素, int大小=4\n", sizeof(a[1]));
printf("sizeof(&a) = %zu // 整个数组的地址, 指针\n", sizeof(&a));
printf("sizeof(*&a) = %zu // *和&抵消, 等于sizeof(a)=16\n", sizeof(*&a));
printf("sizeof(&a+1) = %zu // 跳过整个数组后的地址, 指针\n", sizeof(&a+1));
printf("sizeof(&a[0]) = %zu // 首元素地址, 指针\n", sizeof(&a[0]));
printf("sizeof(&a[0]+1)= %zu // 第二个元素地址, 指针\n", sizeof(&a[0]+1));
return 0;
}
// 64位平台输出:
// sizeof(a) = 16
// sizeof(a+0) = 8
// sizeof(*a) = 4
// sizeof(a+1) = 8
// sizeof(a[1]) = 4
// sizeof(&a) = 8
// sizeof(*&a) = 16
// sizeof(&a+1) = 8
// sizeof(&a[0]) = 8
// sizeof(&a[0]+1)= 8规律:只要结果是"地址",sizeof 返回指针大小(4 或 8);只要结果是"元素"或"整个数组",sizeof 返回对应类型大小。最容易错的是 sizeof(*&a)——* 和 & 抵消,回到数组本身,所以是 16 而不是 8。
字符数组的 sizeof 和 strlen 混合测试——这是笔试的重灾区:
#include <stdio.h>
#include <string.h>
int main()
{
// 场景A:字符数组(无\0)
char arr1[] = {'a', 'b', 'c', 'd', 'e', 'f'}; // 6字节,无\0
printf("=== 场景A:字符数组(无\\0) ===\n");
printf("sizeof(arr1) = %zu // 整个数组6字节\n", sizeof(arr1));
printf("sizeof(arr1+0) = %zu // 地址→指针\n", sizeof(arr1+0));
printf("sizeof(*arr1) = %zu // 首元素'a'→1字节\n", sizeof(*arr1));
printf("sizeof(&arr1) = %zu // 数组地址→指针\n", sizeof(&arr1));
printf("sizeof(&arr1+1) = %zu // 跳过数组后→指针\n", sizeof(&arr1+1));
// strlen(arr1) 的结果不确定,因为arr1没有\0
// 场景B:字符串(有\0)
char arr2[] = "abcdef"; // 7字节: a,b,c,d,e,f,\0
printf("\n=== 场景B:字符串(有\\0) ===\n");
printf("sizeof(arr2) = %zu // 含\\0, 7字节\n", sizeof(arr2));
printf("strlen(arr2) = %zu // \\0前6个字符\n", strlen(arr2));
// 场景C:字符指针指向字符串常量
char *p = "abcdef"; // p存的是首字符地址
printf("\n=== 场景C:字符指针 ===\n");
printf("sizeof(p) = %zu // p是指针\n", sizeof(p));
printf("sizeof(*p) = %zu // 'a'→1字节\n", sizeof(*p));
printf("sizeof(p[0]) = %zu // 'a'→1字节\n", sizeof(p[0]));
printf("sizeof(&p) = %zu // 指针的地址→指针\n", sizeof(&p));
printf("\nstrlen(p) = %zu // 6\n", strlen(p));
printf("strlen(p+1) = %zu // 5(从'b'开始)\n", strlen(p+1));
return 0;
}场景 A 和场景 B 的唯一区别:A 是 {'a','b','c','d','e','f'}(6 字节,无 \0),B 是 "abcdef"(7 字节,自动加 \0)。差一个 \0,strlen 就从"确定 6"变成"未定义"——这就是笔试的考点。
笔试中还经常把 strlen 和字符数组玩出各种花样,比如 strlen(*arr)、strlen(&arr)。记住一条铁律:strlen 只接受"指向字符的指针"(const char*),其他任何类型传进去都是错的。
char arr[] = {'a', 'b', 'c', 'd', 'e', 'f'}; // 无\0的字符数组
strlen(arr); // ❌ arr没有\0,越界查找,结果不确定
strlen(*arr); // ❌ *arr是字符'a',ASCII值97被当作地址0x61访问 → 几乎必然崩溃
strlen(arr[1]); // ❌ 同理,把字符值当地址
strlen(&arr); // ❌ &arr是char(*)[6](数组指针),强塞给char*,语义就错了
strlen(&arr + 1); // ❌ 直接越过整个数组,越界访问strlen(*arr) 这类写法最迷惑人:*arr 明明解引用出来了 'a',但 strlen 要的是"地址",于是 'a' 的 ASCII 值 97 就被当成了地址 0x00000061,程序会去那个地址附近找 \0——轻则返回垃圾值,重则段错误。看到 strlen 的参数里出现 *,或者数组名前面带 &,第一反应就应该是"这里有陷阱"。
二维数组 sizeof 完整测试:
#include <stdio.h>
int main()
{
int a[3][4] = {0}; // 3行4列,共12个int,48字节
printf("sizeof(a) = %2zu // 整个二维数组→48\n", sizeof(a));
printf("sizeof(a[0][0]) = %2zu // 第1个元素→4\n", sizeof(a[0][0]));
printf("sizeof(a[0]) = %2zu // 第1行(一维数组)→16\n", sizeof(a[0]));
printf("sizeof(a[0]+1) = %2zu // 第1行第2个元素地址→指针\n", sizeof(a[0]+1));
printf("sizeof(*(a[0]+1)) = %2zu // 第1行第2个元素→4\n", sizeof(*(a[0]+1)));
printf("sizeof(a+1) = %2zu // 第2行地址→指针\n", sizeof(a+1));
printf("sizeof(*(a+1)) = %2zu // 第2行→16\n", sizeof(*(a+1)));
printf("sizeof(&a[0]+1) = %2zu // 第2行地址→指针\n", sizeof(&a[0]+1));
printf("sizeof(*(&a[0]+1))= %2zu // 第2行→16\n", sizeof(*(&a[0]+1)));
printf("sizeof(*a) = %2zu // 第1行→16\n", sizeof(*a));
printf("sizeof(a[3]) = %2zu // 第4行(越界但sizeof不实际访问)→16\n", sizeof(a[3]));
return 0;
}
// 输出(64位环境):
// sizeof(a) = 48
// sizeof(a[0][0]) = 4
// sizeof(a[0]) = 16
// sizeof(a[0]+1) = 8
// sizeof(*(a[0]+1)) = 4
// sizeof(a+1) = 8
// sizeof(*(a+1)) = 16
// sizeof(&a[0]+1) = 8
// sizeof(*(&a[0]+1))= 16
// sizeof(*a) = 16
// sizeof(a[3]) = 16sizeof(a[3]) 看起来"越界"了——第 4 行根本不存在!但因为 sizeof 在编译期只推导类型(a[3] 的类型是 int[4],16 字节),不会真的访问内存。这是笔试中的经典陷阱。
还有一个函数形参里 sizeof 的陷阱,和一维数组传参是同一个根源:函数形参中的数组会退化为指针,所以形参里写 int arr[3][4],函数内 sizeof(arr) 是 8(指针大小),不是 48!三维、四维同理。只要是在函数参数里,任何"数组大小"信息都会丢失,只能退化成指针。
思考题(数组参数 sizeof)
sizeof(*&a)为什么是 16?(*与&抵消,回到数组本身)sizeof(a[3])会越界崩溃吗?(不会,编译期只推导类型,不访问内存)- 函数形参
int arr[3][4]里sizeof(arr)是多少?(8,形参已退化为指针)
参考答案与详解(数组参数 sizeof)
sizeof(*&a)为什么是 16:&a取整个数组的地址(类型int (*)[4]),再对它解引用*&a——取地址和解引用正好互相抵消,*&a表达式的类型又回到了原来的数组int[4]。所以sizeof(*&a)=sizeof(a)= 整个数组 16 字节,而不是指针的 8。它说明了"*和&抵消/互逆",是数组名两个例外叠加后各自抵消的结果。sizeof(a[3])会越界崩溃吗:不会。sizeof在编译期只推导操作数的类型。a[3](虽然下标 3 越界了,但作为表达式)的类型是int[4],编译器只根据类型算出"4 个 int = 16 字节",根本不会真的去访问内存,所以既不越界也不崩溃。这是笔试经典陷阱:看起来访问了第 4 行,实际只是"算类型大小"。- 函数形参
int arr[3][4]里sizeof(arr):函数形参的数组一律退化为指针。int arr[3][4]作为形参,退化成"指向int[4]的数组指针"(int (*)[4]),所以函数内的arr是一个指针,sizeof(arr)= 指针大小 = 64 位下 8(而不是 48)。二维/多维数组的"大小信息"在形参里同样丢失——这正是"数组传参会退化"规则的多维版本。
指针运算的常见陷阱
前四篇我们用过很多指针算术,这一节把最容易翻车的几个场景集中清算一遍。每一个都是真实笔试题的来源。
陷阱一:(int*)p + 1 和 (int*)(p + 1) 完全不同。 强转的优先级高于加法,所以第一个是"先转成 int* 再加 4 字节",第二个是"先按原类型加步长,再转成 int*"。
char arr[8];
char *pc = arr;
printf("%p\n", (void*)((int*)pc + 1)); // 地址 +4(按int步长)
printf("%p\n", (void*)((int*)(pc + 1))); // 地址 +1(先按char步长)再强转陷阱二:指针加减的方向和 ++ 的副作用。 p++ 先取当前值再移动(等价 *(p++) 取当前元素);++p 先移动再取(等价 *(++p) 取下一个元素)。遍历数组时这两种写法差一个元素:
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
printf("%d\n", *p++); // 10(先取 arr[0],p 移到 arr[1])
printf("%d\n", *++p); // 30(p 先移到 arr[2],再取 30)陷阱三:指针越界后一切运算都不可信。 指针一旦越过数组边界(超过尾后位置),再做算术或解引用就是未定义行为。尾后位置(one-past-the-end)可以比较但不能解引用——这是第一篇就强调过的红线,这里再钉一次。
陷阱四:不同数组的指针相减/比较。 指向两个不同数组的指针相减或做 </> 比较是未定义行为——"两个数组之间相隔几个元素"没有意义。==/!= 则允许。
陷阱五:指针与整数的混淆。 有人把指针强转成整数做算术再转回指针((int*)addr + 5 没问题,但 (int*)((long)p + 5) 很危险)——后者跳过 5 字节可能落在元素中间,产生未对齐访问(某些架构直接崩溃)。地址运算尽量留在指针类型里做,不要转成整数。
陷阱六:ptrdiff_t 的打印。 指针相减的结果类型是 ptrdiff_t(有符号),打印用 %td。很多人用 %d 打(32 位下恰好一样),或误用 %zu(无符号,负数会打出巨大的值)。
int arr[5] = {1, 2, 3, 4, 5};
int *p = &arr[4];
printf("%td\n", p - arr); // 4,正确格式
printf("%d\n", (int)(p - arr)); // 也行(显式强转)
// printf("%zu\n", p - arr); // ❌ 类型不匹配,未定义行为陷阱七:p + i 和 i + p 之外的非法组合。 指针不能加指针(p + q 非法)、不能乘除、不能和浮点数相加。编译器会直接报错,但笔试会问"哪一行错了"。
思考题(指针运算陷阱)
(int*)pc + 1和(int*)(pc + 1)的地址差分别是多少?(4 和 1)*p++和(*p)++的区别?(前者:取元素后 p 后移;后者:把 p 指向的元素值加 1)- 为什么
p - q(不同数组)是未定义行为?("元素个数"的度量失去意义)
参考答案与详解(指针运算陷阱)
(int*)pc + 1vs(int*)(pc + 1)的地址差:这里是两类步长的对比(设pc为char*)。(int*)pc + 1:先把pc强转成int*(步长变 4),再+1→ 地址增加 4 字节。(int*)(pc + 1):先在原来的char*上+1(步长 1,地址增加 1),再把结果强转成int*→ 地址只增加 1 字节。- 强转的优先级高于
+/++,所以位置决定了"先转后加"还是"先加后转",结果天差地别。
*p++vs(*p)++:*p++:等价于*(p++),后缀++优先级高——先解引用当前元素(把p指向的值作为一个整体用来运算),再让p指向下一个元素。例如遍历打印*p++会依次取出每个元素。(*p)++:括号让*p先结合,它表达的是"把p指向的那个值自增 1"(即(*p) += 1),p的位置完全不动。- 一个是"取当前元素后指针后移",一个是"把当前元素的值加 1",一字之差,语义完全不同。
- 为什么
p - q(不同数组)是未定义行为:指针减指针的语义是"两个指针之间相隔的元素个数",而"相隔几个元素"这个度量只对同一块连续内存里的指针(通常是同一个数组内)才有意义。两个来自不同数组的指针之间不存在"共同的最小单位(元素)",它们之间的数值差(如某字符串数组指针减数组指针)表达"相差一个指针"无实际意义,所以 C 标准规定这是未定义行为。(==/!=除外,比较两个指针是否指向同一位置是被允许的。)
指针与字符串
字符串在 C 语言里没有独立类型,它要么是 char[] 数组,要么是 const char * 指针指向的常量区。指针与字符串的纠缠,是 sizeof/strlen 之外第二大类笔试题。
第一组对比:数组 vs 指针
char str[] = "hello"; // 栈上 6 字节,内容可改
char *p = "hello"; // 常量区字符串,指针指向它str[i] = 'X'合法;p[i] = 'X'未定义行为(只读区)。sizeof(str)是 6;sizeof(p)是 8。str不能重新赋值;p可以指向别的字符串。
第二组对比:传参后的退化
#include <stdio.h>
#include <string.h>
void print_str(char *s) // 形参退化为指针
{
printf("%zu\n", sizeof(s)); // 8,不是数组大小
printf("%zu\n", strlen(s)); // 5,正确(strlen 只关心\0)
}
int main()
{
char str[] = "hello";
print_str(str); // 传的是首元素地址
return 0;
}第三组对比:函数返回
- 返回
char[]局部数组 → 悬空指针(栈被回收),禁止; - 返回
char *指向字符串常量 → 安全(常量区全局存在); - 返回
static char[]→ 安全但不建议多线程用。
const char *greet()
{
return "hello"; // ✅ 指向常量区,安全
}
char *bad()
{
char buf[] = "hello"; // ❌ 栈上局部数组
return buf; // 返回悬空指针!
}第四组:字符串拷贝/拼接的缓冲区溢出。strcpy(dst, src) 不会检查 dst 空间是否够,这是 C 史上最著名的安全漏洞来源:
char dst[5];
char *src = "hello world"; // 11 个字符
strcpy(dst, src); // ❌ 缓冲区溢出!写坏了 dst 后面的内存正确姿势:要么保证目标足够大,要么用 strncpy/snprintf 限制长度。这块属于字符串函数专题,这里先建立"指针只负责指,空间要你自己保证"的意识。
第五组:字符串指针数组(指针数组的经典形态):
#include <stdio.h>
#include <string.h>
int main()
{
// 每个元素是指向字符串常量的指针
const char *week[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
int i;
for (i = 0; i < 7; i++)
printf("%s (%zu)\n", week[i], strlen(week[i]));
return 0;
}week 是一个指针数组:7 个 const char * 元素,每个指向常量区的字符串。它的好处是每个"行"长度不同也能存(字符串天然变长),比 char week[7][10] 省内存(后者每行固定 10 字节)。这也是"指针数组模拟不规则二维结构"的经典应用。
思考题(指针与字符串)
char *p = "hello";能strcpy(p, "hi")吗?(不能,目标在只读区;应该用char buf[10])- 返回字符串常量地址为什么安全?(常量区生命周期是程序全程)
const char *week[]里每个元素占多少字节?(8,指针大小;字符串本体在常量区)
参考答案与详解(指针与字符串)
char *p = "hello";能strcpy(p, "hi")吗:不能!strcpy会把"hi"(连同\0)写入p指向的目标内存,而p指向的是只读的字符串常量区,往只读区写数据是未定义行为——大多数环境立刻段错误。要让strcpy有地方写,目标必须是可写的字符数组:char buf[10]; strcpy(buf, "hi");(注意 buf 得足够大,strcpy不检查边界)。- 返回字符串常量地址为什么安全:字符串常量存放在只读数据区(静态区/常量区),它的生命周期是整个程序运行期——函数返回后,这块内存依然存在、不会像栈上的局部数组那样被回收。所以
const char *greet(){ return "hello"; }返回的地址在greet返回后依然有效,可以安全使用。对比:返回栈上局部char buf[]的地址就是悬空指针(已被回收),不安全。 week每个元素占多少字节:week是指针数组,每个元素是一个char *(指向字符串的指针),占指针大小(64 位下 8 字节)。注意这里的 8 字节只是"存地址",字符串本体(如"Mon"等)在只读常量区,不占week数组的空间。这正是指针数组比二维字符数组char week[7][10](每行固定 10 字节)省内存的原因。
sizeof 和 strlen 的区别本质上是"编译期 vs 运行期"的对立。理解了这个对立,你就不会再在笔试中搞混它们。void* + 逐字节操作的组合,则打开了 C 语言泛型编程的大门——虽然不像 C++ 模板那么优雅,但它背后"把一切数据看作字节序列"的思想是系统编程的根基。本篇补的"指针运算陷阱"和"指针与字符串"两块,则把前四篇散落的易错点收拢成了体系。
在进入收官篇之前,做两个小练习验收:
练习 1:下面代码的输出是什么(64 位平台)?先手算,再上机验证。
char *p = "abcdef"; printf("%zu %zu %zu\n", sizeof(p), sizeof(*p), strlen(p));(答案:8、1、6)
练习 2:用
void*写一个通用函数void reverse_bytes(void *base, int count, int size),把数组元素倒序(提示:用(char*)base定位元素,逐元素交换)。
练习 2 参考答案
#include <stdio.h>
// 通用交换:交换任意两个对象的内存字节
void swap_bytes(void *p1, void *p2, int size)
{
int i;
for (i = 0; i < size; i++) // 逐字节交换
{
char tmp = *((char *)p1 + i);
*((char *)p1 + i) = *((char *)p2 + i);
*((char *)p2 + i) = tmp;
}
}
// 通用倒序:把数组元素顺序反过来
void reverse_bytes(void *base, int count, int size)
{
int i;
// 首尾对称交换:第 i 个元素 与 倒数第 i 个元素 交换
// (char*)base 以字节为单位,元素偏移 = 下标 * size
for (i = 0; i < count / 2; i++) // 只交换前半段即可(后半段被交换进来)
{
swap_bytes((char *)base + i * size,
(char *)base + (count - 1 - i) * size,
size);
}
}
int main()
{
int arr[] = {1, 2, 3, 4, 5};
int sz = sizeof(arr) / sizeof(arr[0]);
int i;
reverse_bytes(arr, sz, sizeof(int));
for (i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n"); // 输出:5 4 3 2 1
return 0;
}解析:关键是把 base 强转成 char*,这样 + i * size 就能精确偏移到第 i 个元素的首字节;再用首尾对称交换(第 i 个 ↔ 第 count-1-i 个)实现倒序,只需循环 count/2 次。整个函数不关心元素类型,仅凭 size 逐字节交换——和本文 swap_bytes/bubble_sort 是同一套"万物皆字节"的通用思想。倒序任意类型数组(int、double、结构体)都能用这个函数。
下一篇是本系列的收官之作。我会用 7 道经典的 C 语言指针笔试题来检验你的全面理解。每一道题我都画出内存布局图、推演每一步指针运算、标注最容易踩的坑。如果你能独立做完,你的指针功底就已经超过了 90% 的程序员。
本文基于C语言教学课件《第15讲:深入理解指针(5)》的内容框架撰写,所有代码示例均在GCC/MinGW环境下验证通过。
还没有评论 — 第一条由你来留。