学完了前五篇,你已经掌握了指针的方方面面——从最基础的内存地址,到 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环境下验证通过。
还没有评论 — 第一条由你来留。