谈谈C语言的字面字符串

简介: 通过几段小程序深入分析了C语言中字面字符串(literal string)的特点以及正确的使用方式。

如果对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

目录
相关文章
|
3月前
|
NoSQL 程序员 Redis
C语言字符串的设计缺陷
C语言字符串的设计缺陷
39 1
|
7天前
|
存储 C语言
【C语言基础考研向】10 字符数组初始化及传递和scanf 读取字符串
本文介绍了C语言中字符数组的初始化方法及其在函数间传递的注意事项。字符数组初始化有两种方式:逐个字符赋值或整体初始化字符串。实际工作中常用后者,如`char c[10]=&quot;hello&quot;`。示例代码展示了如何初始化及传递字符数组,并解释了为何未正确添加结束符`\0`会导致乱码。此外,还讨论了`scanf`函数读取字符串时忽略空格和回车的特点。
|
7天前
|
存储 Serverless C语言
【C语言基础考研向】11 gets函数与puts函数及str系列字符串操作函数
本文介绍了C语言中的`gets`和`puts`函数,`gets`用于从标准输入读取字符串直至换行符,并自动添加字符串结束标志`\0`。`puts`则用于向标准输出打印字符串并自动换行。此外,文章还详细讲解了`str`系列字符串操作函数,包括统计字符串长度的`strlen`、复制字符串的`strcpy`、比较字符串的`strcmp`以及拼接字符串的`strcat`。通过示例代码展示了这些函数的具体应用及注意事项。
|
10天前
|
存储 人工智能 C语言
C语言程序设计核心详解 第八章 指针超详细讲解_指针变量_二维数组指针_指向字符串指针
本文详细讲解了C语言中的指针,包括指针变量的定义与引用、指向数组及字符串的指针变量等。首先介绍了指针变量的基本概念和定义格式,随后通过多个示例展示了如何使用指针变量来操作普通变量、数组和字符串。文章还深入探讨了指向函数的指针变量以及指针数组的概念,并解释了空指针的意义和使用场景。通过丰富的代码示例和图形化展示,帮助读者更好地理解和掌握C语言中的指针知识。
|
16天前
|
C语言
C语言 字符串操作函数
本文档详细介绍了多个常用的字符串操作函数,包括 `strlen`、`strcpy`、`strncpy`、`strcat`、`strncat`、`strcmp`、`strncpy`、`sprintf`、`itoa`、`strchr`、`strspn`、`strcspn`、`strstr` 和 `strtok`。每个函数均提供了语法说明、参数解释、返回值描述及示例代码。此外,还给出了部分函数的自实现版本,帮助读者深入理解其工作原理。通过这些函数,可以轻松地进行字符串长度计算、复制、连接、比较等操作。
|
1月前
|
C语言
【C语言】字符串及其函数速览
【C语言】字符串及其函数速览
23 4
|
1月前
|
C语言
【C语言篇】字符和字符串以及内存函数详细介绍与模拟实现(下篇)
perror函数打印完参数部分的字符串后,再打印⼀个冒号和⼀个空格,再打印错误信息。
|
1月前
|
存储 安全 编译器
【C语言篇】字符和字符串以及内存函数的详细介绍与模拟实现(上篇)
当然可以用scanf和printf输入输出,这里在之前【C语言篇】scanf和printf万字超详细介绍(基本加拓展用法)已经讲过了,这里就不再赘述,主要介绍只针对字符的函数.
|
3月前
|
C语言
C语言学习笔记之初识字符串
C语言学习笔记之初识字符串
35 5
|
3月前
|
C语言
c语言左旋字符串问题(不同方法超详细解答)
c语言左旋字符串问题(不同方法超详细解答)
21 1