嵌入式方向的C语言面试,指针这块几乎是必考区,而且面试官特别喜欢从各种刁钻角度来验证你是不是真的懂,而不是背了几道题。我在招人时也经常问指针相关的问题,说实话,能把这个话题讲透的候选人,基础功基本都扎实,后续带起来也省心。这篇文章就把指针、数组、野指针、引用这几个高频考点放在一起做个系统梳理,结合这些年我在面试中和实际开发里遇到的真实场景来聊,希望能帮准备面试的朋友把这条线彻底打通。
1. 指针变量本身:面试官绕不过去的第一道坎
很多人觉得指针难,其实指针的概念本身并不复杂,复杂的是它和其他语法特性交织在一起时产生的各种组合变化。面试官问指针,第一步通常是想确认你对指针变量的本质有清晰认知。
1.1 指针变量也是变量,存的不是值而是地址
指针变量首先是一个变量,它也需要占用内存空间,只不过它里面存的东西比较特殊——不是普通的数据,而是另一个变量的地址。这就像你用手机通讯录存了一个朋友的电话号码,通讯录的名字是指针变量,电话号码是地址,你通过这个电话可以联系到朋友,就像通过指针可以访问到目标变量。
这里有一个面试官特别爱问的细节:指针变量本身占多大内存?
在32位系统上,地址总线是32根,所以一个指针变量占4字节;在64位系统上,指针变量占8字节。这个和指针指向的数据类型无关——不管你指向的是char、int还是一个结构体,指针变量本身的大小只跟平台位数有关。很多初学者会以为int*比char*占的内存大,这是错误的理解。
#include <stdio.h> int main(void) { char *p_char = NULL; int *p_int = NULL; double *p_double = NULL; struct Test *p_struct = NULL; printf("char pointer: %zu bytes\n", sizeof(p_char)); printf("int pointer: %zu bytes\n", sizeof(p_int)); printf("double pointer: %zu bytes\n", sizeof(p_double)); printf("struct pointer: %zu bytes\n", sizeof(p_struct)); return 0; }我在面试中加了这个问题后,大概有七成候选人能答出“跟平台有关”,但能准确补一句“指针大小和指向类型无关”的人就少一些。这句话虽然简短,但说明他对指针的存储模型是真的理解了,而不是背过结论。
1.2 指针的类型决定读写的边界
既然指针变量存的是地址,那为什么还要区分int*、char*、double*呢?这就要说到指针类型的两层含义了:
- 解引用时读多少字节:
int*解引用读4字节,char*解引用读1字节,double*解引用读8字节。 - 步长(指针加减时的跨度):
p + 1跳过的字节数等于sizeof(指向类型)。
这两点是面试必考方向,往往会通过sizeof和自增运算来命题。比如下面这段代码,让你猜输出:
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; int *p2 = &arr[2]; printf("%d\n", *p); // 10 printf("%d\n", *(p + 1)); // 20 printf("%d\n", p2 - p); // 2 printf("%ld\n", (char*)p2 - (char*)p); // 8p2 - p的结果是2,因为指针减法的单位是“元素个数”,不是字节数;但强转成char*再减,结果就是8字节。这种题目在笔试题里非常多见,本质就是考察指针运算的步长概念。
注意:指针减法只有在两个指针指向同一个数组(或者同一个内存区域)时才有定义。两个无关的指针做减法,结果未定义,这一点面试官可能作为加分追问。
1.3 指针的三种修饰:const在不同位置的含义
const和指针的组合很容易把人绕晕,我用一套简单的方法教大家区分:看const和*的相对位置。const在*左边,说明指向的是常量(值不能改);const在*右边,说明指针本身是常量(地址不能改)。
const int *p; // 指向常量的指针,p可以改,*p不可以改 int const *p; // 同上,这种写法少见 int *const p; // 常量指针,p不可以改,*p可以改 const int *const p; // 两者都不可改面试时我一般不会直接问定义,而是给一段代码问哪里编译报错。比如:
const int a = 100; int *p = &a; // 编译警告/错误&a的类型是const int*,不能直接赋给int*。这一点很多人会忽略,但它背后是C语言的类型安全机制——如果你能用普通指针去改一个const变量的值,const就形同虚设了。嵌入式开发中,const常用于定义只读参数表、代码段中的常量数据,用好它可以有效防止误改。
2. 数组和指针的等价与不等价:经典考题背后的本质
数组和指针经常被拿来一起讨论,主要是因为两者在绝大多数语境下可以等价使用。但等价是有条件的,面试官挖的坑往往就在那些不能等价的地方。
2.1 数组名的双重身份
数组名这个符号很特殊,它既是整个数组的标识符,又在多数表达式中隐式转换为首元素地址。这就是“数组名可以赋值给指针”的根本原因。
int arr[5]; int *p1 = arr; // 等价于 int *p1 = &arr[0] int *p2 = &arr[0]; // 这是显式写法两者的区别在于,arr类型是int[5],隐式转换后才是int*。在需要整个数组类型的语境下,数组名不能当作指针用:
sizeof(arr)的结果是整个数组的字节数(5 * 4 = 20),不是指针的大小。&arr的类型是int(*)[5](指向整个数组的指针),不是一个普通的int**。
我在面试中经常出这样一道题:
int arr[5] = {0}; int *p = arr; printf("%p\n", (void*)arr); // 假设输出 0x1000 printf("%p\n", (void*)(arr + 1)); // 0x1004,跳过4字节 printf("%p\n", (void*)(&arr + 1)); // 0x1014,跳过整个数组20字节第一次答对的人不少,但能把第三个讲清楚的不多。&arr + 1跨过的是整个数组,这在分配二维数组内存、做缓冲区对齐时非常有用。嵌入式开发中处理DMA缓冲区、外设寄存器映射时经常用到这种技巧。
2.2 指针数组 vs 数组指针
这两个概念在面试中几乎是必问:
- 指针数组:本质是数组,数组里存的是指针。定义形式是
int *p_arr[5]。 - 数组指针:本质是指针,指向一个数组。定义形式是
int (*p_arr)[5]。
记住一个口诀:先看右侧,再看左侧。int *p_arr[5]中,p_arr先和[5]结合,说明它是数组,元素类型是int*;int (*p_arr)[5]中,括号让p_arr先和*结合,说明它是指针,指向一个长度为5的int数组。
指针数组在实际开发中用得很广,最典型的场景是命令行参数表、菜单表、键值对映射表。嵌入式里做命令解析,我经常用指针数组来保存命令字符串:
const char *cmd_table[] = { "help", "reset", "version", NULL // 哨兵 };遍历时只需要循环到NULL为止,不需要额外记录长度,非常方便。而且用const修饰,确保只读。
2.3 二维数组传参:降维打击的尺寸之谜
二维数组传到函数里,第一维的维度是可以省略的,但第二维必须写清楚,因为编译器需要知道每一行的步长。这也是面试官爱挖的坑。
void func1(int a[][4]) { // 合法 } void func2(int (*a)[4]) { // 合法,和上面完全等价 } void func3(int **a) { // 不合法!类型不匹配 }很多人搞不清楚第三个为什么不行,因为二维数组在内存里是连续排列的线性结构,a[i][j]的地址是通过base + i * 列数 + j算出来的,编译器必须知道列数。而int**表示的是“指向指针的指针”,它默认内存模型是“指针数组的数组”,即先通过第一个指针跳转到一行,再通过第二个指针跳转到元素,这和二维数组的存储模型完全不同。
我见过不少候选人在这个点上卡壳,明明平时写过二维数组的程序,但让他传参就懵了。
提示:如果要传“动态生成的二维数组”(也就是手动malloc出来的指针数组),那确实可以用
int**来传,但这时的内存模型和二维数组完全不同,不能混为一谈。这个区别是面试官常埋的雷,答的时候要区分清楚。
2.4 面试真题:用指针实现数组反转
这种题其实考察的不是算法,而是你能不能自然地使用指针操作。我最常见到下面这种写法:
void reverse_array(int *arr, int n) { int *left = arr; int *right = arr + n - 1; while (left < right) { int tmp = *left; *left = *right; *right = tmp; left++; right--; } }这个解法本身没什么稀奇,考察的是两个细节:
arr + n - 1能不能准确定位到最后一个元素?这取决于对“数组越界一位”的理解——数组中最后一个合法元素是arr + n - 1,arr + n是越界的,但C标准允许指针指向数组结尾后一位,只允许比较和减法,不允许解引用。left和right用的是元素地址的移动,而不是下标操作。你可以看到实现里没有任何一个[i],全程都靠指针自增自减——这就是“指针思维”。
3. 野指针:很多崩溃问题的根源藏在它这里
野指针这个词在面试里几乎是必问,而且面试官不只是让你背定义,他更想听你怎么排查、怎么预防。因为野指针直接关系到嵌入式系统的稳定性——一个崩溃可能就发生在断电重启、误操作寄存器之后,很难复现。
3.1 野指针的三类来源
当面试官问“野指针是什么”的时候,建议不要只背一个定义,直接把三类来源讲清楚,会显得你经验丰富:
来源一:未初始化的指针
int *p; *p = 100; // p未被赋值,指向未知内存,写入行为未定义局部变量的值是随机的,不能被当作有效地址来用。这个问题的根源是没有给指针一个明确的初值。C语言不会自动帮你把指针初始化为NULL,这跟很多高级语言不一样。
来源二:指向已释放的内存(悬空指针)
int *p = malloc(sizeof(int) * 10); free(p); // p 还保留着原来地址,但内存已被释放 *p = 5; // 这是典型的 use-after-freefree(p)之后,p并没有变成NULL,它仍然指向那块地址。但地址上的内存已经被归还给堆管理器了,可能被重新分配,也可能还没被写保护。对它的访问是未定义行为——轻则读到脏数据,重则导致二次崩溃。
来源三:返回局部变量地址
int* get_value(void) { int val = 42; return &val; // 严重错误:val在函数返回时销毁 }val是栈上的局部变量,函数返回时栈帧被销毁,但仍保留着残值。返回的指针指向的是“已死”的栈内存,这种环境极其隐蔽——有时候调试模式下它还能“碰巧”工作,因为栈空间没有被立即覆盖,但一换编译器优化等级或者函数调用链,立刻崩给你看。
3.2 野指针排查:从崩溃现场倒推问题
嵌入式产品如果出现偶发死机,复位后发现PC指针跳到非法地址,很多情况都是野指针导致的。排查野指针不能靠肉眼瞪代码,要有系统方法。我自己的经验是分几步走:
- 打开编译器警告:至少打开
-Wall,推荐-Wextra,很多未初始化赋值问题编译器能直接报出来。 - 地址类崩溃先看调用栈:如果崩溃在某个地址写操作上,先用调试器看当前PC指向哪段函数,再看栈回放,找到最后一次对指针赋值的位置。
- 缩小范围用“哨兵法”:在可疑内存区域前后填充特殊值(比如
0xAA、0x55),崩溃后查看内存是否被破坏。如果发现0xAA区域被改写,就能确定是缓冲区越界或野指针写入了。 - 善用调试器的硬件断点和内存访问断点:在嵌入式调试器(比如J-Link、ST-Link的配套工具)里,可以对某块内存设访问断点,一旦有代码读写这块内存就自动停下,直接抓到罪魁祸首。
这个“从崩溃现场倒推”的能力,是区分有经验和没经验工程师的重要分水岭。
3.3 防御野指针的实战习惯
有些候选人会问我实际项目里怎么防止野指针,这其实是个很好的加分区问题。我的习惯是:
- 声明指针时立即初始化:要么赋有效地址,要么置为
NULL,绝对不留未初始化的指针。 - 释放后立即置NULL:
这样下次误用free(p); p = NULL;*p时,至少在大多数平台上会因为访问NULL地址而立刻崩溃,便于快速定位。虽然这不能根治,但比悬空指针好排查一万倍。 - 封装内存分配与释放:在嵌入式团队里,我会建议做一个内存管理封装,释放函数里做二次释放检测。比如:
调用时写成void safe_free(void **ptr) { if (ptr && *ptr) { free(*ptr); *ptr = NULL; } }safe_free((void**)&p);,这样每个释放点都强制置NULL。
这些习惯听着简单,但在实际项目里能拦截住大量隐性bug。面试时能主动说出这些实践细节,比单纯背“野指针是不能用的指针”要加分得多。
4. 从指针到引用:C++面试里的常见变体
嵌入式岗位如果要求C++,引用这个考点也跟着出来了,而且面试官很爱把你绕进“引用和指针的区别”里。
4.1 引用的本质:一个不能改绑定的“别名”
引用在底层实现上就是一个指针,绝大多数编译器里,引用和指针的汇编代码是一样的。但语言层面上,引用比指针做了更多约束:
- 引用必须在定义时初始化,不能先声明再赋值。
- 引用一旦绑定到某个变量,就不能再改绑到其他变量。
- 对引用取地址,得到的是目标变量的地址,而不是引用本身。
int a = 10; int b = 20; int &ref = a; // ref 是 a 的别名 ref = b; // 这个操作相当于 a = b,a变成20,但ref仍然绑定a printf("a = %d\n", a); // 20 printf("b = %d\n", b); // 20很多人会误以为ref = b把引用重新绑定到了b,其实没有。这是面试里最容易出错的点。
4.2 引用做函数参数:解决“按值传递的拷贝开销”
为什么C++里推荐用引用传参?因为C语言里函数传参默认是值传递,传一个大的结构体时需要整体拷贝一遍,性能很低。引用传参在语法上像值传递一样自然,但底层传的是地址,不需要拷贝。
typedef struct { int id; char name[64]; float values[32]; } SensorData; // C风格写法 void process_data_c(SensorData *d) { d->id = 100; // ... } // C++引用风格写法 void process_data_cpp(SensorData &d) { d.id = 100; // ... }引用写法免去了->箭头操作符,代码可读性更好。但要注意,如果函数内部不允许修改这个参数,一律用const引用:
void print_sensor_info(const SensorData &d) { printf("id: %d\n", d.id); // d.id = xxx; // 编译报错,不允许修改 }const T&在C++里还有一个很妙的作用——可以绑定临时对象和字面量,这为后面聊移动语义、完美转发做了铺垫。
4.3 左值引用 vs 右值引用:C++11之后的加分项
提到引用,有经验的面试官可能会追问C++11之后的右值引用,因为嵌入式从业者现在用C++17、C++20做项目的人越来越多了。
简单区分一下:
- 左值引用
T&:只能绑定左值(有名字、能取地址的东西)。 - 右值引用
T&&:可以绑定右值(临时对象、字面量、函数返回的临时值)。
int a = 10; int &ref = a; // 正确,a是左值 int &&rref = a; // 错误,不能把左值绑定到右值引用 int &&rr = 10; // 正确,10是右值右值引用主要用于实现移动语义和完美转发。在嵌入式里最典型的场景就是避免容器的重复拷贝。比如一个std::vector<uint8_t>作为函数返回值,没有移动语义时会拷贝整个缓冲区,有了移动语义,直接“偷走”内部指针,几乎零开销。
面试官如果跟你聊到智能指针,那又是另一块内容了。std::unique_ptr、std::shared_ptr、std::weak_ptr本质上就是“带自动管理生命周期的指针”,很多嵌入式项目现在也大量使用,因为它们能在编译期就帮你干掉大部分内存泄漏和悬空指针问题。
4.4 区分“传引用”和“传指针”的工程决策
虽然引用在C++里是推荐方式,但嵌入式代码里不能一概而论,要看场景。我们项目中有一个不成文的约定:
- 如果参数是一个“必须存在”的对象,优先用引用,省去判空。
- 如果参数“可能为空”,必须用指针,因为引用没有NULL状态,无法表达“不传”的语义。
- 如果参数是普通数据类型(int、float)且不需要修改,直接用值传递,连引用都不用。
- 如果需要在函数里申请资源并返还给调用者,用二级指针或者引用,但务必要约定好谁来释放。
比如,下面的用引用做输出参数的风格在C++里很常见:
void parse_frame(const uint8_t *data, size_t len, size_t &consumed) { consumed = 0; // ... }consumed用引用传出来,调用方不需要手动取地址,代码看起来更清爽。但在C接口里,我们还是习惯用size_t *consumed,因为要兼容C,也方便调用方一眼看出这是输出参数。
5. 考点串联:几道经典综合题带你走一遍完整思路
最后用几道我常年在面试中使用的综合题,把前面的知识点串联起来。这些题目不是要你背答案,而是通过它们建立一个系统的解题思维。
5.1 字符串指针 vs 字符数组
面试官先给代码:
char *s1 = "hello"; char s2[] = "hello";问:这两行有什么区别?
很多人能答出s1指向只读常量和s2是可修改数组,但能继续往深挖的人不多。完整的区分思路是:
s1是一个指针变量,指向字符串字面量“hello”的首字符;这个字符串存储在只读区域(通常是.rodata段),任何通过*s1修改内容的操作都是未定义行为,在嵌入式平台可能触发段错误。s2是一个字符数组,大小是6(5个字符加末尾的\0),存储在栈上,内容可以修改。- 从内存布局上看,
s1本身占4或8字节,s2占6字节;s1指向的字符串可能存储在Flash中,s2的数据在RAM中。
嵌入式背景下要特别注意这一点:如果芯片支持Flash和RAM的读取速度不同,把字符串放在Flash只读区可以节省RAM空间。所以写协议解析时,只读的日志输出字符串、AT指令表完全可以放在Flash里,用const char*来引用。
5.2 对函数指针的考察:回调机制的基石
嵌入式岗很喜欢考函数指针,因为MCU开发里到处是回调——中断处理、定时器回调、协议栈事件回调:
typedef void (*event_handler_t)(int event_id, void *args); void register_handler(event_handler_t handler);面试官往往会现场出题:
int add(int a, int b) { return a + b; } int main(void) { int (*fp)(int, int) = add; printf("%d\n", fp(3, 4)); // 7 printf("%d\n", (*fp)(3, 4)); // 7,写法不同含义相同 return 0; }这里要理解的是:函数名add在表达式中会隐式转换为函数指针,fp指向函数的入口地址。调用时fp(3, 4)和(*fp)(3, 4)等效,因为函数指针调用时会自动解引用。这个在嵌入式里的典型应用是状态机跳转表:
typedef void (*state_action_t)(void *ctx); static void state_idle(void *ctx); static void state_run(void *ctx); static void state_error(void *ctx); static const state_action_t state_table[3] = { [0] = state_idle, [1] = state_run, [2] = state_error };这种表驱动设计在写完状态机后特别清晰,比switch-case可维护性高一个档次,也是面试中展示工程能力的好素材。
5.3 二级指针的应用场景
二级指针int**在面试里经常作为一道“压轴题”出现,因为它确实是很多人的盲区。面试官可能会让你写一个“在链表头部插入节点”的函数,如果你写成:
void list_insert(Node *head, int value) { // ... }面试官就会追问:如果链表为空,head是NULL,你这个函数能在调用后让外部的head变成新节点吗?
显然不行——因为函数参数传的是值拷贝,函数内部修改head不会影响外部变量。正确的做法之一是:
void list_insert(Node **head, int value) { Node *new_node = (Node*)malloc(sizeof(Node)); new_node->value = value; new_node->next = *head; *head = new_node; }调用时这样写:
Node *list = NULL; list_insert(&list, 10);这就是二级指针存在的意义:当我们想在一个函数里修改调用方的指针本身(而不仅仅是指针指向的内容)时,需要传指针的地址。优先级链表的删除操作、树形结构中的父子节点关系维护,都会用到这个技巧。
追加追问:为什么还可以用“返回新的头节点”的写法?对,定义一个
Node* list_insert(Node *head, int value),返回到外部重新赋值也是常见的替代方案。两种风格各有适用场景,能对比说出异同,面试官会对你有额外的好感。
5.4 指针常量和常量指针在寄存器配置场景里的使用
嵌入式里配置外设寄存器时,经常要把某个地址转成指针并映射为寄存器结构体。比如STM32的寄存器定义:
#define GPIOA_BASE 0x40020000U typedef struct { volatile uint32_t MODER; volatile uint32_t OTYPER; volatile uint32_t OSPEEDR; volatile uint32_t PUPDR; volatile uint32_t IDR; volatile uint32_t ODR; } GPIO_TypeDef; #define GPIOA ((GPIO_TypeDef *)GPIOA_BASE)这里GPIOA本质上是一个“指向寄存器的常量指针”,它的指向不能变,但指向的内容可以变。volatile关键字是另一大考点——它告诉编译器这个地址的内容可能在程序之外被改变(比如硬件中断修改),每次访问必须重新从内存读取,不能优化成寄存器缓存值。
面试官如果深入追问“为什么要用volatile”,你可以说:在优化级别较高时,编译器可能把对寄存器的连续读操作合并成一次,或者把多次写操作合并成一次,这在寄存器配置场景里是致命的。比如你配置完一个发送寄存器后立刻读回状态寄存器,如果状态寄存器没有声明为volatile,编译器可能把读回的结果直接用上次的缓存值代替,导致你误判外设状态。
6. 实战心得:你在IDE里看不到的那些指针细节
6.1 嵌入式平台和PC平台的差异
很多人在PC上用Visual Studio练C语言,写指针写得飞起,一上嵌入式就踩坑。最大的差异是内存模型:
- PC上虚拟地址空间很大,某块内存非法访问时通常表现为段错误,程序崩溃得干脆利落,问题好定位。
- 嵌入式MCU只有一个扁平地址空间,没有MMU做页表映射访问保护。你访问一个野指针,它很可能不是崩溃,而是静默地改写了某个外设寄存器的值,导致设备出现无法解释的诡异行为。
我见过最典型的一个问题是:某个野指针把中断向量表附近的RAM给覆盖了,导致设备在特定条件下触发不可屏蔽中断,整机复位。这种问题排查起来极其痛苦,因为它没有明显的代码错误提示,只能靠内存篡改检测和长时间拷机复现。
所以嵌入式下的指针要求更严苛:每一个写操作,都要先想清楚指针指向的是哪块内存、大小是否匹配、生命周期的边界在哪里。
6.2 调试器里看指针的信息量
很多初学者跟我抱怨,说“知识都懂,出了问题还是不会查”。我一般会反问一句:你打开调试器,看指针变量的完整信息了吗?
调试器里一个指针变量包含的信息比你想象的多:
- 指针的值(也就是地址本身),可以对照链接脚本里的内存布局,判断它指向的是Flash、RAM还是外设区。
- 指针指向内存的内容,可以展开看前几个字节,初步判断数据是否符合预期。
- 如果地址明显异常(比如0x08000000在RAM区域、0xFFFFFFF0在保留区),基本可以直接判定是野指针。
- 在Watch窗口里一行加入
*(int*)0x20000010这样的表达式,可以直接观察某块内存的实时变化,定位越界写入非常有用。
6.3 代码审查中我最常强调的指针规则
由于我经常参与团队代码审查,总结了几条指针相关的高频问题,特别是嵌入式新人容易犯的:
| 检查项 | 错误示例 | 正确做法 |
|---|---|---|
| 返回值错误处理 | malloc后不判空直接使用 | 分配后立即检查是否为NULL,否则返回错误码 |
| 数组越界写 | for(i = 0; i <= n; i++) buf[i] = x; | 边界条件写成i < n,尤其要警惕差一错误 |
| 类型不匹配 | 用uint8_t*强转结构体指针 | 强转前确认字节对齐和size大小,必要时用memcpy |
| 生命周期不清 | 一个函数里分配,另一个模块随意释放 | 约定“谁分配谁释放”,或使用智能指针封装 |
| volatile缺失 | 中断和主循环共享标志位时不加volatile | 共享变量加volatile,必要时用原子操作或关中断保护 |
这些规则看着简单,但每一条背后都有真实的事故故事。什么时候你能把这些规则背后“为什么”讲清楚,你的指针功底就真正过关了。
6.4 给准备面试的你最后几点建议
平时练习时,不要只看知识点,要多动手写代码、多单步调试、多看反汇编。指针这种东西,光靠“看懂”是不够的,必须形成直觉化的肌肉记忆。比如一个指针加1,你要能立刻反应出它跳过了多少字节——这在阅读协议解析代码时几乎每时每刻都在用。
面试前可以把常见考点串起来自问自答:指针变量的本质是什么?数组名和指针的区别是什么?野指针的三大来源?引用和指针的区别?二级指针用在什么场景?函数指针怎么定义怎么调用?智能指针解决什么问题?把这些问题的回答练到脱口而出,并且在每个问题上都能举出一个实际开发中的小例子,面试效果会比死记硬背好得多。
如果要再推荐一个深度学习方向,我建议找一份开源嵌入式项目(比如RT-Thread、FreeRTOS的某个内核模块),把里面涉及指针的代码逐行读一遍,遇到不懂的API就去查手册。你会惊讶地发现,那些看起来“高大上”的指针用法,本质上就是这篇文章里讲的几个基本概念的组合运用——数组指针访问任务栈、二级指针维护链表、函数指针实现事件回调、const和volatile修饰硬件寄存器。概念打通之后,项目和面试题的视野都会完全不一样。