乐于分享
好东西不私藏

函数指针笔试题的思考:钓鱼佬的鱼钩哲学,说透指针的本质

函数指针笔试题的思考:钓鱼佬的鱼钩哲学,说透指针的本质

  写在前面:这篇文章是怎么来的  

前阵子帮公司做应届生招聘笔试,我出了一道C语言判断题:
int (*ptr)();则 ptr 是一维数组的名字。( )
正确答案是“错误”---这是一个函数指针的声明,ptr 是一个指向函数的指针变量。但结果让我吃了一惊:很多同学选了“正确”。他们认识 int a[10],认识 int *p,但遇到 int (*ptr)() 这种写法,彻底懵了。更不用说理解“指针竟然可以指向函数”这个事实。
这让我意识到两个问题:
第一,指针认知是“碎片化”的。很多学生只知道指针指向变量,不知道指针也可以指向函数、指向另一个指针、指向结构体。在他们的认知里,指针 ≈ 地址 ≈ 变量的地址。
第二,教材讲指针太抽象了。大段概念加代码,辅以少量示意图。学生读完能应付考试,但脑子里没有“指针到底在内存里长什么样”的画面感。指针是C语言面试中占比最高的考点之一,也是最容易失分的题型之一。
市面上有一本经典著作叫《C和指针》(Pointers on C),厚448页---指针本身就值得用一本几百页的书来讲,可见它确实是C语言的精髓,也是无数初学者的噩梦。
所以我想写一篇文章,用一种更直观、更形象、更贴近生活经验的方式,把指针的底裤扒干净。
这个比喻就是:钓鱼。
不废话了,开始。

  一、先搞清楚:CPU、内存、硬盘,谁是谁? 

在聊指针之前,我们需要先弄明白一个问题:指针到底在“指”什么?
很多学生学指针的时候,脑子里只有“地址”这两个字,但“地址”是什么?在哪里?长什么样?完全没有画面感。
所以,我们先从最基础的硬件概念说起。
核心认知(这是理解指针的起点):
CPU只认地址,不认变量名。你写的int a = 10;,在CPU眼里就是“往地址0x100写一个值10”。[注]:0x100等地址只是举例,下同.
指针的本质,就是让程序员能直接操作“地址”---像 CPU 一样看世界。一句话记住三者的关系:硬盘是仓库(存文件),内存是工作台(存运行时的程序和数据),CPU是工人(从工作台上取东西干活)。

    二、对 CPU 来说,一切皆地址  

这是理解指针最重要的前置认知。
普通变量、数组、函数、硬件寄存器---它们对 CPU 来说没有任何区别,都是“地址+值”。
CPU 不认识变量名 `a`,不认识数组名`arr`,不认识函数名 `func`。编译器把这些名字翻译成地址,CPU 只认数字(地址)。
所以:变量、数组、函数、寄存器---在 CPU 看来都是地址对应的值。指针之所以强大,就是因为它可以直接操作这些地址。
关键认知:
指针可以指向任何有地址的东西---内存中的变量、数组、函数,甚至硬件寄存器。对 CPU 来说,它们都是“地址+值”,没有本质区别。
这也是为什么函数指针存在的根本原因---函数在内存里也有地址,指针自然可以指向它。

 三、把整个地址空间想象成一个大鱼塘 

现在,我们把“地址空间”想象成一个超级大鱼塘---每个地址就是鱼塘里的一个位置。

  四、鱼塘里只有两种居民:鱼和鱼钩 

【鱼】--- 肚子里装的是“内容”
例如:
地址0x100 是🐟鱼,int a = 10 ← 肚子里装的是数据(占 4 字节);       
地址0x2000是🐟鱼,func() { ... } ←肚子里装的是代码(占 N 字节);
地址0x40021000 是🐟鱼,对应GPIO 寄存器← 肚子里装的是寄存器值 = 0x01,表示硬件状态值;
【鱼钩】--- 肚子里装的是“地址”
例如:
地址0x108是鱼钩 p,占 8 字节(64位系统),内部存的是:0x100──►勾向一个地址值!正好是int a变量占用空间的首地址;
⚠️记住:鱼钩也是鱼塘的居民!它自己也占 8 个格子(64位系统)!唯一不同的是:鱼肚子里装的是内容,鱼钩肚子里装的是地址。鱼钩可以勾向任何鱼---不管那条鱼肚子里装的是数据、代码还是寄存器值。这就是“函数指针”的底层原理---函数也是鱼,鱼钩自然可以勾向它

 五、鱼钩的本质---指针变量的定义 

现在来看代码:
int a = 10;//鱼a,住在0x100号水域,肚子里装的是数据10
int *p;//鱼钩p,放在0x108号水域(它自己占8字节![64bit系统])
p = &a;//鱼钩p内部写入:勾向0x100
图解:
执行“p=&a;”之前:鱼a在地址0x100起始的4个字节空间;鱼钩p在地址0x108起始的8字节空间(64位系统),8字节的内容是乱码(未初始化);
执行“p=&a;”之后:鱼钩p内部的8字节的值变成鱼a所在的地址0x100,即勾向了鱼a的地址,顺着这个地址就可以找到鱼a。
⚠️鱼钩 p 和鱼 a 都是鱼塘里的居民,各自占不同水域!

 六、顺着鱼钩的指向游过去(*p --- 指针解引用)

现在鱼钩 p 内部存着“0x100”。你要做的事情就是---顺着这个地址游过去
printf("%d", *p);//顺着“0x100”游过去,看那条鱼的值→输出10
*p = 20;//顺着“0x100”游过去,把那条鱼的值改成20
在C语言里,*p这个操作叫做“解引用”---翻译成钓鱼语言就是“顺着鱼钩存着的地址游过去”。
图解:
⚠️*p(解引用)的含义:拿起鱼钩 p,看它内部存着的地址值(0x100),顺着这个地址游过去,然后看/改那条鱼肚子里的内容

       七、鱼钩的“自重”和“钩号”      

① 鱼钩自重:固定8个格子(64位系统)
无论鱼钩准备勾向什么鱼,鱼钩本身的大小是固定的---里面存的就是一个地址值,地址的长度由系统位数决定:
② 钩号(指针类型):决定“顺着鱼钩游过去能钩住多少”
鱼钩的“钩号”在C语言里就是指针的类型
int *p;// 4号钩:顺着游过去,钩住4个格子(int的大小)
char *p;// 1号钩:顺着游过去,钩住1个格子(char的大小)
double *p;// 8号钩:顺着游过去,钩住8个格子(double的大小)
void (*p)();//函数钩:顺着游过去,钩向代码
int **p;//钩中钩:顺着游过去,钩住另一个鱼钩(8个格子)
图解对比:
问题1:鱼钩自重(本身占几格)?---固定 8格(64bit系统)
问题2:顺着鱼钩游过去能钩住几格?---取决于钩号
如果是 4 号钩(int *):
如果是 1 号钩(char *):
把比喻映射到C语言:
“鱼钩自重”→指针变量本身的大小(64位系统占8字节)
“钩号”→指针类型(int *、char *、double *……)
“顺着游过去能钩住多少”→解引用时能读/写几个字节,由指针类型决定
一句话总结:
鱼钩自重多少?固定 8 字节(存地址的空间)[64bit系统]
顺着游过去能钩住多少?看钩号---int *(4号钩)钩4字节,char *(1号钩)钩1字节,double *(8号钩)钩8字节

八、鱼钩可以勾向鱼塘里的任何一条鱼/或鱼钩 

  鱼钩(指针本身)占用8字节空间(64bit系统),这个存储空间的数值含义就是 指向的地址:

 九、传指针为什么能修改外部?---鱼钩的复制 

我们先看两段代码,对比它们的行为:
// 版本1:传值---传的是鱼肚子里的内容
voidfunc(int a) {  a = 20;//形参a是内部新鱼,改它不影响外部}intmain() {  int A = 10;//外部鱼A,住在0x100,肚子里是10  func(A);   //把A的值(10)复制一份递给func  printf("%d", A);//输出10(A没变)}
// 版本2:传指针---传的是鱼钩内部的地址
voidfunc(int *p)// ←形参p是函数内部新创建的鱼钩 {  *p = 20;//顺着p的指向,把目标鱼改成20}intmain() {  int A = 10;//外部鱼A  int *p = &A;//外部鱼钩p,勾向A  func(p);//把外部鱼钩p的指向(0x100)复制一份递给func  printf("%d", A);//输出20(A变了!)}
⚠️注意:外部 main 里有一个鱼钩 p,func 的形参也叫 p---它们是两个不同的鱼钩,只是碰巧名字相同。形参 p 是函数调用时在栈上新创建的鱼钩,它复制了外部鱼钩 p 的指向地址。下面用图示把这个过程讲清楚。
版本1:传值---复制鱼肚子里的内容
a 和 A 是两条独立的鱼,互不影响!
传值:复制的是“鱼肚子里的内容”。函数内部得到的是外部鱼的克隆鱼---修改克隆鱼,原鱼纹丝不动。
版本2:传指针---复制鱼钩内部的指向地址
注意看:外部鱼钩 p 和内部形参 p是两个不同的鱼钩,各自占不同的水域(0x108 和 0x208),但它们的指向相同---内部都存着 0x100,都指向同一条鱼 A!
所以在 func 内部:
*p = 20;→顺着形参 p 的指向(0x100)游过去,找到鱼 A,把 A 改成 20
func返回后,外部通过自己的鱼钩 p 再顺着 0x100 游过去:
printf("%d", *p);→看到 A 已经变成 20 了
一句话总结:
传值:复制的是“鱼肚子里的内容”,函数内部得到克隆鱼,改克隆鱼不影响原鱼。
传指针:复制的是“鱼钩内部存着的指向地址”,内外两个鱼钩指向同一条鱼,所以通过内部鱼钩修改目标,外部鱼钩能看到变化。

   十、多级指针:鱼钩勾向鱼钩      

int a = 10;//鱼A
int *p = &a;//鱼钩p,勾向A
int **pp = &p;//超级鱼钩pp,勾向鱼钩p
图解:

           十一、三种常见错误               

① 空指针:鱼钩内部写着“0”
int *p = NULL;//内部是0
*p = 10;//❌地址0是禁区,程序崩溃!
💡技术细节:地址 0(NULL)在大多数系统中是存在的,但属于受保护的系统保留区域。对 NULL 指针解引用会触发硬件异常/段错误(Segmentation Fault),操作系统会终止进程。
② 野指针:鱼钩内部存着乱码
int *p;//未初始化,乱码地址
*p = 10;//❌随机游走,程序崩溃!
③ 数组越界:游出了鱼塘范围
int a[3] = {0, 1, 2};
int *p = a;
*(p + 3) = 10;//❌第4个位置,可能是其它的鱼,甚至在鱼塘外面!
     十二、函数指针 有啥用?    

讲完了指针的各种花样,很多同学会问一个问题:“我知道函数指针是什么了,但它有什么用?我平时写代码好像从来没用过。”

这个问题问得好。函数指针不是用来“炫技”的,它在实际工程中有几个非常重要的应用场景。

场景一:回调函数---把“行为”当作参数传递

这是函数指针最经典的用途。

比如你写了一个函数,要对一组数据做某种操作---但具体做什么操作,你想让调用者来决定。这时,你就可以把“操作”设计成一个函数指针参数,让调用者传进来。

// 定义一个“操作”类型:接收一个 int,返回一个 int// 这个钩子,勾向的是“行为”typedef int (*Operation)(int);// 对数组中的每个元素执行操作voidprocessArray(int arr[], int n, Operation op) {    for (int i = 0; i < n; i++) {        arr[i] = op(arr[i]);   // 顺着函数钩子的指向,执行它    }}// 调用者可以传入不同的“行为”intdoubleIt(int x) { return x * 2; }intsquareIt(int x) { return x * x; }intmain() {    int arr[] = {12345};    processArray(arr, 5, doubleIt);   // 每个元素翻倍    processArray(arr, 5, squareIt);   // 每个元素平方}

钓鱼版解释:

processArray 鱼钩的“钩号”是 int (*)(int)---它专门勾向“吃一个 int、吐一个 int”的函数鱼。doubleIt 和 squareIt 都是这种鱼,谁都能被这个钩子勾住。勾住谁,就执行谁的行为。这就是“行为参数化”。

场景二:函数表---用数组管理多个函数

如果你有一组功能相似的函数,可以把它们放进一个数组里,用下标来调用。

// 四个简单的计算函数int add(int a, int b) { return a + b; }int sub(intaintbreturn a - b; }int mul(int a, int b) { return a * b; }int div(int a, int b) { return a / b; }// 函数指针数组——装满了“函数鱼”的鱼篓int (*calc[4])(intint) = {add, submuldiv};int main() {    int op = 2;  // 0:加, 1:减, 2:乘, 3:除    int a = 10, b = 5;    int result = calc[op](a, b);   // 从鱼篓里取出对应函数并执行    // 相当于 calc[2](105) → mul(105) → 50}

这种写法在游戏引擎、状态机、计算器等场景中非常常见---不需要写一大堆 if-else 或 switch-case,代码更干净,扩展也更容易。

钓鱼版解释:

calc 是一个鱼篓,里面装了四条“函数鱼”。钩住第 2 条,就执行乘法;钩住第 0 条,就执行加法。不需要一大堆判断语句,直接按编号取鱼、用鱼。

场景三:排序函数---让用户自定义比较规则

qsort 标准库函数就用到了函数指针---用户传入一个比较函数,告诉 qsort 如何判断两个元素谁大谁小。

// 比较两个 intintcmpInt(constvoid *a, constvoid *b) {   return *(int*)a - *(int*)b;}// 比较两个字符串(按长度)intcmpStringLen(constvoid *a, constvoid *b) {  return strlen(*(char**)a) - strlen(*(char**)b);}intmain() {  int nums[] = {52819};  qsort(nums, 5sizeof(int), cmpInt);  // 按数值排序  char *words[] = {"hello""hi""world""!"};  qsort(words, 4sizeof(char*), cmpStringLen);  // 按字符串长度排序}

同一个 qsort 函数,只要传入不同的比较函数,就能实现不同的排序规则---这就是回调函数的威力。

钓鱼版解释:

qsort 是一个“万能钩”,它不知道自己会勾向什么鱼。你告诉它“按数值比”它就勾向 cmpInt,你告诉它“按长度比”它就勾向 cmpStringLen。同一个函数,勾向不同的“比较规则”,就能做出不同的行为。

场景四:面向对象风格的“虚函数”

在 C++ 中,虚函数是实现多态的核心机制;在 C 语言中,函数指针就是实现“多态”的唯一方式

// 动物:每个动物都有一个“叫声”行为typedef void (*SpeakFunc)();voiddogSpeak() { printf("汪汪!\n"); }voidcatSpeak() { printf("喵喵!\n"); }voidduckSpeak() { printf("嘎嘎!\n"); }// 动物结构体——装着数据 + 行为typedef struct {    char *name;    SpeakFunc speak;} Animal;intmain() {    Animal dog = {"小狗", dogSpeak};    Animal cat = {"小猫", catSpeak};    Animal duck = {"鸭子", duckSpeak};    dog.speak();   // 调用狗的方法    cat.speak();   // 调用猫的方法    duck.speak();  // 调用鸭子的方法    // 同一个 speak(),不同表现!这就是多态}

钓鱼版解释:

每个 Animal 鱼的身上都挂着一个鱼钩,指向它自己的叫声函数。狗的鱼钩勾向“汪汪”函数,猫的鱼钩勾向“喵喵”函数。你用同一个动作---解引用(调用 speak),不同动物就会发出不同的叫声。这就是“一个动作,多种表现”。

总结:函数指针到底解决了什么问题?

一句话概括:

函数指针让“代码”也变成了“数据”。 你可以把函数传来传去、存进数组、放进结构体---就像操作普通变量一样操作代码。这就是 C 语言灵活性的根源,也是它能够承载操作系统、嵌入式系统等大型项目的核心原因。

【注】Linux内核代码使用了大量的函数指针!

                     写在最后                       

很多学生被指针折磨得死去活来,不是因为他们笨,而是因为教材把简单的问题讲复杂了
今天我们用“鱼塘里的鱼钩”重新理解一遍:
  • 地址空间就是一个巨大的鱼塘。所有有地址的东西(变量、数组、函数、寄存器)---都是鱼,肚子里装的是内容;
  • 指针变量就是鱼钩,它也在鱼塘里占空间(64位系统下占8字节),鱼钩内部存着地址值(勾向哪里)---这就是“指针的值”;鱼钩可以勾向任何鱼---变量、数组、函数、寄存器,来者不拒。函数指针的本质就是:函数这条鱼,被鱼钩勾住了;
  • 顺着鱼钩的指向游过去(*p --- 指针解引用)---找到目标,看它或改它;
  • 鱼钩自重固定,但能钩住多少看钩号--- int *(4号钩)钩4格,char *(1号钩)钩1格,void (*)()钩向代码;
最核心的一句话:
指针变量(鱼钩)自己也是一块内存空间,它内部存的是一个地址值(勾向哪里)。顺着这个地址游过去,就能找到目标---目标可能是数据,可能是代码,也可能是硬件寄存器。对 CPU 来说,它们都是“地址+值”,没有本质区别。
回到开篇那道题:
int (*ptr)();--- 这是一个函数指针的声明,ptr 是一个指向函数的指针变量,不是数组名。把函数理解成“鱼塘里的一条鱼”,ptr 是“一个鱼钩”,这个鱼钩可以勾向函数这条鱼。这样想,就永远不会搞混了。
🐟祝你不再被指针折磨!
关于作者:某公司嵌入式底软面试官,根据近期招聘应届生的C语言笔试反馈,总结一些 共性的问题,分享一下。回想自己刚毕业的时候,也是一样搞不清楚,逐步啃《C缺陷与陷阱》、《C专家编程》 等C语言经典书籍,才逐步入门。。。因此,花点时间写个笔记,抛砖引玉,希望让有需要的人少点弯路。不够严谨的地方也欢迎留言指正。