如果对C语言的字面字符串(literal string)缺乏足够的了解,编程时不注意它的特点,就可能会遇到一些略显奇怪的状况。本文对下面这段简单的代码加以几个简单的变形,再分别分析它们的输出,最后总结出字面字符串的特点和编程时需要注意的地方。
#include <stdio.h>
int main() {
printf("Hello!\n"); //Hello!
return 0;
}
本文出现的所有代码的测试环境均为运行32-bit Debian Linux操作系统的Raspberry Pi 3
变形#1,声明一个局部字符类型指针指向字面字符串
#include <stdio.h>
int main() {
char *s = "Hello!\n";
printf(s); //Hello!
return 0;
}
依然输出Hello!, 符合预期。
变形#2, 修改字符串的第一个字符为'B'
#include <stdio.h>
int main() {
char *s = "Hello!\n";
*s = 'B'; //crash here
printf(s);
return 0;
}
运行到*s = 'B'这句时进程异常退出, 错误信息为Segmentation fault,看上去有些奇怪,但我们先将这个问题放在一边,继续看后面几种变形。
变形#3, 在一个全部变量和一个局部变量中定义两个完全一样的字面字符串,观察这两个字符串所在的位置
#include <stdio.h>
char *gs = "Hello!\n";
int main() {
char *s = "Hello!\n";
printf("%p,%p\n", gs, s); //0x104dc,0x104dc
return 0;
}
这两个指针的所指向的位置是完全一样的!也就是说,即使代码中定义了多个相同的字面字符串,C编译器实际上也仅生成了一份拷贝。
变形#4, 考察字面字符串所在地址的内存访问权限。
#include <stdio.h>
#include <unistd.h>
char *gs = "Hello!\n";
int main() {
char *s = "Hello!\n";
printf("%p,%p\n", gs, s); //0x10518,0x10518
sleep(100000);
return 0;
}
先让代码#4打印出那两个相同的地址后长时间sleep,再趁它熟睡时通过ps命令查到该进程的pid为27612,然后查看/proc/27612/maps文件就获得了该进程的内存映射信息,其中第一行为
00010000-00011000 r-xp 00000000 b3:07 933808 /home/pi/a.out
这说明从地址0x10000开始的长度为4k的区域(恰好是一个页面的大小)是只读的,如果进程试图写入这块只读区域,就会触发操作系统的内存异常访问保护从而收到SIGSEGV信号并因此退出。
变形#5, 换一种方式来定义字符串。
#include <stdio.h>
#include <unistd.h>
char *gs = "Hello!\n";
int main() {
char s[] = "Hello!\n";
printf("%p,%p\n", gs, s); //0x10538,0x7e9d6360
*s = 'B';
printf(s); //Bello!
sleep(100000);
return 0;
}
将char *s改为char s[]后,编译器会在栈上分配一块和字符串"Hello!n"同样大小的内存并它将复制进去。采用和变形#4同样的考察办法也能看出指针s的值0x7e9d6360是一个指向栈内存的地址,并且栈内存是可读写的:
7e9b6000-7e9d7000 rwxp 00000000 00:00 0 [stack]
于是,程序正常打印出"Bello!"。显然,还存在一种不使用栈空间而使用堆空间的变形,该变形的实现不在这里描述,留给读者作为练习。
变形#6, 改变内存访问权限。
#include <stdio.h>
#include <sys/mman.h>
char *gs = "Hello!\n";
int main() {
char *s = "Hello!\n";
//align to page boundary then make the page writable
void *page = (void *)((unsigned long)s & ~0xfff);
if (mprotect(page, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC)) {
perror("mprotect");
}
*s = 'B';
printf(s); //Bello!
printf(gs); //Bello!
return 0;
}
通过调用mprotect()函数将原本只读的内存页设为可写的,我们实现了对字面字符串的直接修改!但是,这种方式的副作用是巨大的,会令所有指向该字符串的指针都被影响,例如,在上面的代码中,通过指针s将'H'改为'B'后指针gs指向的内容也一起被改变了。由于这样的原因,在实际编程中极少会将一个原本只读的代码页改为可写的。相反,在调查某块不应被修改的内存区域被意外改写的bug时,可以将本来可写的内存页面设置为不可写,让有bug的代码由于触发内存访问异常而暴露出来。
结论
事实上,对字面字符串的修改是C语言标准中一个未定义的行为[1], 但各大主流C编译器的实现都是对每个字面字符串仅保留一份只读拷贝,导致试图直接修改它们的代码都会遇到内存保护错误而异常退出。所以,千万不要试图直接修改一个字面字符串,如需要使用一个修改后的字面字符串,应先在栈上或堆上创建一份拷贝,再对这个拷贝进行修改。最后,当定义一个字符类型指针变量指向一个字面字符串时,最好总是给它加上const修饰符,以便编译器能在遇到试图修改一个字面字符串的代码时报错。