如果你去参加 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;
}
维度sizeofstrlen
身份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)

  1. sizeof("abc") 是多少?(4,含 \0;strlen("abc") 才是 3)
  2. sizeof 里的 i++ 会执行吗?(不会,sizeof 只推导类型不计算表达式)
  3. strlen 对没有 \0 的数组为什么危险?(运行期一直往后找 \0,越界访问,未定义行为)

参考答案与详解(sizeof 与 strlen)

  1. sizeof("abc") 是 4,strlen("abc") 是 3:sizeof 计算的是这个字符串字面量所占的字节数,字符串字面量 "abc" 实际存储是 'a','b','c','\0' 共 4 个字节,所以 sizeof("abc") = 4;strlen 统计 \0 之前的字符个数(不包含 \0),所以是 3。一个包含 \0、一个不包含,这是两者最直观的区别。
  2. sizeof 里的 i++ 不会执行:sizeof 是编译期操作符,它只推导操作数的类型,从不计算表达式本身。sizeof(i++) 只看 i++ 的类型是 int、返回 sizeof(int) = 4,那个 i++ 根本不会真的执行——所以 i 的值保持原样(还是 0)。同理 sizeof(fun()) 不会真正调用 fun()、sizeof(arr[100]) 不会真的越界访问。记住:sizeof 不产生任何运行时副作用,这是笔试高频陷阱。
  3. strlen 对没有 \0 的数组为什么危险:strlen 是运行期库函数,它从接收的地址开始一个字节一个字节地往前找,直到遇到 \0 才停。如果数组里没有 \0,strlen 就会一直读下去,越过数组边界去访问相邻的未知内存,直到在别处偶然碰到一个 \0 才停止——这是未定义行为,返回的长度是垃圾值,甚至可能触发段错误。所以 char arr[3] = {'a','b','c'};(无 \0)绝不能让 strlen 处理。

指针运算深入:qsort的模拟实现

理解了 sizeof 和 strlen 的区别之后,我们来用指针运算实现一个通用排序函数。要排序任意类型的数据,面临三个核心问题:

  1. 函数不知道数据的类型 → 用 void* 接收
  2. 函数不知道怎么比较两个元素 → 用回调函数(函数指针)
  3. 函数不知道每个元素多大,怎么交换 → 逐字节交换!

交换两个元素时,因为不知道元素类型,不能直接 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)

  1. 为什么必须把 base 强转成 char* 才能做 + j * size?(void* 不能做算术运算,char* 以 1 字节为单位才能精确定位)
  2. swap_bytes 能交换两个 double 吗?(能,逐字节交换不关心类型)
  3. 如果 size 传错了(比如传 sizeof(double) 但元素是 int),会怎样?(交换错位,数据全乱——size 是调用者的契约)

参考答案与详解(模拟 qsort)

  1. 为什么必须把 base 强转成 char*:void* 是"无类型指针",标准 C 里不能对 void* 做算术运算(它不携带"每步跳几字节"的信息)。而 char* 的步长恰好是 1 字节,把 void* 强转成 char* 后,(char*)base + j*size 就能以"字节"为最小单位精确地定位到数组第 j 个元素的起始地址。这一步是"按字节操作泛型内存"的关键,也是所有通用算法(memcpy 等)的地基。
  2. swap_bytes 能交换两个 double 吗:能。swap_bytes 不看类型、只看 size,它把一个对象当作 size 个连续的字节,用循环一对一对地交换。double 占 8 字节,传 sizeof(double) 就是把两个 double 各自的 8 个字节逐一交换——内存字节序列互换后,对象的位模式就互换,等价于值互换。"任何对象的内存都是一串字节",这就是它通用的根本原因。
  3. size 传错会怎样:如果元素其实是 int(4 字节)却传了 sizeof(double) = 8,swap_bytes 就会把一个"元素"当成 8 字节去搬动——它会把两个不相关的 int 的内存一起搬走,导致数据完全错位、排序结果错乱(甚至越界写到数组之外)。所以 size 是通用的核心契约:必须与元素实际大小严格一致,这是调用者的责任(qsort 也无法替你验证)。

数组参数的sizeof规则

这是笔试中的重灾区。计算 sizeof 结果时,记住三条黄金规则:

  1. sizeof(数组名):数组名表示整个数组 → 结果是整个数组的大小
  2. &数组名:取整个数组的地址 → 类型是数组指针 T (*)[N]
  3. 其他所有情况:数组名表示首元素地址 → 退化为指针

一维数组 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])      = 16

sizeof(a[3]) 看起来"越界"了——第 4 行根本不存在!但因为 sizeof 在编译期只推导类型(a[3] 的类型是 int[4],16 字节),不会真的访问内存。这是笔试中的经典陷阱。

还有一个函数形参里 sizeof 的陷阱,和一维数组传参是同一个根源:函数形参中的数组会退化为指针,所以形参里写 int arr[3][4],函数内 sizeof(arr) 是 8(指针大小),不是 48!三维、四维同理。只要是在函数参数里,任何"数组大小"信息都会丢失,只能退化成指针。

思考题(数组参数 sizeof)

  1. sizeof(*&a) 为什么是 16?(* 与 & 抵消,回到数组本身)
  2. sizeof(a[3]) 会越界崩溃吗?(不会,编译期只推导类型,不访问内存)
  3. 函数形参 int arr[3][4] 里 sizeof(arr) 是多少?(8,形参已退化为指针)

参考答案与详解(数组参数 sizeof)

  1. sizeof(*&a) 为什么是 16:&a 取整个数组的地址(类型 int (*)[4]),再对它解引用 *&a——取地址和解引用正好互相抵消,*&a 表达式的类型又回到了原来的数组 int[4]。所以 sizeof(*&a) = sizeof(a) = 整个数组 16 字节,而不是指针的 8。它说明了"* 和 & 抵消/互逆",是数组名两个例外叠加后各自抵消的结果。
  2. sizeof(a[3]) 会越界崩溃吗:不会。sizeof 在编译期只推导操作数的类型。a[3](虽然下标 3 越界了,但作为表达式)的类型是 int[4],编译器只根据类型算出"4 个 int = 16 字节",根本不会真的去访问内存,所以既不越界也不崩溃。这是笔试经典陷阱:看起来访问了第 4 行,实际只是"算类型大小"。
  3. 函数形参 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 非法)、不能乘除、不能和浮点数相加。编译器会直接报错,但笔试会问"哪一行错了"。

思考题(指针运算陷阱)

  1. (int*)pc + 1 和 (int*)(pc + 1) 的地址差分别是多少?(4 和 1)
  2. *p++ 和 (*p)++ 的区别?(前者:取元素后 p 后移;后者:把 p 指向的元素值加 1)
  3. 为什么 p - q(不同数组)是未定义行为?("元素个数"的度量失去意义)

参考答案与详解(指针运算陷阱)

  1. (int*)pc + 1 vs (int*)(pc + 1) 的地址差:这里是两类步长的对比(设 pc 为 char*)。
    • (int*)pc + 1:先把 pc 强转成 int*(步长变 4),再 +1 → 地址增加 4 字节。
    • (int*)(pc + 1):先在原来的 char* 上 +1(步长 1,地址增加 1),再把结果强转成 int* → 地址只增加 1 字节。
    • 强转的优先级高于 +/++,所以位置决定了"先转后加"还是"先加后转",结果天差地别。
  2. *p++ vs (*p)++:
    • *p++:等价于 *(p++),后缀 ++ 优先级高——先解引用当前元素(把 p 指向的值作为一个整体用来运算),再让 p 指向下一个元素。例如遍历打印 *p++ 会依次取出每个元素。
    • (*p)++:括号让 *p 先结合,它表达的是"把 p 指向的那个值自增 1"(即 (*p) += 1),p 的位置完全不动。
    • 一个是"取当前元素后指针后移",一个是"把当前元素的值加 1",一字之差,语义完全不同。
  3. 为什么 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 字节)。这也是"指针数组模拟不规则二维结构"的经典应用。

思考题(指针与字符串)

  1. char *p = "hello"; 能 strcpy(p, "hi") 吗?(不能,目标在只读区;应该用 char buf[10])
  2. 返回字符串常量地址为什么安全?(常量区生命周期是程序全程)
  3. const char *week[] 里每个元素占多少字节?(8,指针大小;字符串本体在常量区)

参考答案与详解(指针与字符串)

  1. char *p = "hello"; 能 strcpy(p, "hi") 吗:不能!strcpy 会把 "hi"(连同 \0)写入 p 指向的目标内存,而 p 指向的是只读的字符串常量区,往只读区写数据是未定义行为——大多数环境立刻段错误。要让 strcpy 有地方写,目标必须是可写的字符数组:char buf[10]; strcpy(buf, "hi");(注意 buf 得足够大,strcpy 不检查边界)。
  2. 返回字符串常量地址为什么安全:字符串常量存放在只读数据区(静态区/常量区),它的生命周期是整个程序运行期——函数返回后,这块内存依然存在、不会像栈上的局部数组那样被回收。所以 const char *greet(){ return "hello"; } 返回的地址在 greet 返回后依然有效,可以安全使用。对比:返回栈上局部 char buf[] 的地址就是悬空指针(已被回收),不安全。
  3. 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环境下验证通过。