前阵子一个朋友在群里贴了一段代码:char *msg = "welcome"; 然后下一行就写 msg[0] = 'W';。编译没报错,运行直接段错误。他问我是不是指针越界了,我说指针地址没问题,问题是它指向的那块内存从一开始就不允许你写。这类问题每隔几天就会在一个新手提问区出现一次,代码形态五花八门,内核却都一样:字符串、指针的内容可修改性判断没有建立起来。今天就把这件事一次讲透,从声明形式、const 规则到函数边界,再到崩溃现场怎么定位,全部串起来说。
1. 字符串、指针、可修改性:先分清问题出在哪一层
1.1 三个概念其实不在同一个层面
聊“字符串能不能改”之前,先把名词拆干净。
- 字符串:一批以
\0结尾的char序列,它在内存里是一段连续的空间。 - 指针:一个保存地址的变量。它本身是一个独立的对象,有自己的内存地址,也有自己的可修改性。
- 可修改性:至少有上下两层。上层是“指针变量本身能不能重新指向别处”,下层是“指针指向的那块内容能不能被写入”。
很多人在分析 msg[0] = 'W' 崩溃时,第一反应是“哦,指针变量用错了”,于是去检查指针赋值、检查偏移量。实际上 msg 这个指针变量没有任何问题,它的值指向的确实是字符串 "welcome" 的首字符,问题出在“那个位置的内容是只读的”。这就是典型的把两个层混在一起谈造成的误判。
所以后续所有判断逻辑都要围绕一件事:你要修改的到底是“指针变量自己”,还是“指针指向的内容”?改这两个对象的合法性依据完全不一样。
1.2 字符串字面量的只读身份是底层安排
"welcome" 这种写在代码里的字符串,专业叫法是字符串字面量。它不是一个普通的运行时变量,而是编译器放在只读数据段里的常量数据。
以 Linux 下 ELF 格式为例,字符串字面量一般被放进 .rodata 段。进程加载时,这个段会被映射成只读页面,任何对它的写入都会触发操作系统的内存保护机制,表现就是段错误。有些嵌入式环境或者老式 32 位系统里,.rodata 和可写数据段可能落在同一个可写的页面上,写进去一时半会儿不崩,但这是彻头彻尾的未定义行为,换一个平台就原形毕露。
既然字面量是只读的,为什么还总有人拿它去初始化一个普通指针?因为在 C 语言的历史里,字面量的类型被定义成 char[N],允许隐式转成 char*,这就是 char *msg = "welcome"; 能通过编译的根本原因。C++ 就严格得多,C++11 起字符串字面量类型是 const char[N],写成 char* msg = "welcome"; 直接编译报错。
1.3 写字符串的三种姿势,判据都一样
实际代码里写字符串无非三种:
c复制p[0] = 'W';
strcpy(p, "Wel");
memcpy(p, "Wel", 3);
有人觉得下标赋值崩溃,换成 strcpy 就安全,这是错觉。如果 p 指向的是只读区域,三种写法全部崩溃,只是崩溃的位置不同。判断依据不取决于“用什么方式写”,而取决于“写到哪里”:
- 目标是栈上的字符数组?可以写。
- 目标是堆上
malloc出来的内存?可以写。 - 目标是全局字符数组?可以写。
- 目标是字符串字面量本身?不可以写。
后面几章就是教你怎么在写代码之前,就判断出“这个指针最终指向哪一类目标”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 声明形式就是第一判据:数组和指针是两回事
2.1 数组与指针的内存建模差异
同样是保存 "hello",下面两行代码的底层完全不同:
c复制char str[] = "hello"; // 栈上分配6字节,把"hello\0"拷贝进去
char *ptr = "hello"; // 栈上分配8字节(64位),保存字面量地址
str 数组拥有自己的副本。你修改 str[0],改的是栈上自己的那份,字面量 "hello" 是源头,但已经和它没关系。ptr 不拥有字符串内容,它只是记录了只读数据段里那个 "hello" 的地址,修改 ptr[0] 等于直接往只读页面写数据。
用表格来看更直观:
| 对比维度 | char str[] = "hello" |
char *ptr = "hello" |
|---|---|---|
| 内存位置 | 栈/全局数据段,可写 | 指针变量在栈上,字符串在 .rodata |
| 谁拥有字符串内容 | str 自己 |
字面量区,ptr 不拥有 |
sizeof |
6 | 8(64位平台) |
&str / &ptr |
内容首地址 | 指针变量本身地址 |
| 修改内容是否合法 | 合法 | 未定义行为,通常崩溃 |
判断可修改性时,最省事的做法就是先看声明:如果是数组,内容归自己管,放心写;如果是指针变量,就要继续追查它指向谁。
2.2 数组名退化为指针之后,函数里拿不到“出身”信息
这里有一个非常关键的认知盲区。数组一旦作为函数参数传递,就会退化成指针:
c复制void doit(char *s) {
s[0] = 'X'; // 这里完全无法判断s指向哪里
}
函数内部拿到 s 时,它只知道自己收到一个地址,既不知道这个地址是数组退化来的,也不知道它是不是指向字面量。同一个函数,既能处理:
c复制char buf[] = "hello";
doit(buf); // 安全
也能处理危险调用:
c复制doit("hello"); // C语言编译通过,运行崩溃
所以当你看到某个函数签名是 char *s 就认为“传入的一定是可修改缓冲区”,这个推理是错的。函数的签名只表达“我希望收到一个 char 指针”,至于这个指针指向的对象能不能写,调用者比函数更清楚。
这也解释了为什么很多优秀库会在文档里写一句“该参数必须是可写缓冲区”。这不是废话,这是在补全编译器传达不了的信息。
2.3 二维数组与指针数组:谁存内容,谁存地址
字符串数组的声明方式,是最容易踩坑的地方,尤其是涉及多维数组和指针数组。
c复制char table[][16] = {"zhang", "li"}; // 每行16字节,内容是拷贝
char *alias[] = {"zhang", "li"}; // 元素是指针,指向字面量
table 是一个真正的二维字符数组,16 字节一行的内存是连续的,table[0][0] 可以改,改完不影响别人。alias 是“指针数组”,数组里存的是地址,"zhang" 和 "li" 本身还在只读区。所以:
alias[0] = "wang"合法,改的是数组元素(指针变量),没碰字符串内容。alias[0][0] = 'z'非法,它在修改字面量。
很多同学在写菜单表、命令表、状态表时,喜欢用 char *items[] = {...},这本身没问题,前提是把每个元素当作“只读字符串指针”来用。C++ 里这种写法建议直接写成 const char *items[],不然连初始化都会收到警告,在 C++20 里甚至是错误。
3. const 的读写规则:从右往左,分清谁不能改
3.1 三种常见声明的可修改性
const 在字符串相关代码里出现频率极高,但很多人对它的理解停留在“加个 const 就安全了”。实际上 const 修饰谁、修饰在哪一层,直接决定“指针本身”和“指针指向的字符串”哪个能改。
c复制const char *s1; // 等价于 char const *s1
char * const s2;
const char * const s3;
读法技巧:从右往左读,const 修饰它左边最近的那个类型。
const char *s1:s1是指向const char的指针。s1可以被重新赋值,*s1不能写。char *const s2:s2是const指针,指向char。s2本身不能改,*s2能写。const char *const s3:两者都不许动。
用表格更好记:
| 声明 | 指针本身可改? | 字符串内容可改? | 适用场景 |
|---|---|---|---|
char *s |
可以 | 可以 | 需要修改内容的临时缓冲区 |
const char *s |
可以 | 不可以 | 只读字符串参数、字面量 |
char * const s |
不可以 | 可以 | 固定指向某缓冲区的写接口(少见) |
const char * const s |
不可以 | 不可以 | 全局只读配置字符串 |
项目里凡是描述“一段只读字符串”的接口,统一写成 const char *。好处是调用方可以放心传字面量、传 char[]、传 std::string::c_str(),编译器来帮你保证没人试图在函数里改它。
3.2 多级指针的 const 传递陷阱
字符串数组、命令行参数这类场景会用到二级指针,const 的分层规则在这里特别容易出问题。
c复制char **argv; // 指向"指向char的指针"的指针
const char **p; // 指向"指向const char的指针"的指针
这两者类型不兼容。很多人以为 const 加在第二层就能保护字符串内容,于是写 const char **p = argv;,结果编译器报错。原因很微妙:
如果允许 char ** 转成 const char **,那么可以通过以下路径破坏 const 保证:
c复制const char c = 'x';
const char *pc = &c;
char *p = "abc";
char **pp = &p;
const char **q = pp; // 假设允许
*q = pc; // 现在 p 指向了 const char c
**pp = 'y'; // 通过非const指针写入了const对象
所以语言干脆禁止这种隐式转换。
那命令行参数这种字符串数组怎么只读访问?直接用 const char *args[] 声明参数,函数内部就不会产生修改意图。记住一条原则:如果数据是“字符串数组的只读视图”,类型应该写成 const char * const * 或 const char **,而不是在 char ** 上硬加一个 const。
3.3 别用 const_cast 拆只读内存的墙
C++ 里有个操作符叫 const_cast,能把 const char * 强转成 char *。有些人用它绕开接口限制去修改字符串,这在字面量场景下是给自己埋雷:
cpp复制const char *s = "abc";
char *p = const_cast<char*>(s);
p[0] = 'A'; // 编译通过,运行崩溃/未定义行为
理解 const_cast 的正确姿势:它的合法用途是“去掉一个本来就是可变对象的 const 修饰”。比如某个函数签名写错了 const,但实际数据来自一个可修改的 char[],此时用 const_cast 勉强说得过去。如果数据来源本身就是字面量,const 不是多余的承诺,而是底层只读的映射,const_cast 拆不掉硬件的保护。
4. 判断的难点:函数边界上的修改权限
4.1 函数签名里的 char* 不等于“可改”
判断可修改性最深的坑,出现在函数调用边界上。
假设函数声明:
c复制void trim(char *s);
你只知道 trim 可能需要修改参数,但不知道它会不会改,更不知道它假设你传给它的地址代表什么对象。调用方如果写:
c复制trim(" hello ");
在 C 语言里这是合法的,编译器最多给个警告,运行起来就取决于 trim 内部是否真的写了。一旦写了,崩溃概率极高。
正确习惯是接口设计阶段就把读写权限写清楚:
c复制// 明确:in必须是指向可写缓冲区的指针,且缓冲区大小至少为size字节
int normalize(char *in, size_t size);
调用方拿到这个接口,就知道自己不能传字面量。函数内部修改、越界、容量校验都有据可查。判断“能不能改”这件事,单靠函数签名永远不够,必须回到数据源头。
4.2 标准库函数里三处典型“身份陷阱”
标准库函数自己也藏了几个身份陷阱,天天有人踩。
第一个是 strchr / strstr。
c复制char *strchr(const char *s, int c);
函数接收的是 const char *,却返回 char *,原因是 C 语言标准委员会为了兼容历史代码,去掉了返回值的 const 属性。返回的指针指向原字符串内部,所以修改它是否合法取决于原字符串是谁。如果原字符串是字面量,返回的指针就是只读的;如果原字符串是 char[],则返回的指针可修改。判断原则和前面一模一样:看源头,不看表面类型。
第二个是 strtok。
strtok 会把原字符串里的分隔符改成 \0,也就是说它必须修改输入参数。如果你写:
c复制char *token = strtok("a,b,c", ",");
第一步就崩,因为它在写字面量。正确做法是先把字符串拷贝到可写缓冲区,再调用 strtok。这类“会原地改写参数”的函数,文档里通常会写明,问题是很多人不看文档。
第三个是 strdup。
strdup 会动态分配一块新内存,把字符串拷贝进去并返回。返回值指向堆内存,可写,用完必须 free。这是少数几个“返回值天然可修改”的字符串函数。
| 函数 | 是否改写实参 | 返回是否可写 | 常见坑 |
|---|---|---|---|
strchr |
否 | 取决于原字符串 | 把返回指针拿来直接写 |
strtok |
是 | — | 第一参数传字面量 |
strdup |
否 | 是 | 忘记 free |
sprintf |
写目标缓冲区 | — | 目标传给只读区 |
4.3 不同编译器与语言标准的行为差异
同样一段代码,在不同编译器、不同语言标准下的接纳程度不一样,这会影响你对问题的感知。
C 语言里,字符串字面量类型是 char[N],它允许字面量隐式转换成 char*。GCC 加 -Wwrite-strings 之后,会对这种转换提出警告。C++ 则直接是编译错误:
cpp复制char *s = "hello"; // C++ 编译报错:invalid conversion from 'const char*' to 'char*'
也就是说,C++ 开发者根本写不出这种代码,而 C 开发者写出来通常也只是个警告。不同的开发环境对“可修改性”的拦截力度不同,但不代表危险的代码在宽容的编译器下就变安全。
在嵌入式或老平台上还有个隐蔽问题:.rodata 和 .data 可能落在同一个可写段里,代码在开发板上一路跑过,部署到新平台立刻崩溃。所以“我这边不崩”不能作为修改字面量合法的理由。
5. 实战排查链路:从崩溃地址到代码根因
5.1 三步判断法:先问地址从哪来
遇到“字符串写完就崩”的代码,不要急着改写法,按下面三步走。
第一步,找源头。盯着目标指针变量,往上追溯它的第一步赋值/初始化。看它是来自字面量、数组名、函数返回值,还是堆分配。
第二步,拆类型。把声明里的 const 分层拆开,确认“字符串内容可不可写”这一层有没有被 const 限制,或者底层本身是否只读。
第三步,找写点。在代码里扫描对这个指针的下标赋值、strcpy、memcpy 等写操作,确认写操作发生在哪一层对象上。
常见场景可以归纳成一张表:
| 目标地址来源 | 例子 | 内容可修改? |
|---|---|---|
| 字符串字面量 | char *p = "hi"; |
否 |
| 栈数组副本 | char buf[] = "hi"; |
是 |
| 全局/静态数组 | static char buf[64]; |
是 |
| 堆内存 | char *p = malloc(64); |
是 |
argv[i] |
程序参数 | 标准未保证,POSIX 语义下通常可改但不建议依赖 |
函数返回值(如 strchr) |
取决于原字符串 | 视源头而定 |
5.2 现场案例:字符串逆序函数为什么一跑就崩
字符串逆序是社区高频问题,也特别适合当案例。
c复制void reverse(char *s) {
int len = strlen(s);
for (int i = 0; i < len / 2; i++) {
char t = s[i];
s[i] = s[len - i - 1];
s[len - i - 1] = t;
}
}
// 调用
char *text = "hello";
reverse(text); // 崩,s[0] 在写 .rodata
根因一眼就能看到:text 是指向字面量的指针,reverse 内部对 s 字符做的交换,本质上全是写操作。修起来也很简单,把调用侧改成:
c复制char text[] = "hello";
reverse(text); // 安全,把字面量内容拷贝到栈上再处理
或者按“需要修改就传可写缓冲区”的约定去改造接口,把拷贝、逆序、释放全部包在函数内部。后者更符合模块化的思路。
5.3 用编译器和调试器坐实“只读段”证据
如果你已经遇到崩溃,可以用工具链把证据链坐实。
编译阶段加警告:
bash复制gcc -Wall -Wwrite-strings -o app app.c
-Wwrite-strings 会让 GCC 对“把字符串字面量赋给 char*”的行为给出警告。虽然不能完全阻止,但能帮你扫出一批有隐患的初始化。
运行阶段,用 GDB 看崩溃位置。假设程序崩在 s[i] = s[len - i - 1] 这一行:
text复制(gdb) bt
(gdb) p s
(gdb) p/x s
打印出的地址如果和 /proc/<pid>/maps 里的只读段范围吻合,基本就实锤了。也可以用:
bash复制objdump -s -j .rodata app
直接查看可执行文件里哪个字面量落在了只读数据段。用 Valgrind 跑一遍能检测到 Invalid write,ASAN 也能抓一部分。调试过程先定位“访问地址属于哪个段”,再回到代码看“这个地址是谁给的”,两个信息一对,根因就跑不掉。
6. 我在排查字符串修改崩溃时的习惯动作
这几年帮人看过的段错误,有相当比例都是改字符串内容惹出来的。我现在排查这类问题,已经不习惯第一时间怀疑“越界”。越界通常会写坏相邻数据,表现更随机;而“写只读区”几乎百分之百稳定崩溃,且崩溃指令就在写操作那一行。看到这种稳定崩溃,我第一句话就是:这个地址是哪儿来的?
顺着地址来源往下查,判断链条其实就那么几条:声明是数组还是指针、const 有没有、源头是字面量还是可写缓冲区。别人问我有什么经验,我只能说,别急着写,先想清楚你要改的目标,是数组里的自己人,还是别人家的字面量。如果接口上拿到的地址来路不明,拷贝到自己分配的缓冲区再动手,永远是最稳妥的路。
