写这篇总结的起因,是有次在代码评审时看到一段字符串处理逻辑,把char *当数组用,越界改了“字符串字面量”区域,程序在release版本下偶发崩溃,查了大半天。回过头来,发现不少开发者,包括有一定工作年限的人,对char *和char[]的区别仍然停留在“一个能改一个不能改”的层面,没有真正吃透两者在内存布局、生命周期、sizeof行为、函数传参等一系列底层差异。这个问题虽然基础,但一旦理解透彻,能避免大量隐蔽的bug。这篇总结就围绕这两个声明的本质区别展开,从内存角度一路拆到函数调用现场,适合正在学C语言的学生、刚入行的嵌入式或后端开发者,也适合带新人的老手用来当培训素材。
1. 先看本质:两者在内存里的位置完全不同
很多教材一上来就讲“数组名是常量指针”,这句话其实害了不少人。数组名确实在很多表达式里会被“退化”成指针,但数组本身和指针在内存中的存在形式是两回事。为了把这件事说清楚,我直接从内存布局层面拆解。
1.1char *到底指向哪里
char *p = "hello";这一行声明里,p是一个指针变量,它本身占据一个指针大小的内存(64位系统下8字节,32位系统下4字节),这个内存在栈上。而p里保存的地址,指向的是字符串字面量"hello"的存储位置 —— 一般来说在编译生成的可执行文件的只读数据段(.rodata),程序加载运行后,这个区域在内存中被映射为只读页面。
也就是说,p就像一张写着门牌号的纸条,门牌号指向的是一间禁止改动的档案室。纸条本身在哪儿?在栈上,你可以随时撕掉这张纸条,换一张写新的地址。但纸条指向的那个“内容”,你没有权限去改。
1.2char[]的空间是实实在在“挖”出来的
char arr[] = "hello";这一行声明,编译器会在栈上分配一块连续的内存,大小是6字节,正好容纳'h','e','l','l','o','\0'六个字符。arr这个名字不是变量,它代表的是这一整块内存区域的起始地址。这块内存就在栈上,属于当前函数栈帧的一部分。
从内存的角度对比,最直观的理解方式是:
char *p:创建了一个指针变量,指向“别处”的只读数据。char arr[]:创建了一块实打实的字符数组,内容就在数组自己的内存里。
我们可以用一个简单例子验证两者的内存差异。在一个函数内部打印相关地址,虽然不同平台下具体数值不同,但模式是固定的:
char *p = "hello"; char arr[] = "hello"; printf("p 变量自身的地址: %p\n", (void *)&p); printf("p 指向的地址: %p\n", (void *)p); printf("arr 数组地址: %p\n", (void *)arr);p变量自身的地址和arr数组的地址都在栈区范围内,而且arr数组的地址和p变量自身的地址往往离得很近(同一个栈帧);但p 指向的地址会在另一个完全不同的区间——只读数据段。这一步就能直观看到,两者的内存来源根本不是一个地方。
正是这个根本性的内存布局差异,衍生出了后面所有的行为差异。所以遇到任何关于char *和char[]的问题,脑子里首先反应的应该是:这块内存到底在哪,谁拥有它,可不可以写。
2. sizeof与strlen:最容易混淆的操作结果
很多人搞不清sizeof在指针和数组上的行为差异,导致代码里出现各种诡异的长度计算错误。这里直接把两组结果摆出来,然后解释为什么会有这种差异。
2.1sizeof的完整计算规则
对于数组,sizeof返回的是整个数组占用的字节数,包含末尾的'\0'。
char arr[] = "hello"; printf("sizeof(arr) = %zu\n", sizeof(arr)); // 6为什么是6?字符串"hello"本身5个字符,加上结尾隐藏的'\0',正好6个字节。
对于指针,sizeof返回的是指针变量本身的大小,与它指向的内容毫无关系。在64位平台下:
char *p = "hello"; printf("sizeof(p) = %zu\n", sizeof(p)); // 8(64位下指针大小)哪怕指针指向的是一个10000字节的字符串,sizeof(p)依然是8。这就是很多网络通信代码中,传输结构体时把指针直接写入缓冲区,导致接收端读出乱码的根源之一——你发送的是8字节的地址值,不是字符串内容。
2.2strlen与sizeof的分工
strlen是用来计算字符串长度的库函数,它从传入的地址开始逐字节扫描,直到遇到'\0'为止,返回的是'\0'之前的字符个数。
char arr[] = "hello"; char *p = "hello"; printf("strlen(arr) = %zu\n", strlen(arr)); // 5 printf("strlen(p) = %zu\n", strlen(p)); // 5两者在这里结果相同。不过需要注意,strlen只接受“指向字符串起始地址的指针”,如果数组没有以'\0'结尾,strlen会一路越界扫描下去,直到偶然遇到内存中的0字节才停下,这时返回的数值就是完全不可预测的。这是C语言里极难排查的经典bug之一。
| 操作对象 | sizeof结果 | 含义 |
|---|---|---|
char arr[] | 整个数组字节数 | 包含'\0' |
char *p | 指针自身大小(4或8) | 与指向内容无关 |
strlen(任意) | 实际字符数 | 不包含'\0' |
实际开发中,我见过不少代码这样写:
char *data = get_data(); int len = sizeof(data); // 实际这里len=8,而正确做法是strlen(data)+1(如果需要包含结尾符)这类错误在面对不同平台时表现还不一样,32位机可能碰巧“对”了,一上64位就彻底乱套。所以记住一条铁律:对指针使用sizeof,得到的永远是指针自身的大小;要想知道字符串内容长度,一律用strlen或者直接传递长度参数。
3. 可修改性:一个合法写入,一个是未定义行为
这是两者区别中最常被提及、但理解最浅的一点。char *指向的字符串字面量能不能修改?标准C明确规定:修改字符串字面量的行为是未定义的。所谓未定义,意味着程序可以崩溃、可以静默损坏数据、可以看起来正常工作但只在某个优化级别下崩溃——任何事情都可能发生。
3.1 为什么char *指向的内容改不得
看这段代码:
char *p = "hello"; p[0] = 'H'; // 试图修改字符串字面量"hello"作为字符串字面量,编译器把它放在只读段。运行时如果尝试写入,在Linux上通常会触发段错误(Segmentation Fault);在Windows上可能触发访问冲突。但不要想当然以为每次都会崩,有些平台或编译器优化下,字符串字面量可能被放到可写区域,此时修改“碰巧成功”,但这些内容可能是多个指针共享的。一旦某处修改了,所有指向同一字面量的指针看到的内容都会变,造成极其隐蔽的交叉影响。
实际上C标准规定未定义行为之后,编译器可以不通知你就做任何优化。比如下面这段在编译器看来完全合法:
char *p = "hello"; if(p[0] == 'H') { ... } // 编译期可能优化为恒假/恒真的判断因为编译器知道字面量内容不会改,于是基于“p[0]永远是'h'”的前提做常量传播优化。如果你违反了规则去修改它,程序的逻辑可能出现不可理喻的结果——明明改了p[0]='H',后面的if却没看到变化。这类问题靠排查数据流很难找到,因为根本不是正常的程序逻辑,而是违反了C语言本身的契约。
3.2char[]的合法修改
char arr[] = "hello"; arr[0] = 'H'; // 合法,arr的内容在栈上,属于自己 printf("%s\n", arr); // 输出 "Hello"数组的内容是当前函数栈帧的一部分,拥有完整的读写权限。修改它没有任何未定义行为。
实际项目里一个常见场景:解析配置项时,从文件读到的字符串需要去掉首尾空格、大小写转换。如果用的是指向文件缓冲区的char *,直接原地改缓冲区可能没问题(因为那通常是可写内存),但如果你得到的是一个const char *或者指向字符串字面量的指针,那就绝对不能原地改,必须先拷贝到数组或用动态分配的内存。我自己就遇到过因为贪图方便直接改字面量,导致内存映射的配置文件内容被破坏的情况,排查时一度以为是驱动问题,最后发现是同行代码里一个“不起眼”的赋值。
一个比较实用的判断方法是:拿到一个字符串指针后,如果无法确认它的内存是否可写,就当成只读来使用。需要拼接或修改时,先拷贝一份。虽然多了一次内存拷贝的开销,但规避的是未定义行为,这笔账怎么算都划算。
4. 初始化与赋值:数组的“一次定型”法则
很多初学者在写完下面的代码后发现编译不过,一脸困惑:
char arr[6]; arr = "hello"; // 编译错误:数组类型不可赋值原因在于C语言里,数组不是一个可赋值的“一级对象”。你可以在初始化时用字符串字面量给数组“灌入”内容,但初始化之后,数组名不能作为左值被整体赋值。这就像一栋房子盖完后,地址不会变,你不能把整栋房子搬到别处去,只能在原址改造。
4.1 初始化的几种合法姿势
char arr1[6] = "hello"; // 正确,完整初始化 char arr2[] = "hello"; // 正确,编译器自动计算长度 char arr3[6] = {'h','e','l','l','o','\0'}; // 正确,逐个字符初始化 char arr4[10] = "hello"; // 正确,剩余位置自动补0值得留意的细节是char arr2[] = "hello"——数组长度由编译器根据初始化内容推断为6,正好容纳字符加结尾符。如果字符串比数组长度长,比如:
char arr[5] = "hello"; // 错误,没有位置放'\0' char arr[5] = "hello!"; // 更严重的错误第二种情况,C标准并不禁止编译(实际很多编译器会给出警告),但后果是字符串没有以'\0'结尾,后续任何调用strlen、printf("%s")的行为都会越界。这是缓冲区溢出漏洞的高发模式。比较稳妥的做法是声明时不管是arr[]还是arr[N],都让N比最大的字符串长度至少多1,给结尾符留位置。
4.2 指针的赋值是“换指向”,不是“拷贝内容”
char *p = "hello"; p = "world"; // 合法,p只是换了指向 p = arr; // 合法,让p指向数组arr的首地址指针的赋值操作不涉及任何字符串内容拷贝,只是把一个新的地址写入指针变量。这一点在函数返回值中特别容易造成误解:函数里定义一个局部数组,返回数组名,调用方拿到指针时,那个数组的内存已经被回收了,指针成了悬垂指针。
运行下面这段代码就会看到典型的悬垂指针问题:
char *danger(void) { char local[] = "temp"; return local; // 返回了已失效的栈地址 }local的生命周期在函数返回那一刻就结束了,但指针值还在调用方手里。编译器通常会给出“返回局部变量地址”的警告,但很多人不重视。等程序运行到指针解引用时,那块栈内存可能已经被其他函数调用改写了,数据面目全非。修复方案一般是两种:用静态数组(注意线程安全),或者用动态内存并约定释放责任。
5. 函数传参:数组退化到底是怎么发生的
在函数传参这件事上,char *和char[]的表达形式经常让人误以为写法不同、行为也不同。实际上,C语言有一个规则:当数组作为函数参数时,它“退化”为指向其首元素的指针。
5.1 三种等价写法
void func1(char *s) {} void func2(char s[]) {} void func3(char s[100]) {}这三种写法在函数内部完全等价,参数s统统是char *类型。即使写了char s[100],那个100也不会被传递,函数内部对s做sizeof得到的依然是指针大小。
这意味着,用“数组写法”声明形参并不会让数组内容随参数复制进函数。函数内部通过s[i]访问到的,始终是调用方的原始内存。对s[i]的修改会直接反映到调用方的数组上——这就是所谓“传引用”行为的本质。C语言没有显式的引用类型,数组参数退化后天然有了“传引用”的语义。
5.2 为什么一定要额外传长度
正因为数组退化成了指针,函数内部无法通过sizeof(s) / sizeof(s[0])推导出数组长度。所以设计函数时,要么传长度参数,要么用'\0'结尾符约定字符串边界。
// 推荐:指针 + 长度 size_t hash_string(const char *s, size_t len); // 或者约定以'\0'结尾 void print_string(const char *s) { while(*s) putchar(*s++); }我见过不少跨语言互操作的代码,在C函数里导入一个“数组定义”的接口,就直接用sizeof(param)计算长度,结果拿到一个指针大小,传参时把本来就有限的缓冲区边界彻底搞乱了。正确做法是把长度单独参数化,或者用返回结构体的方式同时携带指针和长度。实际项目中,我建议宁可接口里多一个参数,也不要依赖“隐式约定”,因为约定在代码迭代中很容易被破坏。
5.3 二级指针与常量限定
在函数参数设计上还有一层微妙之处:const char *——指向常量的指针——表示内容不可通过该指针修改;而char * const——常量指针——表示指针本身不能指向别处。两者含义完全不同:
void read_only(const char *p); // 内容只读,常用 void fixed_ptr(char * const p); // 指针不可变,较少用很多接口设计把不需要修改的字符串参数统一声明为const char *,这是非常正确的做法:既向调用方明确说明函数不会改动内容,也让编译器帮你拦截误写。如果函数内部确实要修改字符串,再接收非const指针。这个习惯能直接减少一类“误改只读数据”的bug。
6. 常见错误现场与面试考点速查
这部分整理几个真实开发中反复出现的错误模式,以及面试时经常用来考核候选人的代码片段。理解透彻了,基本就能应对大部分相关讨论。
6.1 典型错误对照表
| 错误代码示例 | 问题本质 | 修改建议 |
|---|---|---|
char *p="hello"; p[0]='H'; | 修改只读字面量,未定义行为 | 改为char arr[] = "hello" |
char buf[5]; strcpy(buf,"hello"); | 缓冲区容量不足,越界写入 | 将长度扩为至少6 |
char *p; printf("%s", p); | 未初始化指针,读取野地址 | 初始化或动态分配后使用 |
return local_array; | 返回局部栈数组地址,悬垂指针 | 改用动态内存或静态/全局存储 |
char s[100]; sizeof(s)在另一函数中使用 | 形参退化为指针,size结果错误 | 显式传递长度参数 |
char str[] = {'h','i'}; printf("%s", str); | 缺少'\0'结尾,打印越界 | 初始化时补上'\0' |
每一项都在前面的内容中分析过。把这些错误场景理一遍,基本就覆盖了实际代码中最常见的踩坑点。
6.2 一段测试题,检验你的真实理解
我经常拿下面这段让身边的人先预测再实际验证:
char *p = "hello"; char arr[] = "hello"; printf("1. %zu\n", sizeof(p)); // 8(64位) printf("2. %zu\n", sizeof(arr)); // 6 printf("3. %zu\n", strlen(p)); // 5 printf("4. %zu\n", strlen(arr)); // 5 arr[0] = 'H'; // 合法 p[0] = 'H'; // 未定义行为,通常崩溃 printf("5. %s\n", arr); // Hello如果第2行你答成了5,说明对结尾符的空间还没有形成下意识反应;如果第1行答成了5,说明指针和数组的概念还需要再捋一捋。这些细节平时不一定影响开发,但一旦进入嵌入式、通信协议、系统库这类对内存操控要求极高的领域,每一个点都可能决定程序能否安全运行。
日常写代码时,我的习惯是遵循几条直观准则:不确定字符串常量是否只读时,一律拷贝;函数的字符串参数只读就用const char *;sizeof只用来获取数组“定义处”的字节数,不用于指针;局部数组绝不作为返回值传出。这些规则在实践中帮我规避了大量隐性崩溃。希望对读到这里的你也有同样效果。