恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C/C++数组与指针笔试题全攻略:从sizeof到函数指针
首页
资讯中心
/
C/C++数组与指针笔试题全攻略:从sizeof到函数指针
C/C++数组与指针笔试题全攻略:从sizeof到函数指针
发布时间:2026/10/1 12:23:18
数组和指针几乎是每一场 C/C 笔试的送分题也是送命题。说是送分因为翻来覆去就那几个考点说是送命因为考点的细节特别容易记混一个sizeof和strlen的差别就能筛掉一大批简历上写着“精通C语言”的候选人。我自己在面试别人的时候也经常拿这几道经典题当开场能答对的人并不多。这篇文章把常见的数组和指针笔试题做一次汇总从数组名的退化规则到函数指针的回调写法每个题目都会讲清“为什么”而不是只丢结论。无论你是准备校招的在校生还是用 C 语言做嵌入式、后端的老手都可以当作复习提纲来刷。1. 先搞清楚数组名和指针的关系1.1 数组名不是指针但会退化成指针很多人张嘴就说“数组名就是指针”这句话在笔试题里如果直接用来答题至少丢一半分。C 语言里数组名其实代表整个数组对象它有自己的类型和大小。只是在绝大多数表达式中数组名会“退化”decay成指向首元素的指针比如赋值、算术运算、函数传参的时候。但在sizeof和这两种操作符下数组名不会退化。看这段代码#include stdio.h int main(void) { int a[5] {1, 2, 3, 4, 5}; printf(%zu\n, sizeof(a)); // 20整个数组大小5 * 4 printf(%zu\n, sizeof(a 0));// 8a0 已经是表达式运算退化成 int* printf(%zu\n, sizeof(a)); // 8a 是 int(*)[5]本质也是指针 printf(%p %p %p\n, a, a 1, a 1); return 0; }在 64 位系统上sizeof(a)是 20因为sizeof直接作用在数组名上编译器知道这是一个 5 个int的数组。a 0触发了退化得到的是int*所以大小是 8 字节。a的类型是“指向含 5 个元素的数组的指针”它同样是一根指针所以大小也是 8。但注意a和a打印出来的地址值可能一样类型和步长完全不同a 1跳过一个int也就是 4 字节a 1跳过整个数组也就是 20 字节。这种题在笔试里经常以“输出下面程序的结果”出现核心就是看你能不能分辨int*和int(*)[5]。另一个常被拿来考的性质是数组名是左值但不是可修改的左值所以a、a a 1都是非法的。能移动的是一个独立指针变量比如int *p a; p;。这些细节才是面试官真正想考察的基础功。1.2 sizeof 和 strlen 的经典对比题sizeof是编译期运算符它只关心类型占用的内存大小不会去访问内存内容strlen是标准库函数它从指针开始逐字节扫描直到遇到\0才停止访问的是内容。笔试中有一道极高频的题char str[] hello; char *p hello; printf(%zu %zu\n, sizeof(str), strlen(str)); // 6 5 printf(%zu %zu\n, sizeof(p), sizeof(hello)); // 8 5str是存放字符串的数组包含了末尾隐藏的\0所以sizeof(str)是 6strlen是 5。p是指针在 64 位系统上sizeof(p)是 8。hello作为字符串字面量类型是char[6]所以sizeof(hello)是 6不是 5。如果想当然地认为字符串长度就是 5第一题就会掉坑。还有一个变种char str[10] hello; printf(%zu %zu\n, sizeof(str), strlen(str)); // 10 5这里sizeof是数组长度 10不是字符串长度。初始化的意义只是把h,e,l,l,o,\0拷贝到数组前 6 个字节数组其余部分未初始化但整个数组大小仍然是 10。strlen会从 str[0] 数到 str[5] 的\0所以结果是 5。这个区别在面试中还有更深一层的意义sizeof在编译时就能得出结果不产生运行时访问strlen必须运行时访问内存如果传的是NULL会直接段错误。所以笔试题若是问“能否用一个strlen来判断一个数组是否有效”答案是不能因为你没法通过字符串函数去判断一个地址是否合法。2. 指针数组还是数组指针声明辨析题2.1 从优先级看懂复杂声明以前置声明来举例int *p[4]; // 指针数组 int (*p)[4]; // 数组指针C 语言声明优先级规则很简单[]的后置优先级高于前置*。所以int *p[4]中p先和[4]结合它是一个数组数组里有 4 个int*元素。int (*p)[4]因为加上了括号p先和*结合它是一个指针指向一个长度为 4 的int数组。笔试题经常用这两种声明做选择题比如“声明一个指向含 3 个整型元素数组的指针”。正确写法是int (*p)[3]很多人会写成int *p[3]。还有一种更狠的考法int *a[3]; int (*b)[3]; sizeof(a); // 3 个指针64 位下是 24 sizeof(b); // 一个指针8a的每个元素都能单独指向一个int但b只有一个地址必须指向某个长度为 3 的数组。它们在内存里的布局是完全不同的。一个是指针的集合一个是指向数组的指针。实际项目里我习惯用typedef把复杂声明包一层笔试题也会给一种让你“用 typedef 改写”的考法typedef int (*ArrayPtr)[4]; ArrayPtr p;这样声明可读性高很多代码评审的时候也不会被同事骂。2.2 二维数组传参的秘密二维数组int a[3][4]的类型到底怎么理解a是一个长度为 3 的数组每个元素是长度为 4 的int数组。所以数组名退化成int (*)[4]是一个数组指针。当一个二维数组作为函数参数时以下两种写法等价void func(int a[][4], int rows); void func(int (*a)[4], int rows);因为它们本质上都是int (*)[4]类型的指针。但很常见的错误是写成void func(int **a);然后期望用它接收int arr[3][4]。这个在编译阶段可能不报错或只报警告但运行时访问a[i][j]几乎必然出问题。因为int**的内存模型是“指针的指针”先解引用拿到的应该是一个int*的地址而二维数组的第一块内存是纯int数据根本不是指针。这就像拿到了一个门牌号却把它当成另一个门牌号的地址来用指向完全不对。记住一个判断标准二维数组int a[M][N]退化后是int (*)[N]真正能传给int**的是指针数组int *p[M]。这两个东西长得很像但内存布局完全相反。笔试里给出“int **p a;是否合法”这种题就是在考这个点。2.3 字符数组指针和字符串指针数组字符串笔试题很喜欢混搭指针数组和二维字符数组。比如char *keywords[] {if, else, while}; char keywords2[][8] {if, else, while};第一行声明了一个指针数组元素是 3 个char*分别指向三个字符串字面量。每个字符串字面量通常存在只读数据区修改keywords[0][0]是非法的。第二行声明了一个二维数组每个字符串在初始化时被拷贝到数组自己的空间里修改keywords2[0][0]是合法的。笔试里会出现“下列哪个程序会崩溃”的题目答案通常就是选择char *p hello; p[0] H;。不要觉得这个题太基础很多工作两三年的工程师在这道题上也会犹豫。3. 指针运算的算术题别被步长绕晕3.1 指针加减走的是类型步长指针本身保存地址但p 1并不是地址值加 1而是加一个“指向类型大小”。int*加 1 跳 4 字节char*加 1 跳 1 字节void*在标准 C 中不允许直接做加减运算GCC 的扩展允许但面试题一般只考前者。经典笔试题int a[] {10, 20, 30, 40, 50}; int *p a; printf(%d\n, *p); // 10先取 *p再 p printf(%d\n, *p); // 30先 p再取 *p printf(%d\n, (*p)); // 30先取 *p再把 a[2] 自增为 31 printf(%d\n, *p); // 32先把 a[2] 自增再取值要理解这四种表达式的区别关键在于后缀的优先级高于*而前缀虽然优先级也高于*但结合顺序不一样。*p会被解析成*(p)先保存 p 当前指向的值再把 p 移动。(*p)是先对 p 指向的对象值自增。这类题在笔试中经常要求写出每个 printf 的输出错一个就是零分。还有一种考法是“指针相减”int *p1 a[4]; int *p2 a[1]; printf(%td\n, p1 - p2); // 3指针相减的结果是两个元素的下标差类型是ptrdiff_t用%td打印。如果你直接打印地址值相减再除以元素大小逻辑也对但标准要求必须指向同一数组的元素才合法。指向不同数组的两个指针做减法是未定义行为实际编译器可能给出一个诡异的值。3.2 多级指针和二维数组的名分问题二级指针int **pp要理解成“指向指针的指针”。它最常见的用处是通过函数修改外部的指针变量。比如你要写一个初始化函数void init(int **pp) { *pp malloc(sizeof(int)); }然后调用int *p NULL; init(p);如果是void init(int *p)你只能修改p指向的int没法修改p本身。传int**才能让函数内部改变外部指针的值。这个知识点在链表插入、二叉树构造的题里也很常见。二维数组名和多级指针的关系则是笔试里的重灾区。很多人觉得int a[3][4]的a退化成int**这是错的。a退化后是int (*)[4]不是int**。二维数组在内存里是一块连续的int数据按行排列int**指向的却是一个指针数组先拿到int*再到目标数据。两者类型不兼容不能直接赋值。如果非要探讨“二维数组名能否看成二级指针”可以看这一层的逻辑a的首地址值等于a[0]的首地址值但类型不同a1移动一行a[0]1移动一个元素。这个动作在教材里常叫“指针跳跃”其实本质就是指针步长不同。理解了这个后面的二维数组遍历*(*(a i) j)就不会晕。4. 字符串笔试题字符数组和字符指针的区别4.1 字符数组和字符指针的三大区别首先看两段声明char str1[] hello; char *str2 hello;第一点是存储位置。str1是一个数组元素存放在当前作用域的栈上或者全局数据区编译器会把hello的内容拷贝到数组里。str2是一个指针变量它保存的地址指向字符串字面量而字符串字面量通常放在只读数据段。所以str1[0] H合法str2[0] H未定义行为在 Linux 上通常直接段错误。第二点是sizeof结果不同。sizeof(str1)是 6包含了\0sizeof(str2)是指针大小8 字节。遇到这一类题先看声明是数组还是指针再决定用哪套逻辑。第三点是赋值行为。数组是“变量名代表这块内存”不能整体赋值比如str1 world非法指针可以改指向str2 world合法只是让指针换一个地方指。笔试常见的理解陷阱是char *p hello中的hello到底能不能改不能。即使是在某些平台上实测没崩也是未定义行为编译器可以任意处理。写代码不要依赖任何一个平台的“恰好能跑”。4.2 字符串函数边界与地位strcpy、strcat这类函数不检查目标缓冲区长度笔试题往往会问“是否会越界”。举一个很简单的例子char dest[5]; strcpy(dest, hello);hello实际上占 6 字节包含结尾的\0而dest只有 5 字节所以这次拷贝越界写入一个\0看起来没崩但已经破坏了相邻内存。如果笔试改成strcpy(dest, hello!)那越界更严重可能直接运行时报错。strcat的考点是dest必须以\0结尾且剩余空间足够容纳追加后的字符串。如果第一个字符串已经占满整个数组没有位置放\0strcat会一直往后找直到某个字节等于 0 为止几乎一定会越界。还有一类数值陷阱题if (strlen(s) -1) { printf(always true\n); }strlen返回size_t是无符号类型-1会被转换成无符号整数变成一个非常大的数。所以strlen(s) -1永远成立。这种题考的不是字符串本身而是 C 语言无符号数的隐式转换。数组转字符串在笔试里也是一个实用题。C 语言中可以用snprintf把数组元素格式化成字符串int arr[] {1, 2, 3}; char buf[64]; buf[0] \0; for (int i 0; i 3; i) { char tmp[8]; snprintf(tmp, sizeof(tmp), %d, arr[i]); strcat(buf, tmp); }这里的坑是buf的容量要预估够如果元素多很容易溢出。宁可先计算长度再分配也不要直接写一个很可能不够的固定大小。4.3 指针数组存放字符串的典型题给定char *fruit[] {apple, banana, cherry};fruit[0]是char*指向apple的首字符。下面的输出printf(%c\n, *fruit[1]); // b printf(%c\n, *(fruit[2] 1)); // h printf(%c\n, fruit[1][0]); // b这三种写法是等价的。字节层面fruit数组连续放了 3 个 8 字节指针每个指针指向一个独立的字符串字面量fruit[1][0]的运算逻辑是先取fruit[1]这个char*再按字符下标访问。如果把fruit改成char fruit[][8]fruit[1]就是一行数组的地址fruit[1][0]的值一样但内存布局不同一个是连续字符串数据一个是指针表加外部数据。5. const 指针和指针 const 的绕口令题5.1 四种位置逐一拆解const 在指针声明中出现的位置基本能决定一道选择题的胜负。const int *p; int const *p; int * const p; const int * const p;const int *p和int const *p是同一个意思p本身可变可以指向别的地址但不能通过*p修改所指向的int。int * const pp本身不可变必须初始化之后不能指向别的地址但可以修改*p。const int * const p两者都不能变。记忆口诀是const在*左边修饰的是“指针指向的目标”const在*右边修饰的是“指针本身”。笔试题经常给出这样的选项int a 10, b 20; const int *p a; p b; // 合法 *p 30; // 非法再看int * const cp a; *cp 30; // 合法 cp b; // 非法很多初学者会把int * const cp和int **p混在一起记实际上完全不需要混。声明时从变量名开始往外读const离谁近就限定谁。5.2 const 参数在函数接口中的作用写一个只读参数的函数void print_array(const int *arr, size_t n) { for (size_t i 0; i n; i) { printf(%d\n, arr[i]); } }const int *arr表示这个函数约定“不会修改数组内容”调用方可以大胆地传入只读数据。如果不加const调用方会有顾虑加了const等于把接口契约写在类型里编译器也会帮你去检查。还有一个容易被忽略的类型转换问题const int c 10; int *p c; // 严格来说是错误/警告你不能把const int*直接赋值给int*否则一旦通过p修改c就走进了未定义行为。反过来把int*传给const int*参数是允许的这属于安全的“收缩权限”。笔试中常考“以下赋值哪一行编译报错”其实就是看 const 限定是否被违反。记住一个铁律去掉限定是危险的加上限定是安全的。6. 函数指针与指针函数面试官最爱让你写回调函数6.1 声明解析与用法指针函数是指返回值是指针的函数比如int *find(int *arr, int n);函数指针是指一个变量保存函数的地址声明格式int (*handler)(int, int);为什么必须加括号因为不加*会先结合函数参数变成“返回int*的函数”。这个括号不是为了美观是在改变结合优先级。常见的例子int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int (*op)(int, int) add; printf(%d\n, op(3, 4)); // 7函数名在表达式中会隐式转换为函数指针所以op add不需要add。op(3, 4)和(*op)(3, 4)两种调用写法都可以因为编译器会自动处理但笔试有时会考察你“函数指针调用时是否加了*”两者都是合法写法。6.2 库函数 qsort 与回调机制C 标准库的qsort是函数指针最经典的应用。它要求你提供一个比较函数#include stdlib.h int compare_int(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (ia ib) - (ia ib); } int main(void) { int arr[] {3, 1, 4, 2}; qsort(arr, 4, sizeof(int), compare_int); return 0; }compare_int用(ia ib) - (ia ib)而不是ia - ib是为了避免减法溢出。这个细节在面试里能加分。qsort不知道要排序的元素类型它只按你给的元素大小和比较函数来移动内存这正是函数指针回调的价值把“怎么比较”从排序算法里抽出来变成注入进去的策略。6.3 函数指针数组与结构体指针组合函数指针数组适合实现“表驱动”逻辑。比如一个简单命令分发器void start(void) { puts(start); } void stop(void) { puts(stop); } void reset(void) { puts(reset); } void (*commands[])(void) {start, stop, reset};调用commands[0]()就执行start。这种写法比写一长串if/else更清晰扩展新命令只需要加函数和表项。结构体指针和函数指针还可以组合出类似面向对象的效果typedef struct { void (*init)(void); void (*run)(void); void (*deinit)(void); } Device; static void device_init(void) { /* ... */ } static void device_run(void) { /* ... */ } static void device_deinit(void) { /* ... */ } Device dev {device_init, device_run, device_deinit};函数指针表被放在结构体里调用者只需要维护一个Device *指针不需要关心具体实现。嵌入式驱动开发里经常这么写。7. 综合实战题目双指针、数组去重和内存操作7.1 双指针法做有序数组原地去重这是一道高频手写题给你一个升序数组要求原地去重并返回新长度。不允许额外开数组。int removeDuplicates(int *nums, int numsSize) { if (numsSize 0) return 0; int slow 0; for (int fast 1; fast numsSize; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }快指针负责遍历整个数组慢指针维护“干净数组”的边界。因为数组有序凡是遇到新元素就放到慢指针的下一个位置。这个算法时间 O(n)空间 O(1)比“碰到重复就删除一个元素并整体搬移”的写法高效得多。这种“双指针法”换个马甲还能考。比如有序数组两数之和int *twoSum(int *nums, int numsSize, int target, int *returnSize) { int left 0, right numsSize - 1; while (left right) { int sum nums[left] nums[right]; if (sum target) { int *res malloc(2 * sizeof(int)); res[0] left; res[1] right; *returnSize 2; return res; } else if (sum target) { left; } else { right--; } } *returnSize 0; return NULL; }无序数组找“哪些数据和等于固定值”就复杂一些。通常可以用回溯枚举所有组合也可以先排序再用双指针去重。笔试题如果遇到“给定一列数和固定值判断是否存在两个数之和等于固定值”头一个应该想到排序加双指针而不是暴力双重循环。7.2 交换两个数的指针陷阱最普通的交换函数void swap(int *a, int *b) { int temp *a; *a *b; *b temp; }这里有个隐含考点C 语言函数参数是值传递形参a和b是外部指针的副本但指向的地址没变所以可以通过*a修改外部变量。如果写成swap(int a, int b)那只能把副本交换什么也改不了。有些人喜欢用异或避免临时变量void swap(int *a, int *b) { *a ^ *b; *b ^ *a; *a ^ *b; }但面试题如果问“这段代码有什么问题”答案通常是如果调用swap(x, x)同一个地址时*a和*b都是同一个值第一步会把该值清成 0后面无法恢复。解决办法是开头加一句if (a b) return;或者干脆老老实实用临时变量。这种题就是在考察你是否理解指针别名带来的副作用。C 里更推荐用引用void swap(int a, int b) { int temp a; a b; b temp; }引用本质也是别名但调用时少写代码更干净。笔试题经常混着问“C 和 C 在交换函数上的区别”能讲清指针和引用在底层上的关系和差别就是加分项。7.3 动态数组与内存释放的顺序问题笔试里动态数组的题大多围绕malloc/realloc/freeint *arr malloc(10 * sizeof(int)); if (arr NULL) { // 处理失败 } arr[0] 1; free(arr); arr NULL; // 防止野指针这里有几个高频考点。第一malloc的返回值必须检查不要直接使用。第二释放后必须把指针置空否则之后如果不小心再 free 一次就是 double free。第三malloc(0)的行为由实现定义可能返回非空指针但你不应该去访问它。结构体指针的内存释放顺序更容易踩坑typedef struct { char *name; } User; User *u malloc(sizeof(User)); u-name malloc(32 * sizeof(char)); free(u-name); free(u);必须先释放内层指针指向的内存再释放结构体本身。如果先free(u)u已经成了悬垂指针再去访问u-name就是未定义行为。很多内存泄漏工具报出来的都是在某个分支里忘了释放name这种题在实际代码 review 里出现频率极高。C 的new[]也必须对应delete[]int *arr new int[10]; delete[] arr;如果误用delete arr只调用了析构函数但内存释放行为未定义。C 里更推荐直接用std::vectorint或std::unique_ptrint[]让 RAII 帮我们管理生命周期。笔试问“为什么需要智能指针”很大一部分原因就是前面这些裸指针释放的坑太多。8. 常见坑与排查技巧实录8.1 野指针、空指针、悬垂指针这三种指针概念经常被混着考。空指针没有指向任何有效对象值是NULL或nullptr。解引用空指针会崩溃。野指针指针变量没有被初始化保存了一个随机地址。这种问题很难排查因为不一定立刻崩溃。解决习惯是声明指针时立刻赋值NULL。悬垂指针指针原来指向一块内存那块内存被释放了但指针没有置空。释放后继续使用属于 use-after-freeJemalloc 或 ASan 能检测到但裸机嵌入式环境里查起来很痛苦。容易犯的一个错误是函数返回局部变量的地址int *func(void) { int x 100; return x; // 局部变量生命周期结束指针悬垂 }这个x在函数返回后已经无效访问它属于未定义行为。编译器可能给个警告但很多人不以为然。正确做法是传入缓冲区指针或者用malloc分配并明确调用方负责释放。另外要特别留意回调函数里的空指针。比如定时器任务里执行查询报空指针大部分时候不是“空”这个现象本身而是指针指向的生命周期已经结束或者回调触发时对象还没初始化。排查看起来在查空其实在查时序。8.2 用编译器和运行时工具排查指针问题写代码时把这些开关打开很多坑在开发阶段就暴露了gcc -Wall -Wextra -Werror -g -o test test.c-Wall -Wextra能给出类型不匹配、未初始化变量之类的大多数警告。如果用了-Werror警告会变成错误逼着你正视问题。但真正的神器是 AddressSanitizergcc -g -fsanitizeaddress -o test test.c ./test它会检测数组越界、use-after-free、double free输出类似ERROR: AddressSanitizer: heap-buffer-overflow on address ...位置精确到文件行号比gdb一点点调效率高太多。我排查线上 C 模块时第一个动作就是用 ASan 重新编一遍问题基本立刻现形。gdb里几个实用命令也值得记住p打印变量x/10x 指针查看地址附近内存bt查看调用栈watch监视某个内存地址何时被改写。遇到奇怪的值先打印指针地址和对应内存内容比空想有效。最后一条经验在笔试或面试手写代码时养成“指针每次移动后都检查边界”的习惯。写arr[slow] arr[fast]之前先想一想slow和fast是不是有越界风险写strcpy前看一眼目标缓冲区容量。数组和指针的题考试内容从来不是算法有多难而是你有没有把底层细节刻进本能里。我自己刷过无数套题后最深的体会就是能把最简单的sizeof、数组名、函数指针讲明白的人基础一定不会差。这份汇总不求背题只求帮你把原理理顺实战的时候哪怕题目换了个马甲也能一眼看穿。