在 C 语言中,NULL 并不是一个内置关键字,而是由标准库头文件提供的宏。只要包含 <stddef.h>、<stdio.h>、<stdlib.h> 或 <string.h> 等头文件中的任意一个,就可以直接使用 NULL 表示空指针。许多教程把 NULL 简单解释为“值为 0 的指针”,这个说法并不准确。实际上,NULL 究竟展开成什么,由编译器和目标平台决定,标准只要求它是一个空指针常量。

NULL 的标准定义与常见展开形式
C 标准并没有规定 NULL 必须展开成唯一的表达式。在 C99 和 C11 中,标准把 NULL 描述为“实现定义的空指针常量”。空指针常量可以是整型常量表达式 0,也可以是 ((void*)0) 这样的表达式。也就是说,下面两种定义都符合标准:
#define NULL 0 #define NULL ((void*)0)
第一种写法把 NULL 定义成整型 0,第二种写法把 0 转换成 void* 类型。两者在普通指针赋值中效果基本一致,但在类型检查、可变参数和 C++ 环境中的表现差别很大。常见的 GCC 和 Clang 在 C 模式下通常使用 ((void*)0),因为 C 语言允许 void* 隐式转换成任意对象指针类型,赋给 int*、char* 都不会报警告。
查看标准头文件可以发现,很多 C 运行时库会结合 C 与 C++ 的差异来定义 NULL。例如 MinGW 或 glibc 的头文件可能使用类似下面的条件编译:
#ifndef NULL #ifdef __cplusplus #define NULL 0 #else #define NULL ((void*)0) #endif #endif
这段逻辑的含义是:在 C++ 编译环境中使用 0,在 C 编译环境中使用 ((void*)0)。原因是 C++ 不允许 void* 隐式转换为其他对象指针类型,因此 C++ 中采用 ((void*)0) 会给 int* p = NULL; 带来编译错误,必须退回到整型 0 或使用 nullptr。C 语言则没有这个限制。
NULL、0 与 ((void*)0) 的核心差异
从源码层面看,NULL 只是一个宏,它在预处理阶段被替换成对应表达式。0 是整型常量,((void*)0) 是空指针类型的指针常量。理解二者差异的关键在于:指针和整型在 C 语言中虽然是不同的类型,但空指针常量有特殊的类型转换规则。
C 标准规定,整型常量表达式 0 可以被赋值给任意指针类型,它会被自动转换成该类型的空指针。空指针的值不一定是全 0 位模式,尽管几乎所有现代平台上空指针的底层表示确实都是 0 地址。标准没有要求空指针的机器码必须为全零,只要求在源码中使用 0 或 NULL 能产生空指针。因此,假设 NULL 就是地址 0x0 只能说是常见实现,不属于语言保证。
类型上的区别在编译警告中表现得很明显。如果定义一个接受指针参数的函数,却传入整型 0,编译器通常会允许;但如果传入一个非零整型变量,例如 int n = 0; int *p = n;,在 C 语言中会得到类型不兼容警告,因为变量 n 不是常量表达式。这里体现了空指针常量的“常量”属性:只有 0 这个字面量有特殊待遇,变量没有。
int n = 0; int *p = n; /* 警告或错误:int 不能直接赋给 int* */ int *q = 0; /* 合法:0 是空指针常量 */
另外,((void*)0) 的优势在于它明确带有指针类型。在简单的赋值语句中,二者编译结果通常相同,但一旦涉及类型推断、宏拼接或需要明确是指针的场景,((void*)0) 能提供更强的类型信息。例如嵌入式开发中一些静态分析工具会利用类型检测空指针使用,这个差异会直接影响诊断结果。
可变参数与函数指针场景中的 NULL 陷阱
如果 NULL 被定义成 0,那么在可变参数函数中传递 NULL 会带来未定义行为。printf 家族、open 等系统调用或自定义的可变参数接口,都不会像普通函数参数那样自动把整型 0 转换为指针。下面这个例子非常经典:
#include <stdio.h>
int main(void) {
printf("%p", NULL); /* 若 NULL 是 0,可能传入 int 而非指针 */
return 0;
}在 NULL 展开为 ((void*)0) 的实现中,printf 会根据 %p 从栈上读取一个 void* 类型的参数,行为符合预期。但如果 NULL 展开为 0,实际压栈的就是一个 int。在 32 位平台上 int 和指针大小相同,可能碰巧正常;在 64 位平台上 int 通常仍是 32 位,而指针是 64 位,printf 读到的参数宽度不对,轻则打印错误,重则触发未定义行为。解决方式是在不确定实现时手动写 (void*)NULL 或直接使用 (void*)0。
函数指针也有类似的坑。标准并未保证函数指针和对象指针具有相同的表示形式,把 NULL 赋给函数指针虽然是常见的空指针写法,但如果底层定义把函数指针空间大小与对象指针不一样,严格来说直接转换存在可移植性风险。C 标准允许实现中数据指针与函数指针大小不同。因此,涉及函数指针比较空值时,最好显式写 fp == NULL,并确认平台支持这种比较;更稳妥的做法是使用一个明确的空函数指针常量,例如 ((void(*)(void))0)。不过在实际开发中,POSIX 平台基本都允许对象指针和函数指针相互转换,这类问题主要在小型嵌入式或特殊架构上需要关注。
C23 中的 nullptr 与现代替代写法
C23 标准引入了 nullptr 关键字和 nullptr_t 类型,这让 C 语言在空指针表达上向 C++ 看齐。nullptr 是真正的空指针常量,不再依赖整型 0 或 void* 转换,类型明确,可以避免 NULL 作为 0 在可变参数场景中的坑。使用 nullptr 时,不需要再关心当前编译器的 NULL 展开形式。
不过 C23 仍然保留 NULL 宏定义,旧代码可以继续编译。新项目如果确定使用 C23 标准,推荐优先使用 nullptr,它比 NULL 的语义更清晰。举个例子:
#include <stddef.h>
int main(void) {
int *p = nullptr;
if (p == nullptr) {
/* C23 环境下有效 */
}
return 0;
}但要注意,nullptr 在 C23 之前的编译器中并不存在。如果你的代码需要兼容 C99、C11,或者需要同时被 C 和 C++ 编译器编译,仍应使用 NULL,并时刻记住它可能是 0。兼容性处理时,可以自定义 MY_NULL,但通常不建议重新定义标准宏。若必须支持旧编译器,可以使用 ((void*)0) 作为内部空指针表示。
综合来看,理解 NULL 的宏定义,不能只背结论,而是要把“宏展开、空指针常量、空指针值、类型系统”这几个概念放在一起理解。遇到 NULL 相关错误时,先判断当前环境把 NULL 展开成了什么,再看参数类型和参数传递方式,大多数问题都能定位清楚。