学完了前五篇,你已经掌握了指针的方方面面——从最基础的内存地址,到 const 和野指针防护,从数组名与指针的纠缠,到函数指针和回调机制。但掌握知识和能解对笔试题是两回事。笔试题的特点是把所有知识点糅合在一起,一个不注意就掉进陷阱。

做笔试题前,请确认你脑子里装着这些规则:数组名是首元素地址,但 sizeof(数组名) 和 &数组名 是例外;&arr+1 跳过的是整个数组,不是单个元素;指针 ± 整数 的步长由指针类型决定;数组下标 a[i] 完全等价于 *(a+i),也等价于 i[a];二维数组的数组名 是"第一行"的地址;sizeof 是编译期操作符,不实际计算表达式。

如果以上有哪条还模糊,建议回到对应文章复习后再做题。这篇是本系列的收官之作——我会带你逐题解析 7 道经典的 C 语言指针笔试题。每一道题我都会画出内存布局图、推演指针运算的每一步、标注最容易出错的地方。7 题之外,我还会加餐几道同样高频的变体题,最后给出一份"应试自查清单",帮你把前五篇的知识点彻底串起来。

真题逐题精析

题目1:&a+1 与 *(a+1)

#include <stdio.h>
 
int main()
{
    int a[5] = {1, 2, 3, 4, 5};
    int *ptr = (int *)(&a + 1);
    printf("%d,%d", *(a + 1), *(ptr - 1));
    return 0;
}
// 问:输出什么?

第一步,画出数组 a 的内存布局:

a[0]  a[1]  a[2]  a[3]  a[4]
┌──────┬──────┬──────┬──────┬──────┐
│  1   │  2   │  3   │  4   │  5   │  ← 共20字节
└──────┴──────┴──────┴──────┴──────┘
  ↑                            ↑
  a                           a+5 = 数组尾后
  &a                          &a+1 = 跳过整个数组

第二步,逐条分析:

  • &a:整个数组的地址,类型为 int (*)[5]
  • &a + 1:跳过整个数组(20 字节),指向数组尾后位置
  • (int *)(&a + 1):强转为 int*,现在 ptr 指向数组尾后
  • ptr - 1:int* 指针减 1,退回 4 字节 → 指向 a[4](值为 5)
  • *(a + 1):a 是首元素地址,a+1 是 a[1] 的地址,解引用得 2
a[0]  a[1]  a[2]  a[3]  a[4]  尾后
┌──────┬──────┬──────┬──────┬──────┬───
│  1   │  2   │  3   │  4   │  5   │
└──────┴──────┴──────┴──────┴──────┴───
  ↑      ↑                    ↑     ↑
  a     a+1                  ptr   &a+1
                           (ptr-1)

答案:2,5

易错点:如果忘记 &a 的类型是数组指针而不是普通 int*,就会错误地认为 &a+1 只跳过了 4 字节。而 (int *) 强转让 ptr 变回"元素指针",ptr-1 才只退 4 字节——类型转换改变步长,这是本题的核心考点。

题目2:结构体指针与类型转换

// 在 X86(32位)环境下
// 假设结构体大小为 20 字节
#include <stdio.h>
 
struct Test
{
    int   Num;        // 4字节
    char  *pcName;    // 4字节(32位指针)
    short sDate;      // 2字节
    char  cha[2];     // 2字节
    short sBa[4];     // 8字节
};  // 总共 20 字节
 
int main()
{
    struct Test *p = (struct Test *)0x100000;
 
    printf("%p\n", p + 0x1);                        // ①
    printf("%p\n", (unsigned long)p + 0x1);          // ②
    printf("%p\n", (unsigned int *)p + 0x1);         // ③
    return 0;
}
// 问:三个输出分别是什么?

这道题考的是"指针 ± 整数的步长取决于指针类型"。

第①行:p + 0x1

  • p 的类型是 struct Test *
  • 指针 +1 跳过 sizeof(struct Test) = 20 字节
  • 0x100000 + 20 = 0x100000 + 0x14 = 0x100014

第②行:(unsigned long)p + 0x1

  • 先把指针强转为无符号长整型,p 变成一个整数 0x100000
  • 整数 +1 = 0x100000 + 1 = 0x100001——这不是指针运算,就是普通加法

第③行:(unsigned int *)p + 0x1

  • 把 p 强转为 unsigned int * 类型
  • unsigned int * 指针 +1 跳过 sizeof(unsigned int) = 4 字节
  • 0x100000 + 4 = 0x100004

答案:0x100014、0x100001、0x100004

核心洞见:同样是 +0x1,但左边操作数的类型不同,结果天差地别。这就是指针运算的底层本质。注意第②行把指针"变成整数"后,+1 就退化成普通整数加法了——指针运算和整数运算只在类型是"指针"时才生效。

题目3:逗号表达式陷阱

#include <stdio.h>
 
int main()
{
    int a[3][2] = {(0, 1), (2, 3), (4, 5)};
    int *p;
    p = a[0];
    printf("%d", p[0]);
    return 0;
}
// 问:输出什么?

这道题的陷阱在初始化列表。很多人以为 a 被初始化为 {{0,1},{2,3},{4,5}},但实际上:

  • (0, 1) 是逗号表达式,值为最后一个表达式的结果 = 1
  • (2, 3) 同样是逗号表达式,值为 3
  • (4, 5) 值为 5

所以实际初始化等价于:

int a[3][2] = {1, 3, 5};
// a[0][0] = 1, a[0][1] = 3, a[1][0] = 5, 其余为0

内存布局:

       col0   col1
row0:   1      3
row1:   5      0
row2:   0      0

然后 p = a[0],a[0] 是第一行的地址(即 &a[0][0]),所以 p 指向 a[0][0],p[0] = 1。

答案:1

核心教训:{} 用于数组初始化,() 用于逗号表达式。{0, 1} 是两个元素,{(0, 1)} 是逗号表达式。考试时一定看清括号类型。如果再考深一层:{(0,1),(2,3),(4,5)} 其实是三个逗号表达式(而不是三个子数组初始化器),所以花括号层级也"塌缩"了——外层只提供了一层大括号,二维数组按顺序填充,多出的元素补 0。

题目4:数组指针类型不匹配

// 假设 X86(32位)环境
#include <stdio.h>
 
int main()
{
    int a[5][5];              // 5行5列
    int (*p)[4];              // p是指向int[4]的数组指针
    p = a;                    // p指向a的第一行(类型不匹配!)
 
    printf("%p,%d\n",
           &p[4][2] - &a[4][2],
           &p[4][2] - &a[4][2]);
    return 0;
}
// 问:输出什么?

这是最经典的难题之一。核心矛盾:a 的类型是 int (*)[5](每行 5 个 int),而 p 的类型是 int (*)[4](每行 4 个 int)。当 p = a 时(有类型不匹配的警告),p 仍然按"每行 4 个 int"来计算地址。

画出二维数组的所有 int 元素连续排列:

内存地址(偏移量,单位int)
─────────────────────────────────────────────────
p的视角:  row0      row1      row2      row3      row4
        ┌─┬─┬─┬─┐┌─┬─┬─┬─┐┌─┬─┬─┬─┐┌─┬─┬─┬─┐┌─┬─┬─┬─┐
        │0│1│2│3││4│5│6│7││8│9│10│11│12│13│14│15│16│17│18│19│20│21│22│23│24│
        └─┴─┴─┴─┘└─┴─┴─┴─┘└─┴─┴─┴─┘└─┴─┴─┴─┘└─┴─┴─┴─┘
         ↑        ↑        ↑        ↑        ↑
        p[0]     p[1]    p[2]     p[3]     p[4]

a的视角:  row0          row1          row2          row3          row4
        ┌─┬─┬─┬─┬─┐┌─┬─┬─┬─┬─┐┌─┬─┬─┬─┬─┐┌─┬─┬─┬─┬─┐┌─┬─┬─┬─┬─┐
        │0│1│2│3│4│5│6│7│8│9│10│11│12│13│14│15│16│17│18│19│20│21│22│23│24│
        └─┴─┴─┴─┴─┘└─┴─┴─┴─┴─┘└─┴─┴─┴─┴─┘└─┴─┴─┴─┴─┘└─┴─┴─┴─┴─┘
         ↑            ↑            ↑            ↑            ↑
        a[0]         a[1]         a[2]         a[3]         a[4]

在 p 的视角下:

  • p[4] = p 向后退 4"行",每行 4 个 int = 偏移 16 个 int 的位置(下标 16)
  • p[4][2] = 在 16 的基础上再偏移 2 = 下标 18 的位置

在 a 的视角下:

  • a[4] = a 向后退 4"行",每行 5 个 int = 偏移 20 个 int 的位置

  • a[4][2] = 在 20 的基础上再偏移 2 = 下标 22 的位置

  • &p[4][2] 指向下标 18,&a[4][2] 指向下标 22

  • 两个 int* 相减 = (18 - 22) / 1 = -4(元素个数)

%p 打印的是十六进制地址:-4 的补码是 0xFFFFFFFC。

答案:FFFFFFFC,-4

这里补一个严谨性提醒:%p 格式化的是 void*,而这道题传进去的是 ptrdiff_t(32 位下就是 int)——严格来说这是格式串与参数类型不匹配,编译器会报警告。笔试原题就这么写,%p 在 32 位环境下恰好按 4 字节打印,所以能看到 FFFFFFFC。自己写代码时,打印指针差值用 %td(对应 ptrdiff_t)才是规范做法。

再补一个编译环境的提醒:p = a; 这一句的类型不匹配(int (*)[5] 赋给 int (*)[4])在早期编译器(如老版 GCC/VS)通常只是警告,但较新的 GCC/Clang(GCC 14+ 把 -Wincompatible-pointer-types 提升为默认错误)会直接编译失败。笔试环境大多按"允许编译"考察,做题时把 p = a 视为"带类型警告但仍可按题意运行"即可;自己上机想在普通 GCC 跑通该题,可加 -fpermissive 降级为警告。

核心理解:同一个内存块,被不同类型的指针"解释"时,下标运算的结果完全不同。这极致地体现了"指针的类型决定步长"的原理。p[4] 用的是"每行 4 个"的步长,a[4] 用的是"每行 5 个"的步长——数组指针的"行"有多大,取决于指针的类型,而不是内存里实际排布。

题目5:二维数组指针综合运算

#include <stdio.h>
 
int main()
{
    int aa[2][5] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int *ptr1 = (int *)(&aa + 1);
    int *ptr2 = (int *)(*(aa + 1));
    printf("%d,%d", *(ptr1 - 1), *(ptr2 - 1));
    return 0;
}
// 问:输出什么?

先画内存布局:

aa[0][0]...aa[0][4]    aa[1][0]...aa[1][4]
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│1 │2 │3 │4 │5 │6 │7 │8 │9 │10│  ← 连续存储
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
  ↑              ↑              ↑
  aa            aa+1          &aa+1
  (首行地址)    (第二行地址)    (跳过整个数组)

分析 ptr1:

  • &aa:整个二维数组的地址,类型为 int (*)[2][5]
  • &aa + 1:跳过整个二维数组(40 字节),指向数组尾后
  • (int *)(&aa + 1):强转为 int*
  • ptr1 - 1:int* 减 1 → 退回 4 字节 → 指向 aa[1][4] → 值为 10

分析 ptr2:

  • aa:二维数组名,是第一行的地址,类型为 int (*)[5]
  • aa + 1:跳过一行(20 字节),指向第二行
  • *(aa + 1):解引用,得到第二行本身(类型 int[5],退化为 int*)→ 等于 &aa[1][0]
  • ptr2 - 1:退回 4 字节 → 指向 aa[0][4] → 值为 5

答案:10,5

aa[0][0] aa[0][1] aa[0][2] aa[0][3] aa[0][4] aa[1][0] aa[1][1] aa[1][2] aa[1][3] aa[1][4]
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│   1    │   2    │   3    │   4    │   5    │   6    │   7    │   8    │   9    │   10   │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
                     ↑                   ↑                   ↑                   ↑
                  ptr2-1               ptr2           (aa+1解引用)          ptr1-1
                  值为5                                 值为6              ptr1
                                                                         值为10

本题和题目 1 的区别在于维度:&aa 的类型是 int (*)[2][5](指向整个二维数组),所以 +1 跳过 40 字节;aa+1 的类型是 int (*)[5](指向一行),所以 +1 只跳过 20 字节。指针的"级数"和"指向对象的大小"决定步长——&aa 和 aa 数值相同,但类型不同、跳法不同,这是贯穿整个系列的灵魂考点。

题目6:指针数组与二级指针

#include <stdio.h>
 
int main()
{
    char *a[] = {"work", "at", "alibaba"};
    char **pa = a;
    pa++;
    printf("%s\n", *pa);
    return 0;
}
// 问:输出什么?

画内存布局:

a[0]        a[1]        a[2]         ← 指针数组(连续存储)
┌──────────┬──────────┬──────────┐
│  →"work" │  →"at"   │→"alibaba"│
└────┬─────┴────┬─────┴────┬─────┘
     │          │          │
     ▼          ▼          ▼
   "work\0"  "at\0"  "alibaba\0"    ← 字符串常量
  • pa 初始指向 a[0](第一个指针的位置)
  • pa++ 后 pa 指向 a[1]
  • *pa = a[1] = 指向字符串 "at" 的指针
  • printf("%s", *pa) 打印 "at"

答案:at

易错点:char **pa = a; 这里 a 是"指针数组"(元素是 char*),数组名退化后是"指向第一个元素的指针",而第一个元素是 char*——所以是 char**,类型恰好匹配。pa++ 让 pa 移动一个"元素"(即 8 字节,一个指针的大小),从指向 a[0] 变成指向 a[1]。

题目7:多级指针终极挑战

#include <stdio.h>
 
int main()
{
    char  *c[]  = {"ENTER", "NEW", "POINT", "FIRST"};
    char **cp[] = {c + 3, c + 2, c + 1, c};
    char ***cpp = cp;
 
    printf("%s\n", **++cpp);          // ①
    printf("%s\n", *--*++cpp + 3);    // ②
    printf("%s\n", *cpp[-2] + 3);     // ③
    printf("%s\n", cpp[-1][-1] + 1);  // ④
 
    return 0;
}
// 问:输出什么?

这是指针笔试题中的终极 Boss。第一步,画出初始内存布局:

c[0]     c[1]     c[2]     c[3]       ← 指针数组
┌───────┬───────┬───────┬───────┐
│→ENTER │→NEW   │→POINT │→FIRST │
└───┬───┴───┬───┴───┬───┴───┬───┘

cp[0]    cp[1]    cp[2]    cp[3]     ← 二级指针数组
┌───────┬───────┬───────┬───────┐
│→c[3]  │→c[2]  │→c[1]  │→c[0]  │
└───┬───┴───┬───┴───┬───┴───┬───┘
    │       │       │       │
    │       │       │       └──→ c[0] → "ENTER"
    │       │       └──→ c[1] → "NEW"
    │       └──→ c[2] → "POINT"
    └──→ c[3] → "FIRST"

cpp → cp[0]

① **++cpp

++cpp:cpp 先加 1,现在 cpp 指向 cp[1]。*++cpp = cp[1] = c+2 = &c[2]。**++cpp = c[2] = "POINT"。

输出:POINT。操作后 cpp 指向 cp[1]。

② *--*++cpp + 3

当前状态:cpp → cp[1]。

++cpp:cpp 再加 1 → 指向 cp[2]。*++cpp = cp[2] = c+1 = &c[1]。--(*++cpp):将 c+1 减 1 → c(即 &c[0])。注意副作用:cp[2] 的值被改写为 c!*--*++cpp = c = "ENTER"。*--*++cpp + 3:地址向后偏移 3 个字符 → "ER"。

输出:ER。操作后 cpp 指向 cp[2]。

③ *cpp[-2] + 3

当前状态:cpp → cp[2](且 cp[2] 已被改写为 c)。

cpp[-2] = *(cpp-2) = cp[0] = c+3(cp[0] 没被改过)。*cpp[-2] = c[3] = "FIRST"。*cpp[-2] + 3 = 地址向后偏移 3 个字符 → "ST"。

注意:cpp[-2] 不改变 cpp 本身的值。

输出:ST。

④ cpp[-1][-1] + 1

当前状态:cpp → cp[2]。

cpp[-1] = *(cpp-1) = cp[1] = c+2 = &c[2]。cpp[-1][-1] = *(c+2-1) = *(c+1) = c[1] = "NEW"。cpp[-1][-1] + 1 = 地址向后偏移 1 个字符 → "EW"。

输出:EW。

最终答案:POINT、ER、ST、EW。

这道题全面考察了多级指针、自增自减的副作用、[] 运算符与指针运算的等价性、以及运算符优先级——把这些都串在了一起。解这类题的三步法:① 画全内存图(数组、指针数组、字符串常量);② 用括号把表达式拆成最小步骤,每步标出"副作用"(哪些指针被改了);③ 顺着 cpp 的变化追踪每一条线。 尤其注意 --*++cpp 里的 -- 作用于 *++cpp(即 cp[2]),它改写了数组元素——这是最容易漏看的副作用。

加餐题A:sizeof 与 strlen 的混合考核

#include <stdio.h>
#include <string.h>
 
int main()
{
    char arr[] = {'a', 'b', 'c', 'd', 'e', 'f'};   // 无\0,6字节
    char *p = "abcdef";                            // 常量区字符串
    int a[] = {1, 2, 3, 4};
 
    printf("%zu\n", sizeof(arr));      // 6
    printf("%zu\n", sizeof(p));        // 8(64位指针)
    printf("%zu\n", strlen(p));        // 6
    printf("%zu\n", sizeof(a));        // 16
    printf("%zu\n", sizeof(a + 0));    // 8(a+0退化指针)
    printf("%zu\n", sizeof(*a));       // 4(首元素)
    return 0;
}

规律再背一遍:数组名单独出现在 sizeof 里 → 整个数组;数组名参与任何运算(+0 也是运算)→ 退化为指针;*数组名 → 首元素。

加餐题B:函数形参中的 sizeof

#include <stdio.h>
 
void test(int arr[5], char *p)
{
    printf("%zu %zu\n", sizeof(arr), sizeof(p));
    // 都是 8!形参退化为指针,数组大小信息全丢
}
 
int main()
{
    int arr[5] = {1, 2, 3, 4, 5};
    char *s = "hello";
    test(arr, s);
    return 0;
}

int arr[5] 形参里的 5 是"装饰",sizeof(arr) 永远是 8(64 位指针)。想拿长度必须另传参数——这是笔试最爱考的一行。

加餐题C:字符串指针比较

#include <stdio.h>
 
int main()
{
    char str1[] = "abc";
    char str2[] = "abc";
    char *p1 = "abc";
    char *p2 = "abc";
 
    printf("%d\n", str1 == str2);   // 0(两个数组地址不同)
    printf("%d\n", p1 == p2);       // 1(常量合并,指向同一处)
    printf("%d\n", str1 == p1);     // 0(数组地址 ≠ 常量地址)
    printf("%d\n", strcmp(str1, str2));  // 0(内容相同)
    return 0;
}

str1 == str2 比较地址,strcmp(str1, str2) 比较内容——比较字符串内容永远用 strcmp。前两个比较依赖编译器行为(常量合并),不可移植,但笔试默认主流编译器会合并。

加餐题D:二维数组行指针的 sizeof

#include <stdio.h>
 
int main()
{
    int a[3][4] = {0};
 
    printf("%zu\n", sizeof(a));       // 48(整个二维数组)
    printf("%zu\n", sizeof(a[0]));    // 16(第0行,一维数组)
    printf("%zu\n", sizeof(a[0]+1));  // 8(第0行第1列元素的地址)
    printf("%zu\n", sizeof(*(a+1)));  // 16(第1行)
    printf("%zu\n", sizeof(&a));      // 8(整个二维数组的地址,指针)
    return 0;
}

规则:a → 整个数组 48;a[0] → 一行 16;*(a+1) → 一行 16;任何"地址" → 8。

加餐题E:笔试题常见的指针自增综合

#include <stdio.h>
 
int main()
{
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;
 
    printf("%d\n", *p++);    // 10(取arr[0],p→arr[1])
    printf("%d\n", (*p)++);  // 20(取arr[1]的值并让它+1,p不变)
    printf("%d\n", *p);      // 21(arr[1]被改成了21)
    printf("%d\n", *++p);    // 30(p先移到arr[2],再取30)
    printf("%d\n", *p + 1);  // 31(取30再加1,p不动)
    return 0;
}

这题专治"++ 和 * 组合"的优先级混乱。逐个拆:*p++ = *(p++)(后缀++优先);(*p)++ 是"元素值自增";*++p = *(++p)(前置++先动指针);*p + 1 是"值加1",和 *(p+1)(下一个元素)完全不同。

应试自查清单

通过这 7+5 道题,你可以清晰看到笔试高频陷阱的几大类型。临考前把这份清单过一遍,基本就不会翻车:

数组名规则

  • sizeof(数组名) = 整个数组;&数组名 类型是数组指针;其他语境都是首元素地址
  • &arr + 1 跳过整个数组;arr + 1 跳过 1 个元素
  • 数组名不可赋值、不可 ++

指针运算

  • 步长 = sizeof(指向的类型),类型决定一切
  • 指针 - 指针 = 元素个数(ptrdiff_t,打印 %td)
  • 越界、不同数组相减比较、p + q、p * 2 都是未定义/非法
  • (int*)p + 1 ≠ (int*)(p + 1)

sizeof / strlen

  • sizeof 是操作符、编译期、不计算表达式(sizeof(i++) 不增 i)
  • strlen 是函数、运行期、找 \0、返回 size_t
  • sizeof("abc") = 4,strlen("abc") = 3
  • strlen 只接受 const char*,strlen(*arr)/strlen(&arr) 全是坑

const 与指针

  • const 在 * 左 → 内容只读;在 * 右 → 指向只读
  • 函数形参只读时写 const T *

多级指针

  • **pp == *p == a,解引用一层走一层
  • 要改"指针变量"就传"指针的地址"(二级指针)
  • 二维数组名匹配 int (*)[N],不匹配 int **

函数指针

  • 函数名就是地址,pf(...) 与 (*pf)(...) 等价
  • int (*fp)(int) 是指针,int *fp(int) 是函数
  • qsort 比较函数:返回值 <0/=0/>0,char * 数组要 *(const char **)a

然后,独立把本文的 7 道真题 + 5 道加餐题重新做一遍——不看答案,画内存图,写输出。再对照答案检查。


通过这 12 道题,你可以清晰看到笔试高频陷阱的几大类型:&数组名 的类型混淆(&arr+1 跳过整个数组 vs 一个元素)、逗号表达式与初始化列表的混淆、指针类型决定步长导致的地址差异、强制类型转换改变编译器对指针的"看法"、自增自减的副作用改变指针状态、sizeof 编译期不实际计算表达式。如果你能逐题画出内存图、推演每一步,你的指针功底就已经超过了 90% 的程序员。

到此为止,C 语言指针系列的六篇文章全部结束了。从 int *p = &a; 这句最基础的话,到 ***++cpp 这种多级解引用,我们一路走完了指针从入门到精通的全过程。指针不是"学一次就会"的知识——它需要反复读、反复写、反复调试。如果你把这六篇文章中的每一段代码都亲手敲过、每一个内存图都自己画过、每一道笔试题都独立做对了,那么翻过指针这座山,后面的路就开阔了。

编程之路很长,但指针是一块里程碑。恭喜你走到了这里。


本文基于C语言教学课件《第16讲:深入理解指针(6)》的内容框架撰写,所有代码示例均在GCC/MinGW环境下验证通过。