对于 printf 函数,相信大家并不陌生。之所以称它为格式化输出函数,关键就是该函数可以按用户指定的格式,把指定的数据显示到显示器屏幕上。该函数原型的一般格式如下:
很显然,与其他库函数不同的是,printf 函数是一个“可变参数函数”(即函数参数的个数是可变的)。确切地说,是其输出参数的个数是可变的,且每一个输出参数的输出格式都有对应的格式说明符与之对应,从格式串的左端第 1 个格式说明符对应第 1 个输出参数,第 2 个格式说明符对应第 2 个输出参数,第 3 个格式说明符对应第 3 个输出参数,以此类推。其中,格式说明符的一般形式如下(方括号 [] 中的项为可选项):
它用以表示输出数据的类型,如表 1 所示。
符 号 | 类 型 | 说 明 | 示 例 | 结 果 |
---|---|---|---|---|
% | 无 | 输出字符“%”本身 | pnntf("%%"); | % |
d、i | int | 以整型输出 | printf("%i,%d", 100,100); | 100,100 |
u | unsigned int | 以无符号整型输出 | printf( "%u,%u",100u,100); | 100,100 |
o | unsigned int | 以八进制无符号整S输出 | printf( "%o”,100); | 144 |
x | unsigned int | 以十六进制小写输出 | printf("%x",11); | b |
X | unsigned int | 以十六制大写输出 | printf("%X",11); | B |
除表 1 所示的类型符之外,还有一个比较特殊与另类的类型符“%n”,当在格式化字符串中碰到“%n”时,在“%n”之前输出的字符个数会保存到下一个参数里。例如,下面的示例代码演示了如何获取在两个格式化的数字之间空间的偏量:
int main(void)
{
int pos=0;
int x = 123;
int y = 456;
printf("%d%n%d\n", x, &pos, y);
printf("pos=%d\n", pos);
return 0;
}
很显然,上面代码中的 pos 将输出 3,即“123”的长度,运行结果为:
123456
pos=3
这里需要特别注意,“%n”返回的是应该被输出的字符数目,而不是实际输出的字符数目。当把一个字符串格式化输出到一个定长缓冲区内时,输出字符串可能被截短。不考虑截短的影响,“%n”格式表示如果不被截短的偏量值(输出字符数目)。看下面的示例代码:
int main(void)
{
char buf[20];
int pos=0;
int x = 0;
snprintf(buf, sizeof(buf), "%.100d%n", x, &pos);
printf("pos=%d\n", pos);
return 0;
}
很显然,上面的代码会输出 100,而不是 20。
由此可见,相对于“%d”“%x”“%s”等,“%n”的显著不同之处就是“%n”会改变变量的值,这也就是格式化字符串攻击的爆破点,如下面的示例代码所示:
char daddr[16];
int main(void)
{
char buf[100];
int x=1;
memset(daddr,'/0',16);
printf("前X: %d/%#x (%p)\n", x, x, &x);
strncpy(daddr,"AAAAAAA%n",9);
snprintf(buf,sizeof(buf),daddr);
buf[sizeof(buf) - 1] = 0;
printf("后X: %d/%#x (%p)\n",x, x, &x);
return 0;
}
在上面的代码中,x 将被从 1 修改成 7,其运行结果为:
前X: 1/0x1 (0061FEA8)
后X: 7/0x7 (0061FEA8)
之所以会出现这样的结果,是因为程序在调用 snprintf 函数之前,首先调用了 printf 函数,而这时 printf 函数的 &x 参数在 main 函数的堆栈内存中留下了 &x 的内存残像。当调用 snprintf 时,系统本来只给 snprintf 准备了 3 个参数,但是由于格式化字符串攻击原因,使得 snprinf 认为应该有 4 个参数传给它,这样 snprintf 就私自把 &x 的内存残像作为第 4 个参数读走,而 snprintf 所谓的第 4 个参数对应的就是“%n”,于是 snprintf 就成功修改了变量 x 的值。这也就是最常见的使用 Linux 函数调用时的内存残像来实现格式化字符串攻击的方法之一,所以在使用的时候一定要注意。
它用于规定输出格式,如表 2 所示。
符号 | 说 明 |
---|---|
(空白) | 右对齐,左边填充 0 和空格 |
(空格) | 输出值为正时加上空格,为负时加上负号 |
- | 输出结果为左对齐(默认为右对齐),边填空格(如果存在表格最后一行介绍的0,那么将忽略0) |
+ | 在数字前增加符号“+”(正号)或“-”(负号) |
# | 类塑符是o、x、X吋,增加前缀0、0x、0X;类型符是e、E、f、F、g、G时,一定要使用小数点;类型符是g、G时,尾部的 0 保留 |
0 | 参数的前面用0填充,直到占满指定列宽为止(如果同时存在“-”,将被“-”覆盖,导致 0 被忽略 |
它用于控制显示数值的宽度,如表 3 所示。
符号 | 说 明 |
---|---|
n | 至少输出 n 个字符(n 是一个正整数)。如果输出少于 n 个字符,则用空格填满余下的位置(如果标识符为“-”,则在右侧填,否则在左端填) |
0n | 至少输出 n 个字符(n 是一个正整数)。如果输出值少于 n 个字符,则在左侧填满 0 |
* | 输出字符个数由下一个输出参数指定(其必须为一个整形量) |
它用于控制显示数值的精度。如果输出的是数字,则表示小数的位数;如果输出的是字符,则表示输出字符的个数;若实际位数大于所定义的精度数,则截去超过的部分。如表 4 所示。
符号 | 说 明 |
---|---|
无 | 系统默认精度 |
.0 | 对于 d、i、o、u、x、X等整形类型符,采用系统默认精度;对于f、F、e、E等浮点类型符,不输出小数部分 |
.n | 1) 对于d、i、o、u、x、X类型符,至少输出 n 位数字,且:
3) 对于 g 和 G 类型符,最多输出 n 位有效数字 4) 对于 s 类型符,如果对应的输出串的长度不超过 n 个字符,则将其原样输出,否则输出其头 n 个宁符 |
* | 输出精度由下一个输出参数指定(其必须为一个整型量) |
它用于控制显示数值的长度,如表 5 所示。
符号 | 说 明 |
---|---|
hh | 与d、i 一起使用,表示一个signed char 类型的值;与o、u、x、X—起使用,表示一个unsigned char 类型的值;与 n 一起使用,表示相应的变元是指向 signed char 型变量的指针(c99 ) |
h | 与d、i、o、u、x、X 或 n 一起使用,表示一个short int 或 unsigned short int 类型的值 |
l | 与d、i、o、u、x、X 或 n 一起使用,表示一个 long int 或者 unsigned long int 类型的值 |
ll | 与 d、i、o、u、x、X 或 n —起使用,表示相应的变元是 long long int 或 unsigned long long int 类型的值(c99 ) |
j | 与 d、i、o、u、x、X 或 n —起使用,表示匹配的变元是 intmax_t 或 uintmax_t 类型,这些类型在“stdint. h”中声明(c99 ) |
z | 与 d、i、o、u、x、X 或 n —起使用,表示匹配的变元是指向 size_t 类型对象的指针,该类型在“stddef. h”中声明(c99 ) |
t | 与d、i、o、u、x、X 或 n —起使用,表示匹配的变元是指向 ptrdiff_t 类型对象的指针,该类型在“stddef. h”中声明(c99 ) |
L | 和a、A、e、E、f、F、g、G—起使用,表示一个long double类型的值 |
最后,在使用 printf 函数时还必须注意,尽量不要在 printf 语句中改变输出变量的值,因为可能会造成输出结果的不确定性。如下面的示例代码所示:
int k=8;
printf("%d,%d\n",k,++k);
对于上面的代码,表面上看起来输出的结果应该是“8,9”。但实际情况并非如此,在调用printf函数时,其参数是从右至左进行处理的,即将先进行 ++k 运算,所以最后的结果是“9,9”。由此可见,千万不要在 printf 语句中试图改变输出变量的值,如果确实需要改变,可以按照下面的示例代码形式来处理:
printf("%d\n",k);
printf("%d\n",++k);
这样处理之后,其结果就是我们所需要的“8,9”了。
除此之外,每一个输出参数的输出格式都必须有对应的格式说明符与之一一对应,并且类型必须匹配。若二者不能够一一对应匹配,则不能够正确输出,而且编译时可能不会报错。同时,若格式说明符个数少于输出项个数,则多余的输出项将不予输出;若格式说明符个数多于输出项个数,则可能会输出一些毫无意义的数字乱码。
相对于 printf 函数,scanf 函数就简单得多。scanf 函数的功能与 printf 函数正好相反,执行格式化输入功能。即 scanf 函数从格式串的最左端开始,每遇到一个字符便将其与下一个输入字符进行“匹配”,如果二者匹配(相同)则继续,否则结束对后面输入的处理。而每遇到一个格式说明符,便按该格式说明符所描述的格式对其后的输入值进行转换,然后将其存于与其对应的输入地址中。以此类推,直到格式串结束为止。该函数原型的一般格式如下:
int scanf (const char *format, ...);
从函数原型可以看出,同 printf 函数相似,scanf 函数也是一个“可变参数函数”。同时,scanf 函数的第一个参数 format 也必须是一个格式化串。除此格式化串之外,scanf 函数还可以有若干个输入地址,且对于每一个输入地址,在格式串中都必须有一个格式说明符与之一一对应。即从格式串的左端第 1 个格式说明符对应第 1 个输入地址,第 2 个格式说明符对应第 2 个输入地址,第 3 个格式说明符对应第 3 个输入地址,以此类推。
也就是说,除第 1 个格式化串参数之外,其他参数的个数是可变的,且每一个输入地址必须指向一个合法的存储空间,以便能正确地接受相应的输入值。每个输入值的转换格式都由格式说明符决定。格式说明符的一般形式如下(方括号 [] 中的项为可选项):
在使用 scanf 函数的时候,需要特别注意的就是缓冲区问题。对 scanf 函数来说,估计最容易出错、最令人捉摸不透的问题应该是缓冲区问题了。
下面先来看一段示例代码:
int main(void)
{
char c[5];
int i=0;
printf("输入数据(hello):\n");
for(i = 0;i < 5; ++i)
{
scanf("%c", &c[i]);
}
printf("输出数据:\n");
printf("%s\n", c);
return 0;
}
对于上面这段示例代码,我们希望在“c[5]”字符数组中能够存储“hello”字符串,并在最后输出到屏幕上。从表面上看,这段程序没有任何问题,但实际情况并非如此。当我们依次输入“h(回车)”“e(回车)”,然后再输入“l”时,问题发生了。此时,程序不仅中断输入操作,而且会打印出字符数组 c 中的内容,其运行结果为:
输入数据(hello):
h
e
l
输出数据:
h
e
l
很显然,字符数组“c[5]”是完全能够存储“hello”字符串的,但为什么输入到“l”就结束了呢?
其实原因很简单,在我们输入“h”和第一个回车后,“h”和这个回车符“\n”都保留在缓冲区中。第 1 个 scanf 读取了“h”,但是输入缓冲区里面还留有一个“\n”,于是第 2 个 scanf 读取这个“\n”,然后输入“e”和第 2 个回车符“\n”。同理,第 3 个 scanf 读取了“e”,第 4 个 scanf 读取了第 2 个回车符“\n”,第 5 个 scanf读取了“l”。因此,程序并没有提前结束,而是完整地循环了5次scanf语句,只不过有两次scanf都读取到回车符“\n”而已。
由此可见,在使用 scanf 函数时,如果不及时刷新输入缓冲区,有时会出现莫名其妙的错误。对于这类问题,其实解决办法有许多,比如可以使用“fflush(stdin);”语句来刷新输入缓冲区。但不得不说明的是,fflush 函数在可移植性上并不是很好。当然,也可以通过自己编写代码来解决,如下面的示例代码所示:
#include <stdio.h>
void flush()
{
char c;
while ((c=getchar()) != '\n'&&c!=EOF);
}
int main(void)
{
char c[5];
int i=0;
printf("输入数据(hello):\n");
for(i = 0; i < 5; ++i)
{
scanf("%c", &c[i]);
flush();
}
printf("输出数据:\n");
printf("%s\n", c);
return 0;
}
这样,就从根本上解决了输入缓冲区问题,其运行结果为:
输入数据(hello):
h
e
l
l
o
输出数据:
hello
除此之外,还应该注意 scanf 中的空白符(这里所指的空白符包括空格、制表符、换行符、回车符和换页符)带来的问题,如下面的代码所示:
int main(void)
{
int a=0;
printf("输入数据:\n");
/*请注意,这里多了一个回车符\n*/
scanf("%d\n",&a);
printf("输出数据:\n",a);
printf("%d\n",a);
return 0;
}
在上面的代码中,因为在“scanf("%d\n",&a);”语句中多加了一个回车符“\n”,导致的结果就是要输入两个数,程序才会正常结束,而不是我们所期望的一个数。运行结果为:
输入数据:
22
11
输出数据:
22
原因就是在用空白符结尾时,scanf 会跳过空白符去读下一个字符,所以必须再输入一个数。因此在编写程序时一定要多注意这类手误导致的错误。