谈谈C语言的字面字符串

如果对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修饰符,以便编译器能在遇到试图修改一个字面字符串的代码时报错。


[1] https://www.securecoding.cert.org/confluence/display/c/CC.+Undefined+Behavior#CC.UndefinedBehavior-ub_33

上一篇:惊喜程序问题集 (1-3)


下一篇:windows和linux下如何远程获取操作系统版本和主机名