搞懂char *和char []其实是在搞懂 C 语言的内存模型。这个标题我太熟悉了从新手期被段错误虐到面试时被问sizeof区别再到后来 code review 时盯着同事的字符串代码发呆我和这两兄弟打了太久交道。这篇文章不搞“教科书复读”我想换个角度带你直接从内存布局、符号语义、真实运行时的表现和函数传参这四个维度把它们彻底拆干净。每一节都会配上可以直接跑的代码和踩坑实录。1. 内存布局层面的根本差异一个指向一堆字节一个就是一堆字节很多初学者把char *p hello和char s[] hello当成可互相替换的写法大错特错。它们的第一个分水岭不发生在语法层而是发生在内存布局层。1.1 字符串字面量隐藏在代码段里的“只读公民”先看一个现象。在主流平台上运行这一段char *p hello; p[0] H; // 段错误还是正常绝大多数 Linux 桌面上这一行p[0] H会当场崩溃做越界写时尤其明显。原因在于字符串字面量hello会存放进进程的 .rodata 段也就是只读数据段。当你写char *p hello时你拿到的不是“hello”这颗字符串本身而是这颗字符串在进程地址空间中的起始地址。编译器通常会把这段数据放到只读页里任何尝试写入的动作都会触发内存保护异常。而char s[] hello则不同。它会在栈上或者是全局/静态区取决于声明位置分配一整段连续字节每个字节直接存放字符本身包括结尾的字符串终止符\0。编译器会把只读段里的hello内容拷贝一份到这块可读可写的内存里从此s就是那块内存的唯一主人你想怎么改就怎么改。1.2 指针变量 vs 数组对象读地址卡和读信封的双重隐喻一个人容易搞混是因为在大多数表达式里p和s的“长相”太像了——都能当字符串用都能传给printf、strlen。但它们骨子里的语义完全不同char *p定义了一个指针变量这个变量本身是一个独立的存储实体里面保存着一个地址值。你可以理解为“一张便利贴便利贴上写着一个门牌号”。char s[6]定义了一个数组对象它不是一张存地址的便利贴而是“一排柜子本身”每一个格子就是数组的一个元素格子里存的就是字符。所以严格说char s[6]这个对象里并不存在一个“保存数组首地址”的专门存储单元。数组名s之所以能在表达式里被当成地址用是编译器按 C 语言标准进行的隐式转换这个转换叫做数组退化array decay。它不是真实存在于内存里的指针变量而是一种编译期的“语法糖”。用一张表概括它们在内存中的“身份差异”特征char *p hellochar s[] hello变量本体一个指针变量占 8 字节64 位系统一个数组占 6 字节含\0实际字符数据位置只读数据段栈 / 数据段可写谁真正拥有字符串内容编译器字面量数组对象本身能否修改字符串内容行为未定义通常崩溃正常1.3[]里写不写大小编译器如何自动推算有人会问char s[] hello我没写大小啊编译器怎么知道要开多少空间这是 C 语言语法的一个经典细节由初始化器决定数组长度。C 标准规定若数组定义时省略长度但有初始化列表/字符串字面量初始化则数组的实际长度为初始化器元素个数。对于hello字符串本身的字符个数是 5但 C 规定字符串字面量末尾自带一个\0所以编译器实际分配的数组大小是 6char s[] hello; printf(%zu\n, sizeof(s)); // 输出 6不是 5这是初学者最容易翻车的一个点。很多人以为sizeof(s)是 5结果拿它去算缓冲区长度给数组塞第 6 个字节直接越界溢出。如果你显式写成char s[5] helloC 会直接报编译警告或错误因为放不下\0。而char s[5] {h,e,l,l,o}这种写法合法但不含终止符后续用字符串函数处理就会越界读取。2. 用代码说话不同声明方式在真实运行中暴露出的坑理论说得天花乱坠不如看代码直接跑。下面这三个场景是我在实际项目里目睹过、也自己亲手踩过的雷。它们高度浓缩了char *和char []的行为差异。2.1 场景 A回头修改字符串内容一个崩一个欢假设你要写一个小函数把字符串里的首字母大写#include stdio.h #include ctype.h void upper_first(char *s) { if (s *s) { *s (char)toupper(*s); } } int main(void) { char *p hello; char s[] hello; upper_first(p); // 大概率段错误程序崩溃 upper_first(s); // 正常执行控制台打印 Hello printf(s 现在是: %s\n, s); return 0; }如果你在 Linux 上运行这段代码第一个调用upper_first(p)就会把进程打死。这不是偶然而是p指向的是只读字面量你试图往你的地址写入操作系统不答应。解决办法很简单要么像s一样用数组声明要么在堆上分配一块可写内存。这里有一个值得记住的工程经验**凡是函数内部要修改字符串内容的都不要把字符串字面量直接塞给char *形参。**要么声明时用数组要么调用前用strdup之类的函数复制到堆上。2.2 场景 B未初始化的char *直接复制野指针灾难面试时我常问这样一段代码char *p; strcpy(p, hello);问这段代码会发生什么正确答案是行为未定义几乎必然崩溃或破坏内存。因为p只是一个未被初始化的指针变量指向垃圾值。strcpy往一个野地址上写数据轻则 Segmentation Fault重则悄悄踩坏别的数据在线上环境留下难以排查的疑难杂症。对比一下数组char buf[64]; strcpy(buf, hello);之所以勉强能跑是因为buf这个数组对象已经确定了 64 字节的合法存储空间你往它的起始位置写数据是写入了自己“管辖范围”内的区域前提是别超过 63 个字符。数组不需要额外初始化它作为对象本身就自带了一段合法内存而指针若不指向一块合法内存它就是一颗随时会引爆的炸弹。因此工程规范里通常有一条红线**char *要么初始化为合法地址数组名、堆分配地址、NULL要么就赋NULL但绝对不能留着不初始化。**缓冲区类型的入参优先考虑数组或“指针 长度”的接口。2.3 场景 C函数返回局部数组的指针悬垂指针陷阱再看这段典型的错误写法char *bad_func() { char local[] hello; return local; // 危险返回了指向已释放栈内存的指针 }local是bad_func的栈内存数组。函数返回时local的生命周期结束这块内存被释放但返回值仍保存着它的地址。调用方拿这个指针去访问读到的数据是随机的、被别人覆盖过的甚至触发栈保护检查崩溃。这就是经典的悬垂指针dangling pointer。改成char *p hello再返回呢结果不同p指向的字符串字面量位于只读数据段生命周期与进程相同函数返回后这个地址依然有效所以可以安全返回。当然只读属性是这个“安全”的代价。不要把局部数组当成返回值传出去这几乎是 C 面试的必考“夺命题”。反过来看这也说明了char *在“传递地址”上的天然优势你可以返回一个指向长生命周期内存的地址而数组不行它一离开作用域就蒸发。3.sizeof、strlen、指针加减法三个面上看行为差异为什么这两个东西这么容易搞混因为它们在大多数表达式里长得一样但在少数“照妖镜表达式”面前立刻现出原形。sizeof就是最强的那面照妖镜。3.1sizeof为什么一个出 8一个出 6先跑一段最基础的char *p hello; char s[] hello; printf(%zu, %zu\n, sizeof(p), sizeof(s));输出结果在 64 位平台上8, 6原因前面已经提到sizeof(p)计算的是指针变量本身占用的字节数p只是一个保存地址的变量大小固定为你机器上一个指针的大小64 位系统是 8 字节。sizeof(s)计算的是数组对象占用的字节数也就是 6 个字节5 个字符 1 个\0。这个差异经常引来实际的坑如果写代码的人以为sizeof(p) / sizeof(char)能算出字符串长度然后把结果当作缓冲区大小来填充等于在向 8 字节里写 6 个字符——看起来凑合但完全没准头。更危险的是如果字符串更长直接溢出。3.2strlen呢为什么两个结果又一样了有细心的读者会问strlen(p)和strlen(s)输出不都是 5 吗为什么这里没区别因为strlen只看一件事从给定地址开始一直数到第一个\0为止的长度。它根本不管传入的地址到底是“指针变量的副本”还是“数组退化的结果”只看内存里那串字符。p保存着hello的起始地址s本身也能退化成首元素地址两者的起始地址相同指向的都是那 5 个字符的开头所以strlen输出自然一样。这里有一个很关键、也很容易让人误入歧途的认知strlen是“运行时按内容数数”sizeof是“编译时按类型算大小”。一个是查户口看内容走多远一个是按身份证看该对象占几平米。搞清这两者的运行时机很多选择困难症都会痊愈。3.3 指针加减法的双标现场一个能跑一个编译就死关于自增自减两类声明又出现了天壤之别char *p hello; char s[] hello; p; // 合法p 从此指向 e s; // 编译错误s 是数组名不是可修改的左值p是变量你可以改变它保存的地址让它指向数组中的下一个字符。s是数组名它不是变量不具备“可存储地址的实体”这一性质不能自增或自减。C 标准把数组名定义为不可修改的左值你只能通过下标s[i]访问元素不能直接改变s本身。那s 1可以吗可以。注意区别s 1是对s进行指针运算得到的是指向s[1]的地址这个表达式不是左值不修改s本身所以合法。s 1呢这也是合法的但它跳过的步长是整个数组6 个字节不是 1 个字符。原因在于s的类型是char (*)[6]指向整个数组而s退化后是char *只指向首元素。这一步的计算结果往往会把人绕晕。表达式char *p hellochar s[] hellosizeof(变量)8指针大小6数组总字节数strlen(变量)55变量合法编译错误变量 1指向第 1 个字符地址指向第 1 个字符地址变量 1指向 p 变量后的 8 字节处跳过整个数组指向数组结束后变量[0]等价于*(p0)读字符h读首元素h4. 函数传参时的“殊途同归”数组退化的底层逻辑很多人在函数传参上懵圈形参写成void foo(char *p)和void foo(char s[])到底有什么区别答案可能颠覆你的预期在函数形参这个语境下它们是完全相同的。4.1 编译器眼中的形参数组声明被静默改写C 标准在函数形参类型推导时特别规定当数组类型出现在函数形参列表中时它会被静默调整为对应的指针类型。就是说这三个声明完全等价void foo(char *p); void foo(char s[]); void foo(char s[6]);编译器看到char s[]时会自动把它转成char *s看到char s[6]也一样。所以函数体内部编译器并不知道数组长度。这就是为什么你没法在函数内部打印“数组长度”void foo(char s[]) { printf(%zu\n, sizeof(s)); // 输出 8不是 6 }刚从sizeof知识“毕业”的人第一次在函数里打出 8 这个数字时往往会咒骂一句“编译器坏了”。其实编译器没坏它只是按标准替你执行了数组退化。在函数传参的语境里永远只有指针没有数组。4.2 那真实的数组一定要传给函数怎么办三种务实方案既然数组进了函数就“退化”那如果需要让函数知道数组的边界怎么办三种最常见的工程实践第一种指针 长度。这是 C 标准库和绝大多数 C 项目采用的方式void copy_buf(char *dst, size_t dst_len, const char *src);调用方自己知道dst有多大把它和指针一起传进去函数内部用这个长度来防御越界。这种方式不依赖编译器自动推导最通用跨模块接口也好维护。第二种结构体包装。把数组和长度打包进结构体传结构体指针typedef struct { char data[64]; size_t len; } StringBuf; void process_buf(StringBuf *buf) { // 可以访问 buf-data也能读取 buf-len }这种方法在纯 C 语言里模拟“不退化数组”非常直观。数组的边界信息在结构体内保存得妥妥的传参过程也只是传指针不会有拷贝的开销。第三种C 语境下的数组引用。如果你切换到了 C可以用引用绑定数组类型让sizeof不再退化void foo(char (s)[6]) { printf(%zu\n, sizeof(s)); // 输出 6 }这是语言层面的硬性保证类型系统识别出“确确实实、正好 6 个字符的数组”才不会退化。但在纯 C 工程里我们还是以指针和结构体为主。4.3 如果不想让它退化结构体包装和数组引用其实是在“锁死”类型很多人在面试时卡住是因为不理解“退化”背后的契机。数组之所以退化是因为 C 语言从上世纪设计开始就比较务实函数调用时把一个数组拷贝一份传进去成本太高只传首地址高效且足以完成读写。所以标准选择了后者。但如果你真的需要“数组保持数组身份”结构体包装是最接近纯 C 的解法。结构体里定义的数组成员不会因为作为结构体成员而自动退化它的身份是“属于结构体的一块固定内存”。把结构体指针传进函数函数仍然可以一眼看出data的类型是char [64]还能利用sizeof(buf-data)得到真实长度。举个应用场景写一个环形缓冲区你要让读接口知道缓冲区容量是 4096 而不是一个裸指针。用char buf[4096]包进结构体接口设计就自然清晰、类型安全得多。这种“宁可多包一层类型也不要裸奔指针”的做法是我在大型 C 项目里面特别推荐的一招。5. 工程实战怎么选从内存管理、可修改性到接口设计理论知识补齐了最终还是要落地。下面这几个决策场景是我在真实项目里反复遭遇过的选择题。5.1 需要修改字符串内容时首选数组只读时const char *更保险这是第一条铁律写字符串用数组只读字符串用const char *。一个真实的例子是配置文件解析。你从配置文件读取一行想要strtok切分字符串strtok会在原缓冲区上写入\0分隔符。这时你拿const char *line ...或char *line ...都不行必须准备一个可写的字符数组或堆缓冲区。而反过来如果你只是想把这个字符串打到日志里用const char *msg就绰绰有余还能靠const帮编译器拦截误写。// 合适的做法可写场景 char line[256]; strcpy(line, keyvalue); char *p strtok(line, ); // 合适的做法只读场景 const char *greeting hello; printf(%s\n, greeting);把const加在指针指向的内容上是 C 语言里表达“只读语义”最直接的手段。它不会真的锁住内存页——内存页的只读保护是靠编译器分配只读段实现的const更多是编译期的约束。但它能让你在写代码的时候第一时间发现“有人在改只读字符串”这种低级错误。5.2 字符串拼接、动态增长等场景为什么必须上堆指针如果字符串的内容和大小在运行期才确定比如要拼接一个动态数量的用户名列表那么栈上的一块固定数组就撑不住了。这时你必须使用堆分配char *buf malloc(1024); if (!buf) { // 处理分配失败 } snprintf(buf, 1024, %s, %s, name1, name2);这里buf必须是char *因为数组大小在编译期就得固定没法等运行时再决定。堆内存的生命周期由malloc/free控制不再受函数栈的约束因此可以跨函数传递。唯一的代价是你必须自己记得free否则会内存泄漏。在嵌入式等受限环境里更是要仔细规划堆的使用策略。realloc是另一个必须配合指针使用的利器。数组没法扩容指针可以重新分配一块更大的内存再把旧数据搬过去。要处理一个不确定大小的配置项realloc的“伸缩自如”让人很安心。5.3 全局字符串到底放哪只读字面量 vs 全局数组全局作用域下两种声明同样有区别。char *g_p hello将指针放到 .data或 .bss 附近的初始区而hello本身放到 .rodata。char g_s[] hello则直接在数据段分配字符数组并把只读区内容拷入其中g_s是可以随时被改写的。在嵌入式领域这区别尤其要命。比如单片机内存紧张RAM 往往只有几十 KB这时把大字符串声明成char name[] ...等于把它们全部搬到宝贵的 RAM 里而const char *name ...可以把它留在 flash/ROM 中只有 4 字节或 8 字节的指针占用 RAM。想要修改时再从 flash 处复制到局部数组。这是我见过无数新手把自己内存预算吃满的一个经典原因。场景推荐声明原因函数内需要修改字符串char s[] ...数组在栈上可写无需手动释放只读、打印、入参const char *s ...节省栈空间只读安全生命周期长运行时动态拼装char *s malloc(...)大小不定需要realloc扩容全局大字符串常量const char *s ...可放只读段不占 RAM缓冲区收发/拷贝char buf[N]或结构体包需要大片可写内存且边界明确5.4 const 排列组合别再把const char *和char * const搞混最后我们说说const和指针组合的三种形态。这属于char *话题的近亲也是面试常客写法含义典型比喻const char *p指向的内容只读但p本身可以改p是“换店铺的地址本”店铺内容不可改char * const pp本身只读但指向的内容可改门牌号焊死在墙上屋里的东西随便摆const char * const p指针和内容全都只读门牌号焊死屋子也不许动它们的唯一共同点是类型都是“指针”所以sizeof(p)都是 8。在接口设计上const char *是最常见的形参写法它向你传递的信号是“我承诺不修改你传入的字符串”。写库的人用对const能让代码自我保护也让调用方安心——相当于签了一份编译器强制执行的不许改协议。从我个人的代码评审经验看最多的问题不是不知道这些类型的区别而是在函数声明里从不写const。所有人都传char *一旦里面有人写了个只读函数万一实现里不小心改了内容就变成静默的破坏。所以我现在做项目第一遍写代码时就把const标上书面形式逼着自己想清楚这块内存到底是不是我的我是不是真的需要写它回到开头那个问题char *和char []的区别说到底不是什么高深的语法而是“指向一串数据”和“拥有一串数据”的分野。你要是能顺着数组退化的思路把编译器每一次看着像“当指针用”的行为都追问一遍很多 C 语言的困惑都会迎刃而解。建议你下次再碰到不确定的地方别查博客直接写一段printf(%zu %zu\n, sizeof(p), sizeof(buf))自己验证一遍。亲手跑出来的结论比背下来的定义牢靠得多。