物联网技术之C语言字符串和字符串函数
本章重点
• 字符串的表示
• 字符串的输入与输出
• 字符串基本操作(获取长度、比较、查找)
• 获取命令行参数
要编写应用程序,怎样都绕不开字符串的使用,这是因为在人们的生活中大部分时间都在和各种各样的字符串打交道:读书读报、看电影买火车票都要阅读大量字符,追女友写情书更要书写大量字符串。本章将介绍在C语言中和字符串表示、输入/输出以及处理相关的知识点,尽管内容略显繁杂,但学起来还是不乏趣味的。本章的最后还将介绍如何使用格式化字符串漏洞进行攻击,大家可以亲身体验当“黑客”的感觉。
经过本章的学习,大家应熟练掌握C语言中字符串的使用和操作。
9.1 字符串表示
在汉语里,将几个字连起来就是一个字符串,例如本小节的标题“字符串表示”就是一个由五个汉字组成的字符串。下面学习在C语言程序中是如何表示字符串的。
9.1.1 定义字符数组
在深入学习字符串之前,先来了解字符串的前身——字符数组。
顾名思义,字符数组就是用来存放字符的数组。下面的语句定义了一个长度为10的字符数组:
char char_array[10];
下面的语句定义了一个长度为5的字符数组,并为其指定了初始值:
char char_array[5] = {'h', 'e', 'l', 'l', 'o'};
字符数组和其它数组没有什么区别,只是存储的元素类型为字符型而已。每个元素占用一个字节。下图展示了字符数组char_array中的元素分配情况:
图 91 字符数组char_array中的元素分配情况
想必大家学过数组的知识之后,完全能自己写出操作字符数组的程序。下面的例程使用一个循环输出字符数组中的每个字符,如例程9-1所示:
例程 91输出字符数组中的内容
1 #include <stdio.h>
1 int main()
2 {
3 char char_array[5] = {'h', 'e', 'l', 'l', 'o'};
4 int i;
5 for(i = 0; i < sizeof(char_array); ++i)
6 {
7 printf("%c", char_array[i]);
8 }
9 printf("\n");
10 }
程序的运行结果如图9-2所示:
图 92 输出字符数组中的内容
9.1.2 字符串的定义
利用初始化字符数组的方式来初始化字符串实在是太麻烦了。在C语言程序中可以直接使用一个字符串常量对字符串进行初始化,像下面这样:
char char_array[6] = {"hello"};
双引号之间的是一个字符串常量。这样声明时,会自动对字符串常量添加一个空字符'\0'作为字符串的结束标志,该标志总在最后一个字符之后。这意味着字符数组char_array的大小不是5个字节,而是6个字节。
上面的定义和下面的定义是等同的:
char char_array[6] = {'h', 'e', 'l', 'l', 'o', '\0'};
由定义数组的方法可知,还可以在声明时省略数组大小,让编译器自动确定数组的长度,像下面这样:
char char_array[] = {"hello"};
下面的程序展示了char_array的大小确实是六个字节,且最后一个字节为0,如例程9-2所示:
例程 92字符串的长度与字符数组长度之间的关系
1 #include <stdio.h>
11 int main()
12 {
13 char char_array[] = {"hello"};
14 int i;
15 printf("char_array 的大小是 %d 字节\n", sizeof(char_array));
16 for(i = 0; i < sizeof(char_array); ++i)
17 {
18 printf("char_array 的第 %d 位是 %d\n", i, char_array[i]);
19 }
20 }
程序运行的结果如图9-3所示:
图 93 字符串的长度与字符数组长度之间的关系
还可以用更简洁的方法来定义字符串——省略字符串常量前后的大括号,像下面这样:
char char_array[] = "hello";
这种方法和之前带有大括号的定义方式完全一样。这也是编写C语言程序时最常用的定义字符串的语句。
9.1.3 字符串与指针
就像“数组”一章中学过的那样,在C语言中,数组名可以当作指针使用,指针也可以被直接用来访问数组中的元素。字符型指针可以使用char*来定义,它既可以指向一个字符型变量,也可以指向一整个字符串——取决于程序员如何使用这个字符型指针。
下图解释了指向单个字符和指向一整个字符串的字符型指针之间的联系,如图9-4所示:
图 94 指向字符串的指针
图9-4中,字符指针chr既指向了字符'h',又指向了字符串“hello”,这是因为字符'h'位于字符串“hello”的起始处,由于chr指向的是字符串“hello”所在的内存地址的起始位置,因此它也是指向字符'h'的字符指针。
下面的例程演示了使用不同的代码分别访问同一个字符指针指向的字符和字符串的方法,如例程9-3所示:
例程 93字符串与指针
#include <stdio.h>
21 int main()
22 {
23 char* char_array = "hello";
24 char* chr = char_array;
25 printf("访问单个字符:%c\n", *chr);
26 printf("访问整个字符串:%s\n", chr);
27 }
程序的运行结果如图9-5所示:
图 95 字符串与指针
在例程9-3中,第六行使用取值操作符*从指针chr指向的地址取得了字符'h'并输出,这时是通过字符指针访问了单个字符;第七行则将整个字符指针直接传递给printf函数,由格式化输出参数%s输出整个字符串,这时则通过字符指针访问了整个字符串。
还可以使用字符指针遍历整个字符串中的每个字符,并进行相应的处理(比如输出)。由于C风格字符串总是以空字符'\0'结尾,因此在循环时没必要提前获取整个字符串的长度,只要每次循环开始之前判断当前位置是否为空字符即可。如果是空字符的话,则结束循环。如例程9-4所示:
例程 94 使用字符指针遍历字符串中的字符
1 #include <stdio.h>
28 int main()
29 {
30 char char_array[] = "Welcome to the C programming class!";
31 char *ptr;
32 printf("变换前的字符串如下:\n");
33 ptr = char_array;
34 while(*ptr != '\0')
35 {
36 printf("%c", *ptr);
37 ++(*ptr);
38 ++ptr;
39 }
40 printf("\n");
41 printf("变换后的字符串如下:\n");
42 ptr = char_array; /* 将指针 ptr 重置到 char_array 的开头 */
43 while(*ptr != '\0')
44 {
45 printf("%c", *ptr);
46 ++ptr;
47 }
48 printf("\n");
49 }
程序运行的结果如图9-6所示:
图 96 使用字符指针遍历字符串中的字符
上述例程中通过一个自增的字符指针ptr实现了对字符串char_array的访问和修改。循环开始之前,指针ptr被初始化为指向char_array的头部,如图9-7所示:
图 97 ptr被初始化至字符串开头
每次循环时(第11、12行),ptr指向的字符被加1,且ptr自身也被加1。例如第一次循环时,第一个字符'W'加一后变为'X',ptr加一后指向第二个字符'e',被修改的字符使用粗体表示,如图9-8所示:
图 98 第一次循环后的示意图
在最后一次循环完成之后,ptr指向整个字符串的最后一个字节——空字符,如图9-9所示:
图 99 ptr指向字符串末尾的空字符
由于这时*ptr为空字符,循环自动结束。循环的次数就是字符串的长度,也就是字符数组的长度减1。
多学一招:字符指针与字符串常量
例程9-5原来的例程只有第4行不同:这里使用一个指向字符串的字符指针替换了原来的字符数组。如例程9-5所示:
例程 95 使用字符串常量替换字符数组
1 #include <stdio.h>
50 int main()
51 {
52 char *char_array = "Welcome to the C programming class!";
53 char *ptr;
54 printf("变换前的字符串如下:\n");
55 ptr = char_array;
56 while(*ptr != '\0')
57 {
58 printf("%c", *ptr);
59 ++(*ptr);
60 ++ptr;
61 }
62 printf("\n");
63 printf("变换后的字符串如下:\n");
64 ptr = char_array; /* 将指针 ptr 重置到 char_array 的开头 */
65 while(*ptr != '\0')
66 {
67 printf("%c", *ptr);
68 ++ptr;
69 }
70 printf("\n");
71 }
程序的运行结果如图9-10所示:
图 910 程序异常退出
例程9-5在运行时,仅仅输出了第一个字符'W'之后就崩溃了,这是因为在声明时,使用字符串初始化的字符数组和字符指针是不一样的。编译器认为使用字符串初始化的字符数组仍然是一个正常数组,而一般而言,正常的数组应当是可修改的,因此编译器会将字符数组放在可修改的程序区域内;但使用字符串常量初始化字符指针时,实际上编译器会将字符串常量放在程序的常量区之中,然后将字符指针指向那个字符串常量的开头。由于程序的常量区是只读的,因此在大黄尝试通过++(*ptr)来修改第一个字符的值的时候,程序就发生了错误。要解决这个问题,只要第四行定义字符串时,使用字符数组来替换字符指针就可以了。
9.1.4 字符串与字符数组的区别
在各种编程语言中,字符串的地位都是十分重要的。C语言中并没有直接提供字符串这个特定类型,而是以用特殊的字符数组的形式来存储字符串。C语言对于字符串做了如下规定:以空字符'\0'结尾的字符数组被称作字符串。这种字符数组也被称为C风格字符串。
要将上一节中用到的字符数组转换成字符串,需要修改为下面的代码:
char char_array[] = {'h', 'e', 'l', 'l', 'o', '\0'};
注意到上面的语句中省略了char_array的大小。修改后,由于数组的末尾多了一个空字符,字符数组char_array的大小为6。但是这个字符串的长度为5——这是因为字符串的长度并不包括末尾空字符的所占的空间。
还可以在空字符后面添加其它字符,如下面的示例代码:
char char_array[] = {'h', 'e', 'l', 'l', 'o', '\0', 'w', 'o', 'r', 'l', 'd', '.'};
这个字符数组的大小为12,但由于空字符出现在字符数组的中间,因此它所标示的字符串仅仅是“hello”,对应的字符串长度是5。从字符数组的开头开始,第一个空字符就标志着字符串的结束。因此从长度上来说,字符串的长度至少比其所对应的字符数组的长度少一个字节,下面的代码将通过printf输出字符串,如例程9-6所示:
例程 96 使用printf输出字符串
1 #include <stdio.h>
72 int main()
73 {
74 char char_array[] = {'h', 'e', 'l', 'l', 'o', '\0', 'w', 'o', 'r', 'l', 'd', '.'};
75 printf("%s\n", char_array);
76 }
程序的运行结果如图9-11所示:
图 911 使用printf输出字符串
正如之前讲过的那样,printf输出的字符串遇到空字符就截止了。这是因为系统在输出时,在输出每一位之前都会检查是否为空字符,如果遇到了空字符,意味着到了字符串的末尾,系统就自动结束输出过程。
脚下留心:忘记了末尾的空字符会怎样?
定义了一个字符数组,却忘记在最后添加空字符了,如例程9-7所示:
例程 97 没有空字符结尾的字符串
1 #include <stdio.h>
77 int main()
78 {
79 char char_array[] = {'h', 'e', 'l', 'l', 'o'};
80 printf("%s\n", char_array);
81 }
程序的运行结果如图9-12所示:
图 912 没有空字符结尾的字符串
注意到输出时除了想要的“hello”之外,还多了一些奇怪的东西。这是因为printf在输出时没有遇到空字符,于是就继续输出“hello”后面的数据了,直到遇到第一个空字符'\0'。在内存中,字符数组char_array之后还有一些其它的数据,因此被一起输出了。要解决这个问题,只要在定义字符数组时写好末尾的空字符'\0'即可。
9.2 字符串输入
除了可以在声明变量时通过初始化的方式来定义字符串之外,还可以通过C语言提供的形形色色的字符串输入函数来获得字符串,常用的函数包括。
常见的字符串输入函数如下表所示:
表 91 C语言中的字符串输入函数
函数名 |
描述 |
gets |
从控制台读入用户输入的字符串 |
fgets |
从控制台或文件中读入用户输入的字符串 |
scanf |
根据一定的格式读入数据(包括字符串) |
9.2.1 gets函数
gets函数可以用来读入一个用户输入的字符串。其gets函数原型如下:
char* gets(char* str);
函数gets接受一个字符指针作为参数,这个指针应指向已经分配好空间的一个字符数组。
gets函数读入用户输入的字符串,直到用户回车为止,gets将把换行符之前的所有字符读进来(不包括换行符本身),在字符串的末尾添加一个空字符'\0'用来标记字符串的结束,最后将这个字符串的指针作为返回值返回;同时用户输入的换行符被丢弃。在下一次调用gets读取数据时,不会读入之前的换行符。
C语言并没有规定gets函数能接受字符串的最大长度,也不能先接受一个字符串,自动为其分配好空间,再将得到的字符串返回。这就要求开发者在调用gets之前,需要先声明一个有足够空间的字符数组。
例如要通过gets函数接收一个手机号码,不算国家代码的话,国内的手机号码最长只有11位,因此只要定义一个长度为12的字符数组就足够了。
下面的例程实现了接收用户输入的一个手机号码,再打印出来的功能,如例程9-8所示:
例程 98 使用gets函数读入手机号码
1 #include <stdio.h>
82 int main()
83 {
84 char phoneNumber[12];
85 printf("请输入手机号码:");
86 gets(phoneNumber);
87 printf("您的手机号码是 %s。\n", phoneNumber);
88 }
程序的运行结果如图9-13所示:
图 913 使用gets函数读入手机号码
当然也可以用malloc函数为字符数组分配空间。使用之后记得要调用free函数进行释放。如例程9-9所示:
例程 99 使用malloc为字符数组分配空间
1 #include <stdio.h>
89 #include <stdlib.h>
90 int main()
91 {
92 char* phoneNumber = (char*)malloc(12);
93 printf("请输入手机号码:");
94 gets(phoneNumber);
95 printf("您的手机号码是 %s。\n", phoneNumber);
96 free(phoneNumber); /* 使用之后要及时释放! */
97 }
程序的运行结果如图9-14所示:
图 914 使用gets函数读入手机号码
使用gets函数的关键在于预先分配足够的内存空间。如果分配的内存空间不足,就会导致gets向目标地址写入过多的数据,超出数组可以容纳的大小,从而使程序发生错误。这被称为缓冲区溢出。因此大家在创建字符数组时,应留下足够的余地。
此外需要注意的是,gets函数将传入的字符指针参数作为函数调用的返回值传出,这意味着实际上用户只能输入一次字符串。例如,下面的代码是错误的:
char* phoneNumber = (char*)malloc(12);
char* newPhoneNumber = gets(phoneNumber);
free(phoneNumber);
free(newPhoneNumber);
多学一招:fgets与gets的区别
函数gets的不足之处是不能限制用户输入数据的多少,就算目标字符数组中没有足够的空间,gets仍然会不分青红皂白地向里面塞数据,多余的字符则会覆盖其它数据,造成程序出错或崩溃等问题。函数fgets解决了这个问题:它允许用户指定最多能读入的字符的数量。这样就算用户输入过多的数据也不会溢出了。
fgets是专门为读写文件设计的函数,也可以用来直接读取用户的输入。它的具体使用方法可以参考“文件操作”一章中的讲解。
9.2.2 使用scanf函数读入指定长度的字符串
在“输入/输出”一章中已经学习过了scanf函数的基本用法。本小节将scanf函数和其它输入函数做一对比,并介绍使用scanf读入指定长度字符串的方法。
scanf函数和gets函数的最大不同在于,scanf函数是基于单词读入而不是基于整个字符串读入的。请回忆scanf读取的终止条件:是遇到第一个空白字符(包括空格、、制表符、换行符等等)为止,而gets函数则是在第一个换行符处停止读取。因此在使用上,gets比scanf要简单得多,当然功能上也要弱一些。
在返回值上,scanf返回的是成功读取的项目数量或者EOF,而gets返回的是传入的字符数组的指针。
scanf支持像fgets那样定义允许输入字符串的最大大小。不过由于scanf是基于单词进行读入的,因此它支持更细致的控制——可以对每一个格式字符串指定字符串的最大长度!指定字符串最大长度的格式字符串如下:
%<最大长度><原始控制字符>
例如下面的格式化字符串
%10s %10s %10s
将试图读入三个以空白字符分隔的字符串,每个字符串的最大长度为10。如果某个字符串的长度小于或等于10,那么它将被直接读入指定的字符数组中;否则这个字符串将被分割成两个或多个字符串加以处理。注意这里的“最大长度”是指字符串的长度,而不是对应的字符数组的长度。因此对应字符数组的长度应当至少为(字符串最大长度 + 1)。
例子9-10展示了使用scanf函数读入指定大小字符串的方法。例程9-10中读入用户输入的三个电话号码,每个电话号码的位数最长可以为18位,这是因为考虑了国内的“区号-电话号-分机号”的形式,用户可能输入类似“0751-31308842-5206”的号码,如例程9-10所示:
例程 910 使用scanf读入多个指定长度的字符串
1 #include <stdio.h>
98 int main()
99 {
100 char number1[19], number2[19], number3[19];
101 printf("请输入三个电话号码,每个号码最长18位:\n");
102 scanf("%18s %18s %18s", number1, number2, number3);
103 printf("您输入的第一个电话号码为 %s\n", number1);
104 printf("您输入的第二个电话号码为 %s\n", number2);
105 printf("您输入的第三个电话号码为 %s\n", number3);
106 }
程序的运行结果如图9-15所示:
图 915 使用scanf读入多个指定长度的字符串
第一次运行时,输入的每个电话号码都小于最大长度18,如图9-15所示。
第二次运行时,输入的第二个电话号码的长度超过了最大长度18,因此只有前18个字符被读取到第二个字符串中;下一个字符串从上一个电话号码读取结束的地方继续读取;最后一个电话号码就被舍弃了。结果如图9-16所示:
图 916 使用scanf读入多个指定长度的字符串
第三次运行时,输入的四个电话号码,显然最后一个被舍弃了,如图9-17所示:
图 917 使用scanf读入多个指定长度的字符串
与gets或者fgets相比,scanf提供了对获取用户输入更细致的控制方式,但是由此也会对性能有一定的影响,同时使用上也略麻烦一些。scanf函数主要用来对以某种标准形式输入的混合字符串进行转换,能保证输入的数据都符合对应的类型;而gets/fgets函数一次读入一整行字符串,必须由程序员对用户的输入进行错误检查、类型转换等工作。
9.3 字符串输出
上一节中介绍了字符串输入的相关函数,与之对应,C语言还提供了字符串输出相关的函数,如 puts、fputs和printf等。本节将介绍puts函数的用法。
puts函数的作用是输出一整行字符串,并在最后添加换行符'\n'。与其相比,printf并不会在字符串的末尾添加'\n'。
puts函数的原型如下:
int puts(const char* str);
puts只接受一个参数,就是要输出的字符串的指针。调用成功后返回一个正数,如果发生错误的话返回EOF。
下面的例程演示了如何使用puts函数输出字符串,如例程9-11所示:
例程 911 gets/puts函数示例
#include <stdio.h>
107 int main()
108 {
109 char buf[100];
110 puts("请输入一个字符串:");
111 gets(buf);
112 puts("您输入的是:\n");
113 puts(buf);
114 }
程序的运行结果如图9-18所示:
图 918 gets/puts函数示例
注意在例程9-11中,提示语“您输入的是:”后面有两个换行符。一个换行符来自于例程第7行中的换行符'\n',另一个则是因为使用puts函数进行输出,会自动添加一个额外的换行符。
多学一招:fputs、printf与puts的区别
fputs与puts的区别在于,fputs函数不会为为输出的字符串自动添加换行符'\n'作为结尾。fputs是专门为文件设计的字符串输出函数,“文件操作”一章中将对该函数做详细的讲解。
printf与puts函数的区别在于,它不会一次输出一整行字符串,而是根据格式化字符串输出一个个“单词”。由于进行了额外的数据格式化工作,因此在性能上,printf比puts要慢(尽管一般情况下并不明显)。但是printf可以直接输出各种不同类型的数据,从这个角度来说,它是比puts更便于使用的。
9.4 操作字符串
C语言提供了很多操作字符串的库函数,用来进行字符串比较、查找、连接、转换等功能。本节将对这些函数进行一一介绍。这些字符串操作函数的声明都位于头文件string.h中,因此在使用之前,需要在程序中引用string.h,具体示例如下:
#include <string.h>
9.4.1 获取字符串的长度
C语言中可以使用strlen函数获取字符串的长度,其原型如下:
size_t strlen(const char* str);
函数strlen接受待测量的字符串作为参数,并返回字符串的长度。一般而言,返回类型size_t在32位程序中被定义为unsigned int,即无符号整数,因此要特别注意size_t类型的值是不会小于0的。
注意strlen函数得到的是字符串的长度,是不包括末尾的空字符'\0'的。
下面的例程演示了如何实现获取用户输入字符串长度的功能,如例程9-12所示:
例程 912 获取用户输入字符串的长度
#include <stdio.h>
115 #include <string.h>
116 int main()
117 {
118 char buffer[1024];
119 size_t length;
120 printf("请输入待测量的字符串:");
121 gets(buffer);
122 length = strlen(buffer);
123 printf("您输入的字符串长度为 %d。\n", length);
124 }
程序的运行结果如图9-19所示:
图 919 获取用户输入字符串的长度
9.4.2 字符串比较
在C语言中要比较两个字符串char arr1[]和char arr2[],大家不能直接写
arr1 == arr2
这是因为上面的语句实际上是在比较两个字符指针的值是否相等。由于那是两个不同的字符数组,因此上述语句总是返回false。
在C语言中,用于字符串比较的函数主要有strcmp、strncmp、stricmp与strnicmp这四个,下面详细介绍前两个函数。
1、函数strcmp
在C语言中,函数strcmp用于比较两个字符串的内容是否相等。其函数原型如下所示:
int strcmp(const char* str1, const str* str2);
如果两个字符串不完全相同,那么函数strcmp返回一个非零值;反之如果两个字符串相同,strcmp则返回0。只要判断strcmp的返回值就可以知道两个字符串是否相等了。
下面的登录程序演示了如何使用函数strcmp来比较两个字符。
例程 913 登录演示程序
#include <stdio.h>
125 #include <string.h>
126 int main()
127 {
128 char username[100];
129 char password[100];
130 printf("登录\n");
131 printf("请输入用户名:");
132 gets(username);
133 printf("请输入密码:");
134 gets(password);
135 if(!strcmp(password, "ILoveC"))
136 {
137 printf("用户 %s 登录成功!\n", username);
138 }
139 else
140 {
141 printf("登录失败,请检查用户名或密码是否正确输入。\n");
142 }
143 }
程序的运行结果如图9-20所示:
图 920 登录演示程序
例程9-13中使用用户输入的密码和字符串“ILoveC”进行比较,如果正确的话则提示登录成功,否则提示用户登录失败。
脚下留心:strcmp不能用来比较字符
注意函数strcmp只能接受字符指针作为参数,并不接受单个字符。如果传入的是某个字符(例如'A'),那么'A'会被当作指针来使用,从而导致程序报错。
要比较两个字符char_a和char_b是否相等,直接使用char_a == char_b即可。
2、函数strncmp
函数strncmp可以用来比较前n个字符是否完全一致。原型如下所示:
int strncmp(const char* str1, const str* str2, size_t n);
参数n表示要比较的最大字符个数。当然如果字符串str1和str2的长度都小于n,那么就相当于直接调用strcmp进行字符串比较了。
例如要比较字符串str1和str2的前7个字符是否相同,可以使用如下语句:
strncmp(str1, str2, 7);
9.4.3 字符串查找
针对字符串的查找操作,C语言提供了如下三个函数:strchr、strrchr和strstr。本小节将详细介绍这三个函数的使用方法。
1、函数strchr
函数strchr用来查找在一个字符串中某个字符第一次出现的位置。其原型如下所示:
char* strchr(const char* str, int c);
参数str是要扫描的字符串,c是要找的字符。如果字符串str中包含字符c,那么strchr返回一个指向字符c第一次出现的位置的字符指针;否则返回空指针,意味着字符串str中不包含字符c。
下面的例程演示了在字符串中统计某个字符出现次数的方法。基本思路是反复调用strchr来查找字符,找到后就将字符串指针向后移一位,直到找不到字符为止。
例程 914 统计字符在字符串中的出现次数
#include <stdio.h>
144 #include <string.h>
145 int get_count(char* str, char c)
146 {
148 int count = 0;
150 char* ptr = str;
151 while((ptr = strchr(ptr, c)) != NULL)
152 {
153 ++ptr;
154 ++count;
155 }
156 return count;
157 }
158 int main()
159 {
160 char str[1024];
161 int c;
162 int count;
163 printf("请输入要扫描的字符串:");
164 gets(str);
165 printf("请输入要查找的字符:");
166 c = getchar();
167 fflush(stdin);
168 if(c != EOF)
169 {
170 count = get_count(str, (char)c);
171 printf("字符 %c 在字符串中出现了 %d 次。\n", c, count);
172 }
173 }
程序的运行结果如图9-21所示:
2、函数strrchr
函数strrchr的原型如下:
char* strrchr(const char* str, int c);
与函数strchr相比,函数strrchr的功能基本相同,只不过得到的是字符c在字符串str中最后一次出现的位置。
3、函数strstr
上面两个函数都只能搜索字符串中的单个字符,要想在字符串中搜索另一个字符串该怎么办呢?函数strstr可以满足这个需求,其定义如下:
char *strstr(const char *haystack, const char *needle);
参数haystack是被扫描的字符串,needle是要查找的字符串。如果在字符串haystack中找到了字符串haystack,则返回这个字符串的指针;否则返回空指针。
下面的例程实现了在一个段落中查找某个单词的功能,如例程9-15所示:
例程 915 在段落中查找一个单词
#include <stdio.h>
174 #include <string.h>
175 int main()
176 {
177 char str[10240];
178 char word[1024];
179 char* ptr;
180 printf("请输入要扫描的段落:");
181 gets(str);
182 printf("请输入要查找的单词:");
183 gets(word);
184 ptr = strstr(str, word);
185 if(ptr == NULL)
186 {
187 printf("段落中不包含单词 %s。\n", word);
188 }
189 else
190 {
191 if(strlen(ptr) >= 20)
192 {
193 ptr[20] = 0;
194 }
195 printf("单词出现在 %s 附近。\n", ptr);
196 }
197 }
程序的运行结果如图9-22所示:
例程中第19至22行实现了输出要查找单词所在位置附近的文本的功能,最多输出20个字符。
9.4.4 字符串连接
C语言为连接字符串的操作提供了两个函数,分别是strcat和strncat。下面用实例来介绍这两个函数的使用。
1、函数strcat
函数strcat用来实现字符串的连接:即将一个字符串接到另一个字符串的后面。strcat的原型如下所示:
chat* strcat(char* dest, const char* src);
这条语句的含义是将指针src指向的字符串接到指针dest指向的字符串之后。调用之前需要开发者保证dest对应的字符数组中有足够的空间来容纳连接之后的字符串,否则会造成缓冲区溢出的问题。
下面的例程实现了将电话号码和区号相连,并输出的功能,如例程9-16所示:
例程 916 连接区号和电话号码并输出
#include <stdio.h>
198 #include <string.h>
199 int main()
200 {
202 char areaNumber[5];
204 char phoneNumber[12];
205 int input;
207 char extraNumber[5];
211 char buffer[22] = {0};
212 printf("请输入区号:");
213 gets(areaNumber);
214 printf("请输入电话号码:");
215 gets(phoneNumber);
216 printf("有分机号吗?(y/n)");
217 input = getchar();
220 fflush(stdin);
221 if(input == 'y')
222 {
223 printf("请输入分机号:");
224 gets(extraNumber);
225 strcat(buffer, areaNumber);
226 strcat(buffer, "-");
227 strcat(buffer, phoneNumber);
228 strcat(buffer, "-");
229 strcat(buffer, extraNumber);
230 }
231 else
232 {
233 strcat(buffer, areaNumber);
234 strcat(buffer, "-");
235 strcat(buffer, phoneNumber);
236 }
237 printf("您的电话号码是 %s。\n", buffer);
238 }
程序的运行结果如图9-23所示:
图 923 连接区号和电话号码并输出
2、函数strncat
strcat函数不检查第一个字符数组是否有足够的大小来容纳第二个字符串,因此当连接后的字符串长度超出了目标字符数组的大小时,就会出现缓冲区溢出的问题。这是就可以使用C语言提供的strncat函数来解决此问题。函数strncat的原型如下:
char* strncat(char* dest, const char* src, size_t n);
strncat除了接收两个字符数组src和dest之外,还接受第三个参数n。n表示最多从src指向的字符数组中取多少个字符连接到dest指向的字符串之后。这样开发者就可以控制要连接的字符串的总长度,使其不要超过目的字符数组的长度。
下面的例程演示了使用strncat进行字符串连接的方法,如例程9-17所示:
例程 917 使用strncat连接字符串
#include <stdio.h>
239 #include <string.h>
240 int main()
241 {
242 char buf1[30];
243 char buf2[30];
244 printf("请输入第一个字符串:");
245 gets(buf1);
246 printf("请输入第二个字符串:");
247 gets(buf2);
248 strncat(buf1, buf2, sizeof(buf1) - 1 - strlen(buf1));
249 printf("连接后的字符串是 %s。\n", buf1);
250 }
程序的运行结果如图9-24所示:
图 924 使用strncat连接字符串
程序中第11行使用sizeof(buf1) - 1 - strlen(buf1)来计算字符数组buf1可容纳最多多少字符。
9.4.5 字符串复制
C语言中,字符串的复制可以使用函数strcpy来实现,该函数的原型如下:
char* strcpy(char* dest, const char* src);
注意dest和src都不一定是字符串的开头,而可以是字符串中任意一个位置。例如对于两个字符串char a[10] = "ABCDE" 和char b[] = "abcde",下面的语句
strcpy(a, b + 3);
9.4.6 字符与字符串的转换
C风格字符串实际上是字符数组,而C语言中的字符则是一种基本数据类型。因此在字符和字符串之间进行转换是很容易的。
将字符转换成字符串需要经过两个步骤:
• 创建一个长度为2的字符数组;
• 将第一个元素设置为对应的字符,第二个元素设置为空字符。
例如,有一个字符型变量char a = 'A',若想将该变量转换为对应的字符串,则可以使用下面的语句:
char a_str[2] = {a, '\0'};
或者
char a_str[2];
a_str[0] = a;
a_str[1] = '\0';
要将长度为一的字符串转换为字符就更简单了,只要将字符串的第一个字符赋给字符型变量即可。
9.4.7 数字与字符串的转换
数字与字符串之间的转换包括两种,一是将字符串转换为整数,二是将整数转换为对应的字符串。前者可以通过函数atoi完成,后者可以通过函数sprintf来实现。本节将介绍数字与字符串的具体转换操作,并额外讲解非标准函数itoa的使用。
1、将字符串转换为整数
函数atoi可以将一个数字字符串转换为对应的十进制数。atoi的原型如下:
int atoi(const char* str);
atoi接受一个数字字符串作为输入,返回转换后的十进制整数。如果转换失败,则返回0。
atoi的声明位于stdlib.h中,所以需要引用头文件stdlib.h,像这样:
#include <stdlib.h>
下面的例程演示了如何将字符串转换为整数,如例程9-18所示:
例程 918 使用atoi将字符串转换为整数
#include <stdio.h>
251 #include <stdlib.h>
252 int main()
253 {
254 char buf[20];
255 int result;
256 printf("请输入待转换的十进制数:");
257 gets(buf);
258 result = atoi(buf);
259 printf("转换结果是 %d。\n", result);
260 }
程序的运行结果如图9-26所示:
图 926 使用atoi将字符串转换为整数
试试输入一个任意的字符串:
图 927 使用atoi将字符串转换为整数
2、将整数转换为字符串
可以使用格式化字符串函数sprintf将一个整数输出为字符串。sprintf和格式化输出函数使用方法类似,都可以根据格式字符串将不同类型的表达式进行处理,从而得到格式化之后的字符串。只不过printf的输出目标是屏幕,而sprintf将把字符串输出到指定的字符数组中。与printf相比,sprintf将目标字符数组作为第一个参数,格式化字符串作为第二个参数,然后是要输出的各个表达式。
下面的语句就可以完成将整数100转换为十进制表示法下的字符串,存入字符数组str中的功能。
sprintf(str, "%d", 100);
多学一招:使用函数itoa将整数转换为字符串
VS提供了一个不在C语言标准中的函数itoa,用来将一个整数转换为不同进制下的字符串。函数itoa的原型如下:
char* itoa(int val, char* dst, int radix);
第一个参数是待转换的数,第二个参数是目标字符数组,最后是要转换的进制。
下面的例程将用户输入的数转换为八进制、十进制和十六进制下的表示,如例程9-19所示:
例程 919 使用itoa将整数转换为字符串
#include <stdio.h>
261 #include <stdlib.h>
262 int main()
263 {
264 char buf[20];
266 char number_8[20];
268 char number_10[20];
270 char number_16[20];
271 int val;
272 printf("请输入待转换的数:");
273 gets(buf);
274 val = atoi(buf);
276 itoa(val, number_8, 8);
277 itoa(val, number_10, 10);
278 itoa(val, number_16, 16);
279 printf("八进制下为 %s\n", number_8);
280 printf("十进制下为 %s\n", number_10);
281 printf("十六进制下为 %s\n", number_16);
282 }
程序的运行结果如图9-28所示:
图 928 使用itoa将整数转换为字符串
因为itoa不是C语言标准中规定的函数,因此在某些平台(比如Linux)上是无法使用的。届时只要换用sprintf即可。
9.5 通过命令行传递参数
大家写过的程序都是控制台应用程序。控制台应用程序是指直接运行在控制台中的程序,没有常见的图形用户界面(Graphic user interface,简称GUI)。在控制台中,用户输入用来运行程序的命令。例如现在有一个程序的文件名为atoi.exe,那么只要输入atoi.exe(或者省略掉“.exe”,直接输入atoi)即可直接执行程序。控制台环境又被称为命令行环境,如图9-29所示:
图 929 Windows7的控制台环境
命令行环境下,用户可以通过为程序提供命令行参数来向程序中传递信息,例如像下面这样:
atoi.exe a b c
就是在运行程序时提供了三个额外的参数“a”、“b”和“c”。这些参数可以被C语言程序以字符串的形式获取。
C语言程序可以通过主函数的两个参数argc和argv来获取程序运行时的参数。此时主函数的声明如下:
int main(int argc, char* argv[]);
argc表示当前一共有多少个参数。一般而言,程序运行时至少有一个参数,即第一个参数保存了当前程序的路径。从第二个参数开始才是用户提供的自定义参数。argv则是一个字符串数组,从argv[0]至argv[argc - 1]分别指向每个参数。
在上面的例子中,argc和argv的值如表9-2所示:
表 92 上例中argc和argv的值
变量 |
值 |
变量类型 |
argc |
4 |
int |
argv[0] |
….\atoi.exe (路径省略) |
char* |
argv[1] |
"a" |
char* |
argv[2] |
"b" |
char* |
argv[3] |
"c" |
char* |
下面的例程演示了如何将例程itoa转换成基于命令行的版本,如例程9-20所示:
例程 920 命令行版本的进制转换程序
#include <stdio.h>
283 #include <string.h>
284 #include <stdlib.h>
285 int main(int argc, char* argv[])
286 {
287 char *buf;
289 char number_8[20];
291 char number_10[20];
293 char number_16[20];
294 int val;
295 if(argc != 2)
296 {
297 printf("用法:%s 待转换的整数\n", strrchr(argv[0], '\\') + 1);
298 }
299 else
300 {
301 buf = argv[1];
302 val = atoi(buf);
303 /* 使用 itoa 进行转换 */
304 itoa(val, number_8, 8);
305 itoa(val, number_10, 10);
306 itoa(val, number_16, 16);
307 printf("八进制下为 %s\n", number_8);
308 printf("十进制下为 %s\n", number_10);
309 printf("十六进制下为 %s\n", number_16);
310 }
311 }
要运行这个程序,大家需要先编译,然后到程序的目录下运行。
itoa.exe <要转换的数>
程序的运行结果如图9-30所示:
图 930 命令行版本的进制转换程序
第14行检查了用户是否输入了一个参数,如果没有参数或有太多参数,则将程序的正确用法打印出来(注意第一个参数是程序的路径,因此argc为2的时候意味着用户输入了一个参数)。第16行中用到了函数strrchr,表示从字符串中查找字符(第二个参数)最后一次出现的位置,并返回相应的指针。
在Visual Studio中调试时该如何设置命令行参数呢?
请在“解决方案资源管理器”子窗体的相应工程上点击右键,选择“属性”以打开工程属性页。在“配置属性”->“调试”中可以看到“命令参数”一栏,如下图所示:
图 931 在VS中设置命令参数
只要在“命令参数”一栏中填入相应的参数就可以了。
9.6 格式化字符串攻击简介
格式化字符串攻击漏洞是程序安全漏洞的一种。所谓“程序漏洞”,是指应用程序或操作系统在逻辑、设计或实现上的缺陷或错误。程序漏洞往往可以被攻击者利用,从而导致一系列严重的问题,例如使目标程序崩溃、让攻击者获取管理员权限、安装恶意软件或木马、控制整台电脑、窃取机密信息、甚至入侵整个内部网络等等。
很大一部分安全漏洞都是由于错误的代码导致的,格式化字符串攻击漏洞就是这样的一个漏洞。该漏洞最早出现在1999年六月,起初没有人注意到它的危害,直到2000年时才被发现可以用来进行攻击,进而危害程序和系统的安全。后来随着格式化字符串攻击方法越来越多、影响到的知名程序越来越多,才让人们最终重视起来。现在几乎所有的现代C语言编译器都会检查程序中是否有可能存在格式化字符串漏洞的代码,如果有的话,会给出相应的警告。
本小节的内容可能略为艰深难懂,如果大家无法理解的话可以先粗略阅读一下,只要了解如何在编程时避免留下格式化字符串攻击的漏洞即可。
9.6.1 格式化字符串简单的信息泄露
printf是程序员常用的输出函数,第一个参数是格式化字符串。如果仅仅输出一个字符串str的话,应该这样写:
printf("%s", str);
或者
printf(str);
请大家考虑一下,这两种写法等价么?
其实这两条语句并不等价。因为如果字符串str中有类似“%d”或“%x”这样的格式化字符,就会被printf处理掉,输出的结果和str原始的内容就不一致了。
不过还是有很多程序员习惯于写“printf(str)”,这是因为这样可以少打好几个字符。如果str是一个常量字符串、且里面没有任何格式化字符的话还没什么问题,但如果str中包含用户的输入,就会带来大问题。例如格式化字符串中包含“%c”的话,就会把当时保存在栈中的内容输出出来。这就是简单的信息泄露。详情请参见后两节的示例。
9.6.2 格式化字符串攻击示例:信息泄露
将任意的字符串作为格式化字符串使用会带来很大问题,尤其是格式化字符串可以被用户控制时,问题就更严重了。恶意用户可以轻松操作格式化字符串,从而完成攻击。请大家考虑下面的登录验证程序,如例程9-21所示:
例程 921 带有格式化字符串攻击漏洞的示例登录程序
请使用Release模式编译并运行程序。程序运行后,会要求用户输入用户名,程序的运行结果如图9-32所示:
#include <stdio.h>
312 #include <string.h>
313 int main()
314 {
315 /* 用户输入的密码保存在这里 */
316 char password[50];
317 /* 正确的密码在这里,登录的用户是不知道的 */
318 char realPassword[] = "YouDontKnowThisPassword";
319 /* 用户输入的用户名保存在这里 */
320 char username[200];
321 /* 登录循环,直到登录成功后才退出循环 */
322 while(1)
323 {
324 printf("请输入用户名:");
325 gets(username);
326 printf("欢迎 ");
327 /* 直接把用户名当作格式化字符串使用,导致了问题 */
328 printf(username);
329 printf("!\n");
330 printf("请输入密码:");
331 gets(password);
332 if(!strcmp(password, realPassword))
333 {
334 printf("登录成功!\n");
335 break;
336 }
337 else
338 {
339 printf("登录失败。\n");
340 }
341 }
342 }
图 932 带有格式化字符串攻击漏洞的示例登录程序
先试着输入“%c %d %x”,程序的运行结果如图9-33所示:
图 933 带有格式化字符串攻击漏洞的示例登录程序
大家可以看到,程序成功地打印出了几个值,这几个值都是来自栈上的。这意味着可以使用这个漏洞来读取栈上的信息了!
读取栈上的信息有什么作用呢?很简单,例程中第八行存储的是正确的密码,理论上除了真正掌握密码的那个用户之外,其它用户应该不知道正确的密码是什么,因此无法正常登录。可是如果用户可以任意读取栈上的值,不就能把正确的密码读出来了么?
下面就试着把栈上的数据打印出来,直接用20个“%x ”作为用户名(在每个“%x”之后加一个空格是为了分隔读出的每四个字节的数据)。程序的运行结果如图9-35所示:
图 935 带有格式化字符串攻击漏洞的示例登录程序
读出的数据是十六进制数,可以用下面的小程序Hex2Char将其逐个转换成可读的字符,如例程9-22所示:
例程 922 Hex2Char:将十六进制数转换成对应的字符
#include <stdio.h>
343 int main()
344 {
345 char buf[1024];
346 /* 保存读入的数 */
347 int val;
348 printf("请输入待转换的十六进制数,以空格分隔
349 while(scanf("%x", &val) != 0)
350 {
351 printf("%c%c%c%c\n", val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
352 }
353 }
运行Hex2Char,输入登录程序打印出的一系列十六进制数,程序的运行结果如图9-36所示:
图 936 将十六进制数逐个转换成对应的字符
可以看见本来应该保密的密码被当作用户名打印出来了!对于攻击者来说,只要输入刚刚打印出来的密码就能正常登录了,程序的运行结果如图9-37所示:
图 937 利用泄露的密码成功登录
在例程9-22中,格式化字符串漏洞带来的问题是信息泄露:因为这个漏洞,对外界保密的登录密码被泄露出来了。
9.6.3 格式化字符串攻击示例:数据篡改
大家可能对此不屑一顾:只要保证用来比较的密码不在栈上不就可以了么?可以将密码保存在使用malloc申请的空间中,那么密码就会在堆里面了。堆和栈离得非常远,那样攻击者就没办法读出密码了对吧?这样问题不就解决了?
没这么简单!格式化字符串攻击不仅可以用来读出数据,还可以用来写入数据。这是因为格式化字符串中有一个特殊的串“%n”,表示“将该点之前输出的字符个数写入对应的地址中”。例如下例代码:
int count;
printf("ABCDEFG%n", &count);
将向变量count中写入7,这是因为在%n之前,printf将要输出7个字符“ABCDEFG”。
这个功能是为了方便程序员更好地处理控制台下的输出而设计的,但是在存在格式化字符串攻击漏洞的情形下,“%n”可以被用来向某个内存地址写入数据,攻击者就可以篡改内存中的数据了。
由于“%n”十分危险,因此默认情况下,VS是禁用这个格式串的。在VS中使用之前需要先通过。
_set_printf_count_output(1);
语句来启用VS C语言运行库内置的“%n”支持。下面是经过修改的登录验证程序,如例程9-23所示:
例程 923 修改后的登录验证程序
#include <stdio.h>
354 #include <string.h>
355 #include <stdlib.h>
356 static char PASSWORD[] = "YouDontKnowThisPassword";
357 int main()
358 {
359 /* 用户输入的密码保存在这里 */
360 char password[50];
361 /* 正确的密码在这里,登录的用户是不知道的 */
362 char *realPassword = (char*)malloc(sizeof(PASSWORD));
363 /* 用户输入的用户名保存在这里 */
364 char username[200];
365 /* 登录成功标记 */
366 int loginSuccessful;
367 int *loginSuccessfulPtr = &loginSuccessful;
368 /* 启用格式化字符串 %n 支持 */
369 _set_printf_count_output(1);
370 /* 将密码从静态区复制到之前申请的内存区域中 */
371 strcpy(realPassword, PASSWORD);
372 /* 登录循环,直到登录成功后才退出循环 */
373 while(1)
374 {
375 printf("请输入用户名:");
376 gets(username);
377 printf("请输入密码:");
378 gets(password);
379 /* 如果密码匹配,则 loginSuccessful = 1
380 * 否则 loginSuccessful = 0
381 */
382 loginSuccessful = !strcmp(password, realPassword);
383 printf("欢迎 ");
384 /* 直接把用户名当作格式化字符串使用,导致了问题 */
385 printf(username);
386 printf("!\n");
387 if(loginSuccessful)
388 {
389 /* 输出时故意输出 loginSuccessful 和 loginSuccessfulPtr
390 * 变量的地址,这样该变量就不会被编译器优化掉了 */
391 printf("登录成功!\n&loginSuccessful = %x, &loginSuccessfulPtr = %x\n", &loginSuccessful, &loginSuccessfulPtr);
392 break;
393 }
394 else
395 {
396 printf("登录失败。\n&loginSuccessful = %x, &loginSuccessfulPtr = %x\n", &loginSuccessful, &loginSuccessfulPtr);
397 }
398 }
399 }
使用Release编译并运行程序,程序的运行结果如图9-38所示:
图 938 修改后的登录验证程序
例程9-23和修改前最大的不同是,先进行密码的比较,将比较的结果存入一个局部变量中,然后调用带有格式化字符串攻击漏洞的printf函数,最后根据之前存储的比较结果来判断登录是否成功。
在执行到程序的第33行printf(username)一句时,栈中的数据分布如下图所示:
图 939 调用printf前栈上数据的分布
由于判断是否登录成功时(例程第35行)使用的是(loginSuccessful),即只要loginSuccessful不为0就可以成功登录,因此只要将loginSuccessful写成非0值即可。
根据上图可知,只要用“%x%x%x%x%n”作为用户名即可向loginSuccessful中写入一个非零的数,从而绕过验证。程序的运行结果如图9-40所示:
图 940 利用格式化字符串攻击漏洞成功绕过验证
在本例中,格式化字符串漏洞带来的问题是数据篡改:这个漏洞的存在使得攻击者可以篡改内存空间中的数据,从而干扰、影响了程序的正常运行逻辑。
多学一招:避免格式化字符串攻击漏洞
由上述分析可知,要避免格式化字符串攻击漏洞其实很简单,只要保证在使用类似“printf(str)”的语句时,用户不能控制str中的内容即可。更进一步,应当永远使用
printf(<格式化字符串>, 参数1, 参数2, …);
这种形式,而不是将待输出的字符串直接放在“格式化字符串”的位置加以输出。
如果互联网上的HTTP服务端、FTP服务端甚至金融服务的服务器程序上存在格式化字符串攻击漏洞,那它们就有大麻烦了。这并不是危言耸听,而是真真切切曾经发生过的事件。因此现在的程序员不仅应当又快又好地写出代码,还应随时注意代码中的安全问题。
9.7 本章小结
本章介绍了C语言中使用C风格字符串的方法,包括字符串的定义、输入和输出,以及有关字符串的各种操作(获取长度、比较、查找、连接、复制等等)。最后为拓宽视野,还讲解了常见的格式化字符串攻击的原理。希望大家在学习完本章之后,能够牢固掌握有关字符串的应用和各种操作。
- 点赞
- 收藏
- 关注作者
评论(0)