开篇:两道让很多同学懵圈的笔试题
先看两道真实出现在某C语言笔试中的题目:
第1题:void * 解引用
int a = 10;void *p = &a;//p指向变量a
以下哪种写法可以正确地获取变量 a 的值(即 10)?
A. printf("%d", *p);
B. printf("%d", *((int*)p));
C. printf("%d", (int)*p);
D. printf("%d", (int)p);
第2题:void ** 作为函数参数
voidAllocMemory(void **pp, int size){*pp = malloc(size);//这里假设 nalloc()成功,不考虑分配失败场景}intmain(){void *ptr = NULL;AllocMemory(???, 100);//???处应该填什么?//函数返回后,ptr 指向什么?return 0;}
以下选项描述正确的是?
A. 应填(void**)ptr,返回后 ptr 指向 NULL;
B. 应填&ptr,返回后 ptr 指向 NULL;
C. 应填(void**)ptr,返回后 ptr 指向堆上的 100 字节内存;
D. 应填&ptr,返回后 ptr 指向堆上的 100 字节内存;
第1题正确答案:B
- A 错:void * 不能直接解引用,编译报错;
- B 正确:先把 p强制转换成 int *,再解引用;
- C 错:*p 本身就已经非法了,转成int 也没用;
- D 错:把地址值当整数打印,打印的是地址值本身(如0x7ffee...),而不是a 的值10;
第2题正确答案:D
- A 错:传 (void**)ptr 等于传了NULL,因为ptr 本身就是NULL;
- B 错:虽然传的是&ptr(正确),但*pp = malloc(100) 已经修改了ptr,它指向堆内存;
- C 错:传(void**)ptr 是错误的传参方式;
- D 正确:&ptr是 void **类型,传入后函数通过 *pp = malloc(100)修改了外部的 ptr,使其指向堆上的 100 字节内存;
两道题,一道考 void * 的解引用规则,一道考 void ** 作为输出参数的用法。
为什么这么多同学做错?
因为他们只知道“void * 是万能指针”,但不知道这个“万能”到底是什么意思,更不知道 void * 和 void ** 到底有什么区别。在他们的认知里,void * 就像一个“黑盒”---拿来就能用,但用起来全是坑。
要真正理解这两道题,得从 void * 的身世说起。
一、出身:void 的诞生
要理解 void * 的身世,得先认识它的“父亲”---void 关键字。
1.1 void 诞生之前:一个“默认 int”的混乱时代
在 void 关键字出现之前(K&R C 时代,约 1978 年),C语言有一个今天看来不可思议的规则:“隐式 int”(implicit int)。
这意味着,如果你定义一个函数时没有写返回类型,编译器会默认认为它返回 int。
do_something() {//没有写返回类型!printf("Not returning a value\n");}
这段代码在今天会编译报错,但在当时是合法的---编译器会认为 do_something() 返回一个 int。
这种设计带来了几个严重的问题:
第一,意图模糊。你看到 do_something() 时,根本无法判断它是“真的返回一个 int”还是“只是为了表示无返回值而省略了返回类型”。代码的意图完全靠猜。
第二,类型不安全。信号处理函数这种无法返回值的函数,在当时也只能被定义为返回 int。调用者拿到的是一个无意义的“垃圾”整数值,但编译器不会给出任何警告。
第三,编译器检查失效。由于 char * 被当成了“万能钥匙”,编译器无法有效区分“我真的想操作字符”和“我只是在传递一个通用地址”。
1.2 void 的诞生:1979年
1979年,void 关键字被引入C语言。
根据 TUHS(Unix Heritage Society)邮件列表的记载,这场变革的核心推动者是Steve Bourne(著名的 Bourne Shell 的作者)。
当时,Steve Bourne 正在为Algol68 的实现工作,Algol68 中有一个表示“无类型”的关键字,他非常需要一个类似的功能来优化代码生成。他因此向C语言的设计者Dennis Ritchie提议增加 void 类型,但最初被拒绝了。
Bourne 没有放弃。他向Ritchie 证明,增加 void 类型可以让不返回值的函数在返回时节省一条CPU指令---这是一个实实在在的性能提升。
这个能提升效率的“杀手锏”最终说服了 Ritchie。于是,void 关键字被正式加入了C语言,最初的用途是声明“不返回任何值的函数”。
//明确表示:这个函数不返回值void do_something() {printf("Not returning a value\n");}
但在当时,void 关键字只能用于修饰函数返回值,不能声明任何变量。因为编译器根本不知道一个 void 类型的变量应该占几个字节。所以当时有人调侃说:“void存在的唯一意义,就是告诉你‘这里啥也没有’。”
至于 void 能否用来声明“指向未知类型的指针”---那是十年后ANSI C 标准才解决的问题。我们后面会讲到。
void引入的核心价值:

二、前身:当 char * 被迫“身兼数职”
在 void * 出生之前,C 语言里并没有一个专门的“万能指针”类型。那个时代的程序员,用 char * 来充当通用指针。
这就好比你让一个原本只负责“装字符”的盒子,去装 int、装结构体、装一切东西。
后果是什么?
第一,意图模糊。当你看到一个 char * 类型的变量时,你根本不知道它到底是“指向一个字符串”,还是“指向一块任意类型的内存”。代码的可读性大打折扣。
第二,类型不安全。因为 char * 被当成了“万能钥匙”,编译器无法区分“我真的想操作字符”和“我只是在传递一个通用地址”。你没法写一个“只接收字符指针”的函数,因为 char * 已经被泛型用途给“污染”了。
第三,malloc 的尴尬。最典型的例子就是 malloc。在那个年代,malloc 的返回值是 char *。这就意味着,每当你分配一块内存:
int *p=(int*)malloc(100*sizeof(int)); //必须强转!如果不强转,编译器就会报错。
更要命的是,如果你把 int *p 改成 double *p,所有相关的强制转换都得跟着改---改漏一个,就是一个隐蔽的 bug。
三、诞生:被“逼”出来的万能指针
1970年代末,贝尔实验室。
一场技术交流会的酒会上,两位 C 语言的核心开发者---Steve Johnson 和 Charlie Roberts---正靠在吧台边,一边喝酒一边聊着一个让他们头疼了很久的问题:
有没有一种方式,可以让指针只负责“传递地址”,而不携带类型信息?
那个年代,char * 被用来充当“万能指针”,但正如前面所述,这带来了严重的意图模糊和类型安全问题。程序员需要一个专门的类型来表示“只是一个地址,不关心它指向什么类型”。
就在这时,Charlie Roberts 突然露出了一个笑容。他提出了一个想法,让 Steve Johnson 后来形容为“一个近乎字面意义上的‘灵光一现’(being struck by an idea)”。那个想法是:
“嘿!我们为什么不引入一种通用的void *类型呢?它只用来传递地址,不携带任何类型信息。”
于是,void *的想法诞生了。
这个想法后来被 ANSI C 委员会采纳,在 1989 年的 C89 标准中被正式标准化。malloc 也随之改为返回 void *,无需再手动强制转换。
ANSI C 委员会在设计 void * 时,有两个核心目标:
目标一:区分“泛型指针”和“字符指针”。
以前你分不清一个 char * 到底是“指向字符”还是“指向任意类型”。有了 void *,这个问题一目了然。
目标二:让编译器帮你检查错误。
void *不能被直接解引用,也不能做指针运算。如果你试图对 void * 做 *p 或 p++,编译器会直接报错。
这一点在当时是革命性的---它让编译器从“被动的翻译工具”变成了“主动的安全检查员”。
四、成长:三大关键设计决策
void *的成长过程中,有几个关键设计决策决定了它今天的模样。
决策一:和 char * “长得一样”
ANSI C 标准规定:char * 和 void * 必须具有完全相同的内存表示(identical representations)。
为什么?
在 void * 出现之前,char * 已经是事实上的“万能指针”---malloc 返回 char *,所有泛型操作都用 char *。
如果 ANSI 突然规定 void * 和 char * 长得不一样,那所有依赖 char * 做泛型指针的老代码都会出问题。所以委员会做了一个务实的决定:
“既然 void * 和 char * 干的是一样的活儿,那就让它们长一样的尺寸。”
这个决策的结果是:所有指针变量,在 64 位系统下都占 8 字节,在 32 位系统下占 4 字节。
int *p;// 64位下占 8 字节
char *q;// 64位下占 8 字节
double *r;// 64位下占 8 字节
void *s;// 64位下占 8 字节
void **t;// 64位下占 8 字节
不管是一级指针还是二级指针,不管指向什么类型,指针变量本身的大小是固定的---它存的就是一个地址值。
决策二:禁止 void * 算术运算
委员会做了一个非常重要的决定:禁止对 void *做指针算术运算。
理由是:void * 指向的是“未知类型的对象”,编译器不知道它指向的对象有多大。如果你写 p + 1,编译器根本不知道应该偏移几个字节。
如果真的需要做字节级别的偏移,你应该先把 void * 强转成 char *。
决策三:void * 可以隐式转换
在 C 语言中,void * 可以隐式转换成任何类型的对象指针。这意味着:
int *p = malloc(100);// ✅不需要强转!
这在 C++ 中是不允许的---C++ 要求必须显式强转。这也是为什么很多 C++ 程序员会习惯性地对 malloc 做强制转换,而在 C 语言中这其实是多余的。
五、成年:void ** 的“真身”
说完了 void *,我们再来看 void **。
void **是什么?
简单说:它是一个指向 void *的指针。
它不是什么神秘的东西---它就是一个二级指针,占 8 字节(64 位系统),里面存的是一个 void * 变量的地址。
void **为什么存在?
因为 C 语言是值传递。如果你想在一个函数内部修改外部的 void * 变量,你必须传入这个变量的地址---也就是 void **。
这是 void ** 最核心、最常用的场景。
void **在工程中怎么用?
最典型的例子是“不透明句柄”(opaque handle)模式:
// 调用者只需要一个void* 来保存句柄,不需要知道句柄内部是什么intOpenDevice(void **ppHandle, int id) {struct DeviceHandle *h = malloc(sizeof(struct DeviceHandle));if (!h) return -1;*ppHandle = h; // 把句柄“带出去”return 0;}intmain() {void *handle = NULL;OpenDevice(&handle, 123); //传入 handle 的地址// handle 现在指向了有效的设备句柄}
调用者只知道“我有一个 void * 句柄”,完全不需要知道它指向的到底是什么结构体。这正是 void ** 的精髓---它让函数可以“输出”一个不透明指针,同时隐藏了所有实现细节。
一句话总结void **:
void *是“盲盒”,void **是“盲盒的地址”---用来让别人帮你往盲盒里塞东西。
六、用途:void * 到底解决了什么问题?
用途①:泛型编程
如果没有 void *,每个“通用”函数都需要为每种类型单独写一份:
// 没有 void * 的噩梦:voidswap_int(int *a, int *b) {int t = *a;*a = *b;*b = t;}voidswap_float(float *a, float *b) {float t = *a;*a = *b;*b = t;}// ... 每种类型都要写一遍!
有了 void *,一个函数通吃所有类型:
voidswap(void *a, void *b, size_t size){char tmp[size];memcpy(tmp, a, size);memcpy(a, b, size);memcpy(b, tmp, size);}// 调用:不管什么类型,一个函数搞定int x=1, y=2;swap(&x, &y, sizeof(int));double d1=1.5, d2=2.5;swap(&d1, &d2, sizeof(double));
用途②:封装与信息隐藏
这是 void * 最被低估的价值:
// 头文件(对外公开)—— 调用者只看到 void*typedef void *DeviceHandle;DeviceHandle open_device(int id);intread_data(DeviceHandle handle, char *buf, int len);voidclose_device(DeviceHandle handle);// 实现文件(不对外公开)——真正的结构体藏在 .c 文件里struct DeviceHandleImpl {int fd;char name[64];// ... 各种内部状态};
调用者只需要“保存”这个句柄,不需要知道它内部长什么样。用 void * 强制实现了“信息隐藏”。
用途③:与 malloc / free 体系协同工作
malloc返回 void *,free 接收 void *。这是 C 语言内存管理的基础设施。如果 malloc 返回 int *,那分配 char 数组或结构体时怎么办?void * 保证了内存分配函数“只负责给地址,不关心怎么用”。
七、回归笔试:用“前世今生”秒懂两道题
现在,我们把整个故事串起来,回到开篇的两道笔试题。
第1题:为什么 void * 不能直接解引用?
记住一个原则:void *里面存的是一个地址值,但编译器不知道这个地址指向什么类型。
int a = 10;void *p = &a;

【注】变量p是按64bit的8字节举例;0x100/0x200地址 只是举例说明;下同。
A 错: *p 直接解引用 → 编译器不知道读几个字节 → 编译报错;
B 正确: (int*)p 告诉编译器“这是 int 的地址”→ * 读 4 字节 → 得到 10;
C 错: *p 本身非法;
D 错: (int)p 是取 p 里存的地址值(0x100)当整数打印,不是 10;
第2题:为什么必须传&ptr?
记住一个原则:C 语言只有值传递,形参是实参的拷贝。
voidAllocMemory(void **pp, int size){*pp = malloc(size);}
图解:
调用 AllocMemory(&ptr, 100) 时:

执行 *pp = malloc(100) 之后:

如果传ptr:
AllocMemory(ptr, 100);//传入的是NULL形参 pp 得到的是 NULL 的拷贝,修改 pp 不影响外部的 ptr
所以正确答案是 D:应填&ptr,返回后 ptr 指向堆上的 100 字节内存。
八、家谱:一张图看懂 void * 家族

写在最后:void * 的“人生哲学”
回顾 void * 的整个成长历程,你会发现它身上有一种独特的“哲学”:
它知道自己“不知道”。
void *最聪明的地方,就是它知道自己不知道自己指向的是什么类型。它不假装自己懂,不试图做它不该做的事(解引用、算术运算)。它只做一件事---老老实实地当一个“地址的搬运工”。
正因为它“知道自己不知道”,它才成为了 C 语言里最通用、最灵活的类型。
而 void ** 则更进一步---它连“盲盒”都不直接给你,而是给你“盲盒的地址”,让你可以在函数里往盲盒里塞东西。
一句话记住它们:
void *是“盲盒”---什么都能装,但不能直接拆。void **是“盲盒的地址”---用来让别人帮你往盲盒里塞东西。所有指针的本质都一样:8 字节空间(64bit系统),存一个地址值。
回到开篇两道题:
第1题选 B---先强转再解引用。
第2题选 D---填 &ptr,返回后 ptr 指向堆上的 100 字节内存。
这两道题背后的道理,就是上面整个故事讲的所有内容。理解了这个故事,你永远不会再被 void * 和 void ** 搞晕。
关于作者:某公司嵌入式底软面试官,根据近期招聘应届生的C语言笔试反馈,总结一些 共性的问题,分享一下。回想自己刚毕业的时候,也是一样搞不清楚,逐步啃《C缺陷与陷阱》、《C专家编程》 等C语言经典书籍,才逐步入门。。。因此,花点时间写个笔记,抛砖引玉,希望有需要的人少点弯路。不够严谨的地方也欢迎留言指正。
夜雨聆风