开头语
上面一篇已经写过,c语言不仅仅只是语法很好玩,很让人捉摸不透,它的思想更是深奥,需要慢慢体会的。看了关于c语言的书籍不少于50本,每看一遍都感觉认识深了一点,但是终究还是无法完全理解它背后最深层的思想,只是在不断地向它走近。正因为如此,下面的描述不会按照一个固定的说明文格式来编写,而是采用对话的方式来将这个过程展现出来,这样应该能更多地展示出c语言的思想。
格式: 问题(Question)将以 Q: 开头, 回答(Answer)将以 A: 作为开头。
Q: c语言的HelloWorld版本是什么样子的?
A: 起名为hello.c:
#include <stdio.h>
int main()
{
printf("Hello World!");
return 0;
}
Q: #include这条语句是什么意思?
A: 它以#开头,它表示预处理过程,一般不把它当做编译过程,而是用一个单独的预处理器来处理。因为,从编译源代码的角度来说,将一个头文件插入到最终被编译的代码和编译源代码真的没什么一致的地方。
Q:如何才能感受预处理器的存在呢?
A: 一般的编译命令都提供预处理选项,可以查看预处理后的源代码是什么样子。
[Windows-VS2010] 可以使用cl.exe命令来查看预处理后的源代码:
cl /P hello.c
会在同目录下生成一个.i的预处理后的文件hello.i, 内容如下(因为篇幅问题,无法将所有内容上传,否则保存不了,省略号省略了大部分代码):
#line 1 "hello.c"
#line 1 "d:\\Program Files\\Microsoft Visual Studio 10.0\\VC\\INCLUDE\\stdio.h"
.............................
.............................
#line 739 "d:\\Program Files\\Microsoft Visual Studio 10.0\\VC\\INCLUDE\\stdio.h"
#line 2 "hello.c"
int main()
{
printf("Hello World!");
return 0;
}
Q:看了那个文件,文件实在是太长了!
A: 是的,这其实也是c和c++的一个缺点,#include将源代码插入的方式可能会导致被编译的源代码长度迅速增加,这也是预编译出现的一个原因。也可以看到其实hello.c代码那几行也就是最后的那几句,前面都是#include头文件插入的代码,确实大的很直接。
Q:hello.i文件最后几句,有#line 2 "hello.c"这句,它是什么意义?
A: #符号开头,依然是编译器的指令,它表示当前行被设置为2,文件名是hello.c.因为,之前的#include<stdio.h>被插入了很多其它文件的内容,但是在hello.c中,第二行是接着#include <stdio.h>后面的,所以将行数重新设置,这样才能正确地解析hello.c的代码行数。
Q:cl命令我怎么知道/P参数是预处理作用的?
A:当然可以用cl /?命令来得到它的帮助,然后找到需要功能的参数。
Q: 这么多参数,查找要的有点难啊?
A: 可以想到unix下的grep命令来过滤字符串。当然,这需要cygwin的支持。上面的grep命令是在装了cygwin后才有的。
Q: 再回到hello.c里面吧。main函数必须有个返回值吗?
A: 是的,其实这很能体现分层思想,任何一个应用程序最终都可能有个返回值,供调用它的程序来使用,所以在这里有个int的返回值
也是可以理解的。
Q: 怎么查看调用一个程序的返回值呢?
A: [Windows] 在命令行下,可以使用echo %errorlevel%来获取它的值。
[Mac]在命令行下,可以使用echo $?来获取。
[Ubuntu]同[Mac].
比如,hello.c的代码编译成hello.exe,
cl hello.c:生成hello.exe,运行它:
使用echo %errorlevel%打印返回值:
可以看到,返回的正是代码中return语句后面的0.
Q: 可以返回的不是0吗?
A: 当然可以。修改hello.c源代码为:
#include <stdio.h>
int main()
{
printf("Hello World!");
return 1;
}
再次编译运行后,使用echo%errorlevel%得到:
Q: printf是一个函数,它的原型是什么?
A: 在stdio.h中可以看到它的原型是:
Q: 怎么这么复杂?紫色的部分是什么?
A: 其实,很多原型中有一些附加的文本,它们在编译过程中并没有充当很有意义的东西,只是一种注释或者说明信息,在一些特定情况下起了作用,可以暂且不用特别关注它们。
比如,_Check_return_opt_的宏定义是:
再接着进入_Check_return_也可以看到类似的宏定义,依次分析。
Q: 那么printf函数的原型简化版就是
int __cdecl printf(const char * _Format, ...);
?
A:是的,也可以再简化成
int printf( const char * _Format, ...);
Q: __cdecl是什么?
A: 这是一种函数调用方式;对应的还有__stdcall,__ fastcall等等,它们有一定的区别,比如函数压栈顺序,参数优先保存在寄存器中等,c语言默认是__cdecl方式。
Q: const char * _Format中的const可以去掉吗?
A: 可以的,但是用上const表示不可更改的变量,printf函数内部不可以改变_Format指向的字符串,这是一种防御性编程的思想。
Q: c++中的const不是表示常量吗?
A: 是的,但是c语言中的const表示的是一个变量,但是不可更改。这个关键字在c语言和c++中是有区别的。
Q: 举个例子。
A: 比如在c语言中用const修饰的一个变量,不可以作为静态数组的参数个数,因为它是一个变量;在c++中,这是可以的。
如hello.c代码修改为如下:
#include <stdio.h>
int main()
{
const int size = 10;
int arr[size];
return 0;
}
出现编译错误:
错误原因就是需要一个常量的大小表示数组的大小。
Q:如果是c++,如何呢?
A: 使用cl /TP hello.c将hello.c代码当成c++代码进行编译:
没有出现错误。
Q: 源代码不是hello.c,后缀名是.c, 怎么是当做c++代码编译了呢?
A: 这主要是在于/TP参数的作用了:
可以看到,/TP命令行将所有文件当成.cpp来编译,即也会把hello.c代码当成.cpp来编译。
Q: 这么说来,编译器编译源代码不一定看后缀名的了?
A: 当然是的,对于编译器来说,只要给文本即可,对于后缀只不过在通常情况下按照指定类型代码编译而已,可以指定将
某种扩展名的代码当成特定类型代码编译的。
Q: printf函数原型参数中的三个点表示什么?
A: 它表示可变参数,即是参数个数不能确定,也许是1个,也许2个或者更多。
Q: 为什么要这么设计?
A: 因为对于输出功能来说,该输出多少东西,设计者开始是不知道的,所以交给了程序员来是实现,编译器只需要根据获取
到的参数最后正确转换给内部处理函数即可。
比如:
printf("Hello World!");
printf("My name is %s", "xichen");
printf("My name is %s, age is %d", "xichen", 25);
Q: 那么printf函数返回什么呢?
A: 它返回成功输出的字节数。
源代码:
#include <stdio.h>
int main()
{
int ret;
ret = printf("abc");
printf(" ret is %d\n", ret);
ret = printf("Hello World!");
printf(" ret is %d\n", ret);
ret = printf("My name is %s", "xichen");
printf(" ret is %d\n", ret);
return 0;
}
输出:
Q:如果有中文在字符串中,字节数怎么算呢?
A: 这需要根据终端最终输出的字节数来得到了。
在Windows下的记事本打开编写如下代码:
#include <stdio.h>
int main()
{
int ret;
ret = printf("abc中国");
printf(" ret is %d\n", ret);
return 0;
}
以ANSI格式保存,ANSI即以本地化编码格式保存;
[Windows7]本地化语言默认为ANSI格式,在注册表中查看得到:
是0804,0804代表简体中文,对应编码为GBK.
再次查看cmd输出字符使用的编码:
可以看到,确定编码格式是GBK.
所以,上面的字符串中的"中国"是以GBK格式输出,即一个中文字符对应2个字节,即"abc中国"这个字符串总长度为7.
所以,输出:
Q: 如果将此源代码另存为UTF8格式保存,进行编译,最终的结果会不会变呢?
A: 来看看。
将源代码以UTF8格式保存:
再次编译hello.c,并运行:
可以看到,它的结果依然是7.
Q:不是编码格式变成UTF8, 这两个中文每个字符占有3个字节吗?输出的字节数怎么还是7,它怎么认为每个中文占用2个字节呢?
把UTF8格式的hello.c源代码用十六进制打开:
可以看到,"中国"两个中文确实总共占用6个字节,可是为什么printf输出后计算的总字节数不是9而是7呢?
A: 当然,源代码的编码格式以及源代码中字符串的编码格式和最终输出的编码格式不一定是一样的。正如前面所说,printf
函数的返回值以终端真实输出的字节数为准,终端的字符编码是GBK,不管原来编写代码的对应编码是什么,和最终输出并没
有必然关系,所以结果依然是7.
Q:可执行文件hello.exe中的"abc中国"字符串对应的编码是什么呢?
A: 将hello.exe用十六进制打开,
可以发现,"中国"字符串用的是用GBK编码格式保存的;这里,应该可以理解为什么一直输出7了吧。
Q: 编码这个东西还真有意思,如何改变cmd终端的编码格式?
A: 这个编码格式也被称为代码页,可以使用chcp命令:
直接输入chcp可以得到当前的代码页:
更改为utf-8格式,使用chcp 65001 :
此时再运行hello.exe,结果依然是7,但是"中国"字符变成了乱码。
我想原因你应该知道了。
Q:输出字符串,不也是可以用unicode宽字符的吗?
A: 是的。
如下代码:
#include <stdio.h>
int main()
{
int ret;
ret = wprintf(L"abc%S", L"中国");
printf(" ret is %d\n", ret);
return 0;
}
[Windows-VS2010]输出:
在Mac或者Ubuntu下,此代码暂未测试;因为关于wprintf以及%ls和%S,不同系统下表现不一致,可能需要修改代码。
Q: printf函数里面的输出格式有好多种,但是和对应输出格式的数据如果不一致,会导致什么问题呢?
A: 当然是可能会发生一些问题了,因为printf函数是动态解析格式字符串,然后将对应的数据来填充,可能出现本来是
%d格式,但是却用了一个double的数据来填充,这样就可能导致错误。
Q: 比如:
#include <stdio.h>
int main()
{
printf("%d", 1.5);
return 0;
}
为什么编译阶段没有出现编译的错误呢?
A: 因为编译器将"%d"当成了一个字符串,且并不去分析其中的格式,这个交给了printf函数的实现内部。其实,也就是把判断是否正确的责任交给了程序员,
如果程序员弄错了,那么结果就可能会跟着错。
Q: printf函数解析格式输出的代码该怎么写?
A: 这个需要根据字符串中的%为标志,出现这个,说明后面可能跟着一个对应格式的数据,比如d,那么说明是个整数,将栈中对应的数据来填充;如果是s,
那就是取字符串来填充,依次类推;如果没有遇到%符号,那么按原样输出。
一个简单且调用了printf的类printf函数:
int __cdecl cc_printf(
const char *format,
...
)
{
va_list argulist;
int ret = 0;
va_start(argulist, format);
while (*format)
{
if(*format != '%')
{
putchar(*format);
++ret;
goto loop;
}
else
{
++format;
switch (*format)
{
case 'c':
{
int value = va_arg(argulist, int);
ret += printf("%c", (char)value);
goto loop;
}
case 's':
{
char *value = va_arg(argulist, char *);
ret += printf("%s", value);
goto loop;
}
case 'd':
{
int value = va_arg(argulist, int);
ret += printf("%d", value);
goto loop;
}
case 'o':
{
int value = va_arg(argulist, int);
ret += printf("%x", value);
goto loop;
}
case 'x':
{
int value = va_arg(argulist, int);
ret += printf("%x", value);
goto loop;
}
case 'X':
{
int value = va_arg(argulist, int);
ret += printf("%X", value);
goto loop;
}
case 'u':
{
unsigned value = va_arg(argulist, unsigned);
ret += printf("%u", value);
goto loop;
}
case 'f':
{
double value = va_arg(argulist, double);
ret += printf("%f", value);
goto loop;
}
default:
{
goto loop;
}
}
}
loop:
++format;
}
va_end(argulist);
return ret;
}
Q: 还回到刚刚那个问题吧。
printf("%d", 1.5);
这个会输出什么呢?
A: 编译一下,输出:
Q: 为什么会输出这个神奇的数字呢?
A: 根据IEEE754的标准,双精度浮点数1.5的二进制表示形式是:
00 00 00 00 00 00 F8 3F
可以看到,低4个字节都是0,而%d正好只取了低4个字节,所以结果是0.
为了方便地打印出double数据中各个字节的值,可以使用如下的union结构:
union double_data
{
double f;
unsigned char data[sizeof(double) / sizeof(char)];
};
Q: 有的时候,printf格式串后的参数个数超过了格式串中的对应格式,会是什么结果?
A: 先写个代码:
#include <stdio.h>
int main()
{
printf("%d %d", 1, 2, 3);
return 0;
}
输出的结果是什么呢?
Q: 为什么不是输出2和3呢,或者输出3和2呢?
A: 看汇编:
使用cl /Fa hello.c命令编译,得到hello.asm文件:
; Listing generated by Microsoft (R) Optimizing Compiler Version 16.00.30319.01
TITLE F:\c_codes\hello.c
.686P
.XMM
include listing.inc
.model flat
INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES
_DATA SEGMENT
$SG2637 DB '%d %d', 00H
_DATA ENDS
PUBLIC _main
EXTRN _printf:PROC
; Function compile flags: /Odtp
_TEXT SEGMENT
_main PROC
; File f:\c_codes\hello.c
; Line 5
push ebp
mov ebp, esp
; Line 6
push 3
push 2
push 1
push OFFSET $SG2637
call _printf
add esp, 16 ; 00000010H
; Line 8
xor eax, eax
; Line 9
pop ebp
ret 0
_main ENDP
_TEXT ENDS
END
可以看到; Line 6标识的地方,有4个参数压栈的操作,依次是3, 2, 1和$SG2637, $SG2637也就是对应"%d %d"这个字符串。
这个时候,你应该看明白了吧,调用printf("%d %d", 1, 2, 3);函数的时候,此函数有4个参数,但是从右向左依次压栈,最后解析
"%d %d"字符串的时候,第一个格式就对应栈中下一个元素,也就是1, 依次找到第二个对应格式的数据,也就是2.所以最后输出
了1和2.
Q: 如果printf参数中第一个参数中的数据格式数多于后面的参数,那又会发生什么呢?
A: 当然按照之前的原理,解析数据格式的时候会从栈里多解析一个数据,这很可能导致之后的运行错误,因为它很可能并不是
程序员的意图。
Q: 看到一些地方,printf函数后面的参数有好几个带有自增或者自减的操作,最后的结果真的很神奇,这有什么规律吗?
A: 你是说,比如
#include <stdio.h>
int main()
{
int i = 1;
printf("%d %d", ++i, ++i);
return 0;
}
最后的结果很可能不是你想要的,不过这个代码也可能让别人有好几种推测。所以,这种代码最好是不要写,要么写就写清楚的,大家
都能明白的代码;同时,这种代码也不是能够很方便移植的,所以还是尽量少写这样的代码。
Q: 听说,printf函数是带缓冲的输出?这个和不带缓冲的输出有什么区别?
A: 正如一个道理,cpu速度很快,外设运行速度很慢,为了避免这种速度差距导致cpu的效率被严重降低,所以,对于外设的操作,很多都有缓冲,包括显示器、键盘、硬盘等等。不带缓冲的也就是如果需要输出,那么立即输出,不会被缓冲系统来处理。
Q: 我怎么感觉不到是不是缓冲输出的?
A: 一个很容易感受到缓冲的是在cmd里面提示输入的地方。你可以输入一段数据,然后按回车,系统会进行对应处理。这个地方表示的是缓冲输入。
对于缓冲输出,举个例子:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int i = 1;
char *buf = (char *)malloc(512);
if(buf)
{
setbuf(stdout, buf);
}
printf("hello");
printf("xichen");
return 0;
}
这里,申请了一个512字节的空间,然后使用setbuf函数将标准输出的缓冲区设置为buf.
然后向标准输出输出2个字符串。
在printf("xichen");行打断点,调试到此行,可以发现控制台什么也没输出,继续运行依然没有输出,没有输出的原因就在于它们被缓冲了。
然后,修改代码,设置缓冲区为空:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int i = 1;
setbuf(stdout, NULL);
printf("hello");
printf("xichen");
return 0;
}
依然在printf("xichen");行打断点,调试:
运行到断点处,此时查看控制台输出:
可以看出,前面一句代码的字符串已经被输出了,这就是没有缓冲的。
结束语
前面写了这么多,不知不觉发现printf这个基本输出的函数确实不简单;但是了解了关于它的代码的底层信息,也会对它的理解更深刻,
前面写的东西不一定只适用于printf, 很多内部实现的实验都可以从上面的内容找到一些影子,希望这些对大家有帮助。
xichen
2012-5-2 16:09:06