1. 这个标题到底在问什么
第一次看到C:0x0,0,null,NULL,'0',"0",' '," "这一串字符排在一起,很多人的第一反应是"这不都是零或者空吗"。但只要你写过一段时间的 C 语言,就会知道这八个写法在编译器眼里完全是八种不同的东西,有的占一个字节,有的占四个字节,有的根本不是一个值而是一个指针,有的在语法层面就站不住脚。这个标题之所以值得单独拎出来讲,是因为它几乎覆盖了 C 语言里"零值"和"空值"的全部歧义来源,而这些歧义恰恰是线上事故、内存越界、字符串截断、条件判断失效的高发地带。
我在实际项目里见过太多次类似的场景:某同学写了一个判断if (p == NULL),结果传进来的参数是'0',条件永远不成立;又或者有人用memset(buf, NULL, size)初始化缓冲区,编译能过,运行起来在某些平台上行为诡异;还有人把"0"当成假值来用,结果if ("0")永远为真。这些问题的根子都在于没有把"零"这个概念在不同语境下的表现形式分清楚。
这篇内容适合两类人看。一类是刚学 C 语言、被指针和字符字面量绕晕的新手,你需要一个把概念彻底掰开的对照表;另一类是有一定经验、但偶尔在边界条件上翻车的开发者,你需要的是从编译器行为、内存布局、类型系统三个层面把这件事讲透,以后看到类似的写法能条件反射地判断出问题在哪。下面我会按"概念分类、内存与类型细节、实操验证、常见坑排查"这条线,把这八个写法一个一个拆开讲,每个都给出可复现的验证代码和判断依据。
2. 八个写法的分类逻辑与选型考量
2.1 先按"是不是一个值"做第一层切分
这八个写法表面上都是"零",但它们分属三个完全不同的语法范畴,这是理解一切的前提。
第一类是整型常量:0和0x0。它们都是整数常量表达式,类型是int,值是 0。0x0只是十六进制写法,和0在语义上完全等价,编译器在词法分析阶段就把它们归约成同一个整数值。你写int a = 0x0;和int a = 0;生成的机器码一模一样。
第二类是空指针常量:NULL。注意,NULL不是一个"值",而是一个宏,标准规定它展开为一个空指针常量。在大多数实现里它展开成((void*)0)或者直接就是0,但它的语义角色是"指针类型的空值",和整型 0 不是一回事。
第三类是字符与字符串字面量:'0'、"0"、' '、" "。这四个都是"有实际内容"的常量,只不过内容看起来像零或者空白。'0'的 ASCII 值是 48,"0"是一个包含字符'0'和结束符'\0'的两字节数组,' '的 ASCII 值是 32," "是一个包含空格和结束符的两字节数组。
还有两个特殊成员:null和' '里的那个单引号空格。null在 C 语言里根本不是关键字,标准 C 没有null这个东西,它是 Java、C#、JavaScript 等语言的写法。你在 C 里写null,编译器会把它当成一个未声明的标识符,直接报错。这一点是很多人从其他语言转过来时最容易踩的坑。
2.2 为什么必须区分"值零"和"空指针"
这里要解释一个关键的设计逻辑。C 语言里0既可以表示整数零,又可以被用作空指针常量,这是标准特意留的一个"重载"。标准里写得很清楚:值为 0 的整型常量表达式,或者这样的表达式强制转换成void*,都叫空指针常量。所以int *p = 0;是合法的,int *p = 0x0;也是合法的。
但反过来不成立:'0'不是空指针常量,"0"更不是。如果你写int *p = '0';,编译器会给你一个警告甚至错误,因为'0'是int类型的 48,把一个整数赋给指针需要显式转换,而且这个值指向地址 48,几乎必然是非法地址。
注意:
NULL的具体展开形式依赖实现,有的头文件里是#define NULL ((void*)0),有的是#define NULL 0。这意味着sizeof(NULL)在不同平台上可能是 4 也可能是 8,写代码时不要对NULL做算术运算,也不要假设它的类型。
2.3 字符字面量和字符串字面量的本质差异
'0'和"0"的区别,是新手最容易混淆的地方,值得单独说清楚。
'0'是字符常量,类型是int(在 C 里字符常量的类型就是int,不是char),值是字符0在字符集里的编码值,ASCII 环境下是 48。它占一个int的存储空间,但在参与运算时就是一个整数 48。
"0"是字符串字面量,类型是char[2],内容是{'0', '\0'}。它在表达式里会退化成指向首字符的指针char*,指向一块只读的静态存储区。所以"0"的本质是一个地址,不是一个字符,更不是一个数值零。
同理,' '是空格字符常量,值是 32;" "是包含一个空格加结束符的字符串,长度 2,退化成指针。
这个差异带来的直接后果是:if ('0')判断的是 48 是否为真,结果为真;if ("0")判断的是指针是否为空,字符串字面量的地址永远非空,结果也永远为真。两个都"看起来像零",但判断逻辑完全不同,而且都不会按你期望的"零为假"走。
3. 逐个拆解:内存布局、类型与编译器行为
3.1 整型常量 0 与 0x0 的等价性验证
0和0x0在 C 标准里属于同一类记法:整型常量。区别只在进制表示,0是十进制,0x0是十六进制。词法分析后它们都归约为整数值 0,类型都是int。
我做过一个简单的验证,用同一份代码分别写两种形式,编译后对比目标文件:
#include <stdio.h> int main(void) { int a = 0; int b = 0x0; printf("%d %d\n", a, b); printf("%d\n", a == b); return 0; }输出是0 0和1,说明两者数值相等。用gcc -S看汇编,a和b的初始化指令完全一致,都是把 0 写进栈上的对应位置。所以从工程角度,0和0x0可以视为完全等价,选哪个纯粹是风格问题。我的习惯是:表示"数量为零"用0,表示"位模式全零"或者和硬件寄存器打交道时用0x0,让读代码的人一眼看出意图。
有一个细节值得提:0后面如果跟数字,比如010,在 C 里是八进制,等于十进制的 8。这是另一个经典坑,但和本标题关系不大,这里只是提醒一句,写常量时别在 0 后面随手加数字。
3.2 NULL 的真实身份与平台差异
NULL的定义在<stddef.h>、<stdio.h>、<stdlib.h>等多个头文件里都有,标准只要求它"展开为一个空指针常量",具体形式交给实现。常见的两种:
#define NULL ((void*)0) /* 常见于 64 位平台 */ #define NULL 0 /* 部分实现或 C++ 兼容场景 */这个差异会带来一个实际问题:sizeof(NULL)在第一种情况下是 8(64 位指针大小),在第二种情况下是 4(int 大小)。如果你写memset(&x, NULL, sizeof(x)),本意是把x清零,但memset的第二个参数是int,传NULL会被转换成 0,恰好能工作;可如果哪天有人把NULL换成别的空指针常量写法,行为就可能变化。所以正确做法是memset(&x, 0, sizeof(x)),明确用整型 0。
还有一个更隐蔽的问题:把NULL传给可变参数函数。printf("%p", NULL)在某些平台上会出问题,因为%p期望void*,而NULL如果展开成0(int类型),在 64 位下参数传递的寄存器宽度不匹配。稳妥写法是printf("%p", (void*)NULL)。
提示:判断指针是否为空,统一用
p == NULL或p != NULL,不要用p == 0,虽然合法,但可读性差,而且容易和数值比较混淆。
3.3 字符 '0' 与字符串 "0" 的存储对比
这两个是最容易被"看起来一样"骗到的。我用一段代码把它们的真实面目打出来:
#include <stdio.h> int main(void) { char c = '0'; char *s = "0"; printf("'0' 的值: %d\n", c); printf("'0' 的十六进制: 0x%02X\n", (unsigned char)c); printf("\"0\" 的首字符值: %d\n", s[0]); printf("\"0\" 的长度: %zu\n", sizeof("0")); printf("\"0\" 的第二个字节: %d\n", s[1]); return 0; }输出会告诉你:'0'的值是 48,十六进制0x30;"0"的首字符也是 48,但sizeof("0")是 2,第二个字节是 0,也就是字符串结束符'\0'。
这里的关键认知是:'0'是一个数值 48,"0"是一个指向{'0','\0'}的指针。当你写if (c == '0')时,比较的是数值 48;当你写if (strcmp(s, "0") == 0)时,比较的是字符串内容。两者不能混用。
实际项目里有个高频错误:从配置文件读进来一个字符,判断它是不是数字零,有人写if (ch == "0"),编译器会报指针和整数比较的警告,但如果开了宽松模式能过,运行时比较的是ch的值和字符串地址,永远不相等。正确写法是if (ch == '0')。
3.4 空格字符 ' ' 与空格字符串 " " 的陷阱
' '是空格字符,值 32,0x20。" "是包含一个空格和结束符的字符串,长度 2。
这两个在解析文本时特别容易出事。比如你要跳过一行里的空白字符,写while (*p == " ") p++;,这是错的,因为*p是char," "是char*,比较的是字符值和地址。正确写法是while (*p == ' ') p++;。
反过来,如果你要判断一个字符串是不是"只有一个空格",用strcmp(s, " ") == 0是对的,用s[0] == ' '只能判断首字符,不能保证后面没有内容。这两个场景的选型逻辑是:处理单个字符用字符常量,处理字符串用字符串字面量加字符串函数,边界清晰,不要互相替代。
3.5 null 为什么在 C 里根本不存在
null这个词在 C 语言里没有任何特殊地位。它不是关键字,不是宏,不是标准库里的任何东西。你写int *p = null;,编译器会报'null' undeclared。
这个坑主要来自语言迁移。Java、C#、JavaScript、PHP 里都有null,写惯了之后切到 C,手会不自觉地敲出null。C 里对应的是NULL(全大写宏)。而 C++ 里还有nullptr,那是另一套东西,C 里也没有。
我个人的做法是在项目里统一用NULL,并且在代码规范里明确写一条:禁止使用null、Null、nil等非标准写法,CI 里加一条 grep 检查,防止有人手滑。这个成本极低,但能省掉很多"编译不过"的低级返工。
4. 实操验证:用代码把八个写法全部跑一遍
4.1 完整验证程序与输出解读
光讲理论不够,我写了一个完整的验证程序,把这八个写法放在一起,打印它们的类型特征、大小和比较结果。你可以直接复制到本地跑:
#include <stdio.h> #include <stddef.h> #include <string.h> int main(void) { /* 整型常量 */ printf("0 : 值=%d, sizeof=%zu\n", 0, sizeof(0)); printf("0x0 : 值=%d, sizeof=%zu\n", 0x0, sizeof(0x0)); /* 空指针常量 */ printf("NULL : sizeof=%zu, 是否等于0: %d\n", sizeof(NULL), NULL == 0); /* 字符常量 */ printf("'0' : 值=%d, sizeof=%zu\n", '0', sizeof('0')); printf("' ' : 值=%d, sizeof=%zu\n", ' ', sizeof(' ')); /* 字符串字面量 */ printf("\"0\" : sizeof=%zu, 首字符=%d, 长度=%zu\n", sizeof("0"), "0"[0], strlen("0")); printf("\" \" : sizeof=%zu, 首字符=%d, 长度=%zu\n", sizeof(" "), " "[0], strlen(" ")); /* 真假判断 */ if (0) printf("0 为真\n"); else printf("0 为假\n"); if ('0') printf("'0' 为真\n"); else printf("'0' 为假\n"); if ("0") printf("\"0\" 为真\n"); else printf("\"0\" 为假\n"); if (' ') printf("' ' 为真\n"); else printf("' ' 为假\n"); if (" ") printf("\" \" 为真\n"); else printf("\" \" 为假\n"); return 0; }在常见的 64 位 Linux 环境下,输出大致是:0和0x0的sizeof都是 4,值都是 0;NULL的sizeof是 8,NULL == 0为真;'0'的sizeof是 4(因为字符常量类型是int),值 48;' '值 32;"0"的sizeof是 2,首字符 48,strlen是 1;" "的sizeof是 2,首字符 32,strlen是 1。
真假判断部分会输出:0为假,'0'为真,"0"为真,' '为真," "为真。也就是说,除了整型 0,其余全部为真。这个结果直接解释了为什么"用字符串判断零"永远失效。
4.2 参数选择背后的判断依据
上面这段代码里,我特意用了sizeof和strlen两个不同的函数,这不是随意选的。sizeof是编译期运算符,对字符串字面量返回的是数组总大小,包含结束符;strlen是运行期函数,从头扫描到'\0',返回的是可见字符数。"0"的sizeof是 2、strlen是 1,这个差值就是结束符占的那一个字节。
用sizeof('0')得到 4 而不是 1,也是很多人意外的点。原因是 C 标准规定字符常量的类型是int,不是char。这一点和 C++ 不同,C++ 里sizeof('0')是 1。如果你写跨语言兼容的代码,这个差异要留意。
至于NULL == 0为真,是因为空指针常量的定义允许整型 0 参与比较,编译器会把 0 转换成空指针再比。但这不代表NULL和0可以互换使用,语义上一个是指针空值,一个是整数零,混用会让代码意图模糊。
4.3 把验证结果整理成速查表
跑完程序后,我把关键结论整理成一张表,方便你贴在显示器旁边:
| 写法 | 语法范畴 | 类型 | 典型大小 | 值/内容 | 真假判断 |
|---|---|---|---|---|---|
0 | 整型常量 | int | 4 | 0 | 假 |
0x0 | 整型常量 | int | 4 | 0 | 假 |
NULL | 空指针常量宏 | 指针或int | 4 或 8 | 空指针 | 假 |
'0' | 字符常量 | int | 4 | 48 | 真 |
"0" | 字符串字面量 | char[2] | 2 | {'0','\0'} | 真(地址非空) |
' ' | 字符常量 | int | 4 | 32 | 真 |
" " | 字符串字面量 | char[2] | 2 | {' ','\0'} | 真(地址非空) |
null | 未定义标识符 | 无 | 无 | 编译错误 | 不适用 |
这张表是我这些年排查相关问题时反复用到的参照,尤其是"真假判断"那一列,几乎覆盖了八成以上的误用场景。
5. 常见问题与排查技巧实录
5.1 高频误用场景与对应修法
我在代码评审里见过的问题,按出现频率排个序,基本是下面这几类:
第一类,用字符串字面量判断字符。典型写法if (ch == "0")或if (*p == " ")。编译器通常会给警告,但很多人不看警告。修法是改成字符常量'0'、' '。
第二类,用'0'当空指针。典型写法int *p = '0';,本意可能是想初始化成空。修法是int *p = NULL;。这个错误在从其他语言转过来的人身上特别常见,因为他们脑子里"零就是空"。
第三类,用NULL做数值运算。典型写法int x = NULL + 1;或者memset(buf, NULL, n)。前者在NULL展开为((void*)0)时直接编译错误,后者能过但语义不清。修法是明确用0。
第四类,把null当关键字。典型写法if (p == null)。修法是if (p == NULL),并在规范里禁掉小写写法。
第五类,混淆"0"和'\0'。有人想表示字符串结束符,写成了"0",结果strcmp行为完全不对。结束符是'\0',值 0,不是字符'0'。
5.2 排查思路:从编译器警告入手
我的排查习惯是先把警告级别拉满。gcc -Wall -Wextra -Werror一开,上面第一、二、三类问题基本都会暴露。比如ch == "0"会报comparison between pointer and integer,int *p = '0'会报initialization of 'int *' from 'int' makes pointer from integer without a cast。把这些警告当错误处理,能挡掉大部分低级问题。
对于null这种拼写问题,编译器直接报未声明标识符,反而最好发现。真正难查的是那些"能编译、能运行、结果不对"的情况,比如if ("0")永远为真,这种只能靠代码评审和单元测试覆盖。
我一般会在单元测试里专门加一组边界用例,把0、'0'、"0"、NULL分别传进被测函数,断言行为符合预期。这组用例写一次,以后每次改动都能跑,性价比很高。
5.3 一张速查表搞定日常判断
遇到不确定的写法,我按下面这个顺序问自己三个问题:
- 这个位置期望的是数值、指针还是字符/字符串?
- 如果是数值,用
0;如果是指针,用NULL;如果是单个字符,用'x';如果是字符串,用"x"。 - 这个值会不会参与真假判断?如果会,确认它是不是真的想要"假"——只有整型 0 和空指针是假,字符和字符串永远为真。
把这三个问题走一遍,基本不会选错。下面这张表是我总结的"意图到写法"映射:
| 你的意图 | 正确写法 | 错误写法示例 |
|---|---|---|
| 整数零 | 0或0x0 | '0'、"0" |
| 空指针 | NULL | 0(可编译但不推荐)、'0'、null |
| 字符零 | '0' | 0、"0" |
| 字符串"0" | "0" | '0' |
| 空格字符 | ' ' | " "、0 |
| 空格字符串 | " " | ' ' |
| 字符串结束符 | '\0' | "0"、0(在字符上下文) |
5.4 几个容易被忽略的边界细节
还有几个细节,文档里不常写,但实际会咬人。
一是'\0'和'0'的区别。'\0'是转义序列,值是 0,是字符串结束符;'0'是字符零,值是 48。这两个在调试器里看可能都显示成"0",但数值差 48,用错会导致字符串处理逻辑整体偏移。
二是" "和""的区别。" "长度 1,包含一个空格;""长度 0,只有一个结束符。判断"空字符串"要用strlen(s) == 0或s[0] == '\0',不要用s == " "。
三是NULL在 C 和 C++ 里的类型差异。C 里NULL常展开为((void*)0),C++ 里因为void*不能隐式转成其他指针类型,NULL通常展开为0。如果你的代码要两边兼容,判断空指针统一用NULL,不要依赖它的具体类型。
四是0x0在指针上下文里的可读性问题。int *p = 0x0;合法,但读代码的人要愣一下才能反应过来这是空指针。工程上统一用NULL,让意图一目了然。
6. 我个人的几条实操建议
写到这里,把这件事落到日常编码上,我自己的习惯是三条。第一,空指针只用NULL,整数零只用0,字符只用'x',字符串只用"x",四类场景四套写法,绝不交叉。第二,编译警告全开并当错误处理,-Wall -Wextra -Werror是底线,能挡掉绝大多数混淆写法。第三,单元测试里固定放一组零值边界用例,把0、'0'、"0"、NULL都覆盖到,改动代码时这组用例就是安全网。
这套做法看起来笨,但我在多个项目里验证下来,因为"零值混淆"导致的线上问题基本归零。真正麻烦的从来不是这些写法本身有多难,而是它们长得太像,人在赶进度的时候容易凭直觉写,写完还不自查。把规则定死、把检查自动化,比事后排查划算得多。