关于字符串字面量的数组和指针表示法
[!note] 说明
这里所讨论的场景,仅限于使用字符串字面量时,分别用数组和指针表示法进行处理。这篇文章会涉及少量汇编语言的使用,反编译产物由 IDA Freeware 生成。
假设有这样一段 C 语言代码,我们的目标是读取和修改某个 字符串字面量 的值。首先是使用数组形式:
#include <stdio.h>
int main(void)
{
char str[] = "Hello World!";
printf("%s\n", str);
str[6] = 'w';
printf("%s\n", str);
return 0;
}
执行它,可以看到正确输出:
Hello World!
Hello world!
随后,将字符数组形式改为指针形式:
#include <stdio.h>
int main(void)
{
char *str = "Hello World!";
printf("%s\n", str);
str[6] = 'w';
printf("%s\n", str);
return 0;
}
执行后发现出现了段错误:
Hello World!
Segmentation fault (core dumped)
出现段错误说明我们的程序:
- 访问了尚未被初始化的内存地址
- 尝试修改只读的内存地址
比如在这个例子中,我们尝试 str[6] = 'w'
时,违反了上述第二条规则。
两种表示法的数据存放位置
为什么会这样呢?这是 C 语言中对字符数组和字符指针不同的处理方式造成的。当然,这里处理的对象限定为“字符串字面量”。C 语言中常用的两种表示字符串字面量的方式:字符数组和字符指针。我们通过以下例子来探究,同时为了更全面了解,特地引入一个明示变量/符号常量[1] 来表示字符串字面量。另外,本例中所使用的编译器为 gcc version 11.4.0 (GCC)
,特此说明:
#include <stdio.h>
#define MSG "Hello World!";
int main(void)
{
const char *str_p = "Hello World!";
char str_arr[] = "Hello World!";
const char *msg_p = MSG;
char msg_arr[] = MSG;
printf("Source\t\tPtr Addr.\tPtr Target Addr.\tTarget String\n");
printf("[str_p]\t\t%p\t%p\t\t%s\n", &str_p, str_p, str_p);
printf("[str_arr]\t%p\t%p\t\t%s\n", &str_arr, str_arr, str_arr);
printf("[msg_p]\t\t%p\t%p\t\t%s\n", &msg_p, msg_p, msg_p);
printf("[msg_arr]\t%p\t%p\t\t%s\n", &msg_arr, msg_arr, msg_arr);
return 0;
}
我们依次输出代码中直接书写的 字符串字面量 和使用明示变量替代的 字符串字面量 分别按照:
- 指针/数组表示法下的 指针地址
- 指针/数组表示法下的 指针指向目标地址
- 指针/数组表示法下的 字符串字面量的值
编译执行结果如下:
Source Ptr Addr. Ptr Target Addr. Target String
[str_p] 0x7ffffcc38 0x100403000 Hello World!
[str_arr] 0x7ffffcc2b 0x7ffffcc2b Hello World!
[msg_p] 0x7ffffcc20 0x100403000 Hello World!
[msg_arr] 0x7ffffcc13 0x7ffffcc13 Hello World!
首先观察第 3 列 指针指向目标地址。其中,凡是由指针表示法表示的字符串字面量 (第 2、4 行),其内存地址都是 0x100403000
,这是因为 GCC 将这两个指针指向了完全相同的内存数据地址 (位于数据段),也就是字符串字面量 "Hello World!"
所在的地址。但是两个使用数组表示法的指针指向目标地址却并不相同,说明数组表示法 没有将首地址指针指向数据段中字符串字面量的地址。究其原因,我们还需要进行下一步分析。
观察数组表示法 (第 2、4 行) 的第 2 列 指针地址。可以发现这两个值也是不相同的,横向再各自与同行的第 3 列比较,可以发现数组表示法下 指针地址 和 指针指向目标地址 竟然是相同的。说明在数组表示法下,字符数组首地址指向的不是数据段的地址,二是代码段的地址。我们用数组表示法的地址减去指针表示法的地址来计算 地址偏移量,可以发现:
而 0x000000D
的十进制是 13,正好是一个 C 语言字符串 "Hello World!"
的长度 (C 语言字符串需要用 '\0'
结尾)!
位置不同,读写权限也不相同
通常而言,位于数据段的字符串字面量属于 静态存储 的范畴,它们是只读的。因此,使用指针表示法时,这些数据不可以被修改。也就是我们一开始的程序出现段错误的原因了。为了避免这个错误,开发者们应该主动使用 const char * xxx
这样的方式来定义字符串字面量。从而提醒自己和编译器,这个指针只能够读字符串内容,却不可以修改其中的值。
而如果我们希望修改其中的内容,就需要用到数组表示法了。在这种情况下,数据将会在程序运行的时候被复制到程序内存的 栈空间 中,从而允许进行相应的修改操作。这一点将在下一小节中进行分析。
不同存放位置的事实证据
我们使用 IDA Freeware 打开刚才构建的程序,选择 "Portable executable for AMD64 (PE) [pe64.dll]",以及 "MetaPC (disassemble all opcodes)" 这一处理器类型。打开后,找到左侧导航中的函数 f
。其内容很长,但我们这里可以不用在意所有关于输出的部分,只聚焦于定义变量的过程:
lea rax, aHelloWorld ; "Hello World!"
mov [rbp+var_8], rax
mov rdx, 6F57206F6C6C6548h
mov [rbp+var_15], rdx
mov [rbp+var_D], 21646C72h
mov [rbp+var_9], 0
lea rax, aHelloWorld ; "Hello World!"
mov [rbp+var_20], rax
mov [rbp+var_2D], rdx
mov [rbp+var_25], 21646C72h
mov [rbp+var_21], 0
[!note] 基础信息
aHelloWorld
是数据段的内容,相应的内存表示为:注意最后的.rdata:0000000100403000 aHelloWorld db 'Hello World!',0
,0
是字符串结尾的含义。var8, ..., var_21
这些是 IDA 因为无法找到符号而为栈帧中变量所取的别名。
我们依次从上往下分析:
- 通过
LEA
指令获得数据段aHelloWorld
的地址,并存入rax
寄存器; - 将
rax
中的内容 (是一个 地址值) 存放在变量var_8
的位置上,即代码中的str_p
; - 将 16 进制立即数
6F57206F6C6C6548
写入寄存器rdx
,如果你倒过来看,这其实是"oW olleH"
的 ASCII 码序列6F 57 20 6F 6C 6C 65 48
; - 将
rdx
中的内容 (一个 8 字节的 数值) 存放在变量var_15
的地址上,即代码中的字符数组str_arr
的前 8 个元素; - 将 16 进制立即数
21646C72
(4 字节) 写入变量var_D
的地址上,即代码中的字符数组str_arr
的第 8-12 号索引对应的字符,同样,反过来看就是!dlr
的 ASCII 码序列21 64 6C 72
; - 将
0
写入变量var_9
的地址上,即代码中字符数组str_arr
的最后一个字符位置,作为 C 语言字符串的结束符。 - 同理,后面执行了几乎一致的操作。
从上述汇编代码的执行逻辑可以轻松证实我们此前的分析:
- 数组表示法会在运行时将字符串字面量的值存入栈空间,并记录此部分内存的首地址;
- 指针表示法会在运行时分配一个指针指向位于数据段静态存储的字符串字面量首地址。
此处的用法与《C Primer Plus(第6版)中文版》一书相同,两者是同一含义。 ↩︎