为什么我在向使用字符串字面量初始化的“char *s”写入时会出现分段错误,但不会在“char s []”上出现?

341

以下代码在第2行出现段错误:

char *str = "string";
str[0] = 'z';  // could be also written as *str = 'z'
printf("%s\n", str);

虽然这个方法完全可以正常工作:

char str[] = "string";
str[0] = 'z';
printf("%s\n", str);

已使用 MSVC 和 GCC 进行测试。


4
有趣的是,当在Visual Studio开发者命令提示符上使用Windows编译器(cl)时,这段代码实际上可以通过编译并且完美运行。这让我有些困惑了一会儿... - Maverick Meerkat
20个回答

283

参见 C FAQ 中的 问题 1.32

: 这些初始化方式有什么区别?
char a[] = "字符串常量";
char *p = "字符串常量";
如果我尝试给 p[i] 赋新值,我的程序会崩溃。

: 字符串常量(在 C 源代码中表示为双引号括起来的字符串)可以以两种稍微不同的方式使用:

  1. 作为 char 数组的初始化值,像 char a[] 的声明中一样,它指定了该数组中字符的初始值(如果需要,则还包括其大小)。
  2. 在其他任何地方,它会变成一个未命名的静态字符数组,并且这个未命名的数组可能存储在只读内存中,因此不能保证能够修改。在表达式上下文中,该数组立即被转换为指针(如第6节所述),因此第二个声明将 p 初始化为指向未命名数组的第一个元素。

一些编译器具有控制字符串常量是否可写的开关(用于编译旧代码),而一些编译器可能具有选项,使字符串常量被正式视为 const char 数组(以更好地进行错误检测)。


10
另外还有几点需要说明:(1)如描述的那样,程序在运行时会发生段错误,但其出现与运行环境有关;如果相同的代码在嵌入式系统中,则写操作可能没有效果,或者实际上将 s 改变为 z。(2)由于字符串字面值不可写,编译器可以通过将两个 "string" 实例放置在同一位置来节省空间;或者,如果代码中其他地方有 "another string",则一个内存块可以支持两个字面量。显然,如果允许代码更改这些字节,将会导致奇怪和困难的错误。 - greggo
1
@greggo:说得好。在具有MMU的系统上,还可以使用mprotect来取消只读保护以实现此操作(请参见此处)。 - user405725
所以 char *p="blah" 实际上创建了一个临时数组?很奇怪。 - rahul tyagi
3
两年使用C++编程后,我终于发现...TIL - zeboidlund
@rahultyagi 你是什么意思? - Suraj Jain
3
@rahul tyagi,这不是一个临时数组。相反,它是生命周期最长的数组。它由编译器创建并在可执行文件中找到。从上面你应该理解的是,这是一个共享数组,必须按照只读(实际上可能是只读)的方式处理。 - ikegami

120

一般情况下,当程序运行时,字符串常量会被存储在只读内存中。这是为了防止您意外更改字符串常量。在您的第一个示例中,"string" 存储在只读内存中,*str 指向第一个字符。当您尝试将第一个字符更改为 'z' 时,会导致段错误。

在第二个示例中,编译器将字符串 "string" 从只读内存中复制到 str[] 数组中。然后,更改第一个字符是允许的。您可以通过打印每个变量的地址来检查这一点:

printf("%p", str);

此外,在第二个示例中打印str的大小将向您显示编译器已为其分配7个字节:

printf("%d", sizeof(str));

17
在使用printf时,如果要使用"%p"格式来打印指针,请将该指针强制转换为void*类型,例如printf("%p", (void *)str)。 在使用printf打印size_t变量时,如果使用的是最新的C标准(C99),请使用"%zu"格式。 - Chris Young
6
还有,sizeof中的括号只在获取类型大小时需要(此时参数看起来像一个强制类型转换)。请记住,sizeof是一个运算符,而不是一个函数。 - unwind
1
使用%zu打印size_t - phuclv
警告:格式中存在未知的转换类型字符'z'[-Wformat=] :/ - john

45

这些答案大多数是正确的,但为了更加明确,需要补充一点内容...

人们所指的"只读存储器"是ASM术语中的文本段。它是内存中加载指令的同一位置。由于安全原因,这是只读的。当您创建一个char*并将其初始化为一个字符串时,该字符串数据被编译到文本段中,并且程序将指针初始化为指向文本段。因此,如果您尝试更改它,会导致segmentation fault。

如果写成数组形式,编译器会将初始化的字符串数据放在数据段中,这与全局变量等存储的位置相同。由于数据段中没有指令,因此该内存是可变的。这次编译器初始化字符数组(仍然只是一个char*),它指向数据段而不是文本段,在运行时可以安全地改变它。


1
但是难道不是真的存在可以修改“只读内存”的实现吗? - Pacerier
当字符串被写成数组时,如果它们是静态或全局的,编译器会将初始化的字符串数据放置在数据段中。否则(例如对于普通自动数组),它会放置在主函数的堆栈帧中。正确吗? - S E
@SE 是的,我想Bob Somers在写“数据段”时指的是堆栈、堆和静态(包括静态和全局变量)。而本地数组被放在堆栈上,所以你是正确的 :) - Olov
抱歉,但您在这里可能是正确的。数据段是专门用于初始化全局或静态变量的内存部分,但如果数组是本地的,则也可以放在堆栈上,就像您所写的那样。 - Olov
1
@Pacerier,有的。如果这是来自8位微型计算机软盘的代码,您绝对可以修改它。它在RWX标志意义上是“只读”的,而不是RAM vs. ROM意义上的。 - puppydrum64

36
为什么在向字符串写入时会出现分段错误? C99 N1256 草案 字符字符串字面值有两种不同的用法:
  1. 初始化 char[]:

    char c[] = "abc";      
    

    这是“更多的魔法”,并在6.7.8/14“初始化”中描述:

    字符类型的数组可以通过字符字符串文字进行初始化,可选地包含在大括号中。 字符串文字的连续字符(包括终止空字符,如果有空间或数组大小未知)初始化数组的元素。

    所以这只是一个快捷方式:

    char c[] = {'a', 'b', 'c', '\0'};
    

    像任何其他常规数组一样,c可以被修改。

  2. 在其他地方:它生成一个:

    因此,当您编写:

    char *c = "abc";
    

    这类似于:

    /* __unnamed is magic because modifying it gives UB. */
    static char __unnamed[] = "abc";
    char *c = __unnamed;
    

    请注意从char []char *的隐式转换,这始终是合法的。

    然后,如果您修改c [0],则还会修改__unnamed,这是UB。

    这在6.4.5“字符串文字”中有记录:

    5 在第7个翻译阶段,将值为零的字节或代码附加到由字符串文字或文字产生的每个多字节字符序列。 然后使用多字节字符序列初始化具有静态存储期和长度的数组,该数组刚好足以包含该序列。 对于字符字符串文字,数组元素具有char类型,并使用多字节字符序列的各个字节进行初始化[...]

    6 未指定这些数组是否不同,只要它们的元素具有适当的值即可。 如果程序尝试修改此类数组,则行为是未定义的。

6.7.8/32 "Initialization" 提供了一个直接的示例:

EXAMPLE 8: The declaration

char s[] = "abc", t[3] = "abc";

defines "plain" char array objects s and t whose elements are initialized with character string literals.

This declaration is identical to

char s[] = { 'a', 'b', 'c', '\0' },
t[] = { 'a', 'b', 'c' };

The contents of the arrays are modifiable. On the other hand, the declaration

char *p = "abc";

defines p with type "pointer to char" and initializes it to point to an object with type "array of char" with length 4 whose elements are initialized with a character string literal. If an attempt is made to use p to modify the contents of the array, the behavior is undefined.

GCC 4.8 x86-64 ELF 实现

程序:

#include <stdio.h>

int main(void) {
    char *s = "abc";
    printf("%s\n", s);
    return 0;
}

编译和反编译:
gcc -ggdb -std=c99 -c main.c
objdump -Sr main.o

输出包含:

 char *s = "abc";
8:  48 c7 45 f8 00 00 00    movq   $0x0,-0x8(%rbp)
f:  00 
        c: R_X86_64_32S .rodata

结论:GCC将char*存储在.rodata部分,而不是.text部分。
如果我们对char[]做同样的操作:
 char s[] = "abc";

我们得到:

17:   c7 45 f0 61 62 63 00    movl   $0x636261,-0x10(%rbp)

所以它被存储在堆栈中(相对于%rbp)。

但请注意,默认的链接器脚本将.rodata.text放在同一段中,该段具有执行但没有写入权限。可以使用以下命令观察到这一点:

readelf -l a.out

其中包含:

 Section to Segment mapping:
  Segment Sections...
   02     .text .rodata

换句话说,字符串太短了,在我们的情况下,字节被解释为“int”,并以这种方式推送到堆栈上。 - puppydrum64
@puppydrum64 这与字符串大小无关。根据 C 标准,必须始终在堆栈上进行操作(因为它可以进行修改)。另一个可以放在堆栈或文本中,但文本更有效率(因为您不需要在每次函数调用时加载新的副本)。 - Ciro Santilli OurBigBook.com

18
在第一段代码中,"string"是一个字符串常量,字符串常量不应该被修改,因为它们通常被放置在只读内存中。"str"是一个指针,用于修改常量。
在第二段代码中,"string"是一个数组初始化器,类似于缩写形式。
char str[7] =  { 's', 't', 'r', 'i', 'n', 'g', '\0' };

"

str"是在栈上分配的数组,可以自由修改。

"

1
如果str是全局或静态的,则在堆栈或数据段上。 - Gauthier

12

因为第一个例子中的"whatever"类型在上下文中是const char *(即使你将其分配给非const char*),这意味着你不应该尝试写入它。

编译器通过将字符串放置在只读内存部分来强制执行此操作,因此对其进行写入会生成段错误。


9
char *str = "string";  

上面的代码将str指向了程序二进制图像中硬编码的字面量值"string",这在内存中可能被标记为只读。因此,str[0]=试图写入应用程序的只读代码。我猜这可能与编译器有关。

8

为了理解这个错误或问题,您首先需要了解指针和数组之间的区别,因此我首先要解释它们之间的差异。

字符串数组

 char strarray[] = "hello";

内存数组存储在连续的内存单元中,以 [h][e][l][l][o][\0] =>[] 的形式存储,其中每个字符占用 1 字节大小的内存单元,并且可通过名称为 strarray 的名字访问这些连续的内存单元。因此,在这里字符串数组 strarray 包含初始化为其的所有字符。在本例中是 "hello"。因此,我们可以通过访问每个字符的索引值来轻松更改其内存内容。

`strarray[0]='m'` it access character at index 0 which is 'h'in strarray

它的值改变为'm',因此strarray的值改变为"mello";

这里需要注意的一点是,我们可以逐个字符地更改字符串数组的内容,但不能直接将其他字符串初始化为它,如strarray="new string"是无效的。

指针

众所周知,指针指向内存中的内存位置,未初始化的指针指向随机的内存位置,初始化后指向特定的内存位置。

char *ptr = "hello";

这里指针ptr被初始化为字符串"hello",这是存储在只读存储器(ROM)中的常量字符串,因此"hello"无法更改,因为它存储在ROM中。

ptr存储在堆栈段中,并指向常量字符串"hello"

因此,尝试将ptr[0]赋值为'm'是无效的,因为您无法访问只读存储器

但是,可以直接将ptr初始化为其他字符串值,因为它只是指针,因此可以指向其数据类型的任何变量内存地址。

ptr="new string"; is valid

6
char *str = "string";

分配一个指向字符串字面值的指针,编译器将其放置在您的可执行文件中不可修改的部分;

char str[] = "string";

分配并初始化一个可修改的本地数组


我们能否像写char *s = "HelloWorld"一样写int *b = {1,2,3} - Suraj Jain
@SurajJain 不行,因为这将是无效的转换(从int到int)。我们也不能写char* ptr = {'a','b'};,那也将是无效的转换(从char到char)。 - Jitu DeRaps

6
@matli提供的C语言FAQ已经提到了这一点,但是在这里没有其他人提到,为了澄清:如果一个字符串字面值(在你的源代码中用双引号括起来的字符串)被用于初始化字符数组之外的任何地方(即@Mark的第二个示例,可以正常工作),那么该字符串将由编译器存储在一个特殊的静态字符串表中,这类似于创建一个全局静态变量(当然是只读的),它本质上是匿名的(没有变量“名称”)。只读部分是重要的部分,也是为什么@Mark的第一个代码示例会导致段错误的原因。

我们能否像写char *s = "HelloWorld"一样写int *b = {1,2,3} - Suraj Jain

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接