搞懂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 = "hello" | char s[] = "hello" |
|---|---|---|
| 变量本体 | 一个指针变量,占 8 字节(64 位系统) | 一个数组,占 6 字节(含'\0') |
| 实际字符数据位置 | 只读数据段 | 栈 / 数据段(可写) |
| 谁真正拥有字符串内容 | 编译器(字面量) | 数组对象本身 |
| 能否修改字符串内容 | 行为未定义,通常崩溃 | 正常 |
1.3[]里写不写大小:编译器如何自动推算
有人会问:char s[] = "hello"我没写大小啊,编译器怎么知道要开多少空间?这是 C 语言语法的一个经典细节:由初始化器决定数组长度。C 标准规定,若数组定义时省略长度,但有初始化列表/字符串字面量初始化,则数组的实际长度为初始化器元素个数。
对于"hello",字符串本身的字符个数是 5,但 C 规定字符串字面量末尾自带一个'\0',所以编译器实际分配的数组大小是 6:
char s[] = "hello"; printf("%zu\n", sizeof(s)); // 输出 6,不是 5这是初学者最容易翻车的一个点。很多人以为sizeof(s)是 5,结果拿它去算缓冲区长度,给数组塞第 6 个字节,直接越界溢出。如果你显式写成char s[5] = "hello",C 会直接报编译警告或错误,因为放不下'\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 = "hello" | char s[] = "hello" |
|---|---|---|
sizeof(变量) | 8(指针大小) | 6(数组总字节数) |
strlen(变量) | 5 | 5 |
变量++ | 合法 | 编译错误 |
变量 + 1 | 指向第 1 个字符地址 | 指向第 1 个字符地址 |
&变量 + 1 | 指向 p 变量后的 8 字节处 | 跳过整个数组,指向数组结束后 |
变量[0] | 等价于*(p+0),读字符'h' | 读首元素'h' |
4. 函数传参时的“殊途同归”:数组退化的底层逻辑
很多人在函数传参上懵圈:形参写成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, "key=value"); 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 p | p本身只读,但指向的内容可改 | 门牌号焊死在墙上,屋里的东西随便摆 |
const char * const p | 指针和内容全都只读 | 门牌号焊死,屋子也不许动 |
它们的唯一共同点,是类型都是“指针”,所以sizeof(p)都是 8。在接口设计上,const char *是最常见的形参写法,它向你传递的信号是“我承诺不修改你传入的字符串”。写库的人用对const,能让代码自我保护,也让调用方安心——相当于签了一份编译器强制执行的不许改协议。
从我个人的代码评审经验看,最多的问题不是不知道这些类型的区别,而是在函数声明里从不写const。所有人都传char *,一旦里面有人写了个只读函数,万一实现里不小心改了内容,就变成静默的破坏。所以我现在做项目,第一遍写代码时就把const标上,书面形式逼着自己想清楚:这块内存到底是不是我的?我是不是真的需要写它?
回到开头那个问题:char *和char []的区别,说到底不是什么高深的语法,而是“指向一串数据”和“拥有一串数据”的分野。你要是能顺着数组退化的思路,把编译器每一次看着像“当指针用”的行为都追问一遍,很多 C 语言的困惑都会迎刃而解。建议你下次再碰到不确定的地方,别查博客,直接写一段printf("%zu %zu\n", sizeof(p), sizeof(buf)),自己验证一遍。亲手跑出来的结论,比背下来的定义牢靠得多。