char*与char[]深度解析:从内存布局到函数传参的C语言核心差异
写这篇总结的起因是有次在代码评审时看到一段字符串处理逻辑把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)); // 864位下指针大小哪怕指针指向的是一个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[]整个数组字节数包含\0char *p指针自身大小4或8与指向内容无关strlen(任意)实际字符数不包含\0实际开发中我见过不少代码这样写char *data get_data(); int len sizeof(data); // 实际这里len8而正确做法是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 *phello; p[0]H;修改只读字面量未定义行为改为char arr[] hellochar buf[5]; strcpy(buf,hello);缓冲区容量不足越界写入将长度扩为至少6char *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)); // 864位 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只用来获取数组“定义处”的字节数不用于指针局部数组绝不作为返回值传出。这些规则在实践中帮我规避了大量隐性崩溃。希望对读到这里的你也有同样效果。