浅析“HelloWorld”的结构

几乎每个学习编程的人编写的第一个程序都是“HelloWorld”,它是编程之神的传统咒语,我的C语言学习之路也是从“HelloWorld”开始的,在这新的一年,我想捡起曾经那段感情,勿忘初心,方得始终。

刚开始编写“HelloWorld”时,只知道按照固定的格式敲出代码,编译运行,看到命令提示符输出这些字符,神奇不已,时过境迁,现在细细想来其中还是颇具内涵的。让我们先以一个人人常见的“HelloWorld”程序来开始今天的思考吧!

上面这段代码是一个典型的C语言“HelloWorld”程序,第1行代码的作用是进行相关的预处理操作,字符“#”是预处理标志用来对文本进行预处理操作,include是预处理指令,他后面跟着一对尖括号,表示头文件在尖括号内读入。stdio.h就是标准输入/输出头文件,由于在第4行用到了printf()输出函数,所以须加此头文件。

要了解预处理需要首先清楚C语言的运行机制,C语言从源程序到生成可执行的目标文件的主要过程为:源代码—>预处理—>编译—>链接—>可执行文件。预处理是指对源文件代码中的宏进行替换,并将包含的头文件整体插入源文件中,为接下来的编译做准备,由预处理程序负责完成,C语言的编译预处理功能主要包括宏定义,文件包含和条件编译3种。编译是指编译器读取预处理后的输出文件,对其进行词法和语法分析,将高级语言指令转换为功能等效的汇编代码。链接是指将编译后得到的零散的二进制代码文件组合成二进制可执行文件。该步骤主要完成两个工作:一是解析其他文件中函数引用或其他引用;二是解析库函数。

我们可以使用gcc的参数-save-temps来查看C语言编译过程中生成的中间文件,它会由刚开始的.c文件依次生成.i文件,.s文件,,o文件,a.out文件,这3个临时文件依次对应着预处理、编译、链接这3个过程。这里我们重点关注预处理过程,前面说过预处理主要包括宏定义、文件包含、条件编译3种,我们首先考察宏定义,#define <名字> <值>用来定义一个宏,其中名字必须是一个单词,值可以是各种东西,在C语言的编译器开始编译之前,编译预处理程序(CPP)会把程序中的名字换成值,这里做的是完全的文本替换。下面这段程序实现了圆的周长的计算:

查看预处理后的.i文件如下:

因为.i文件很大,所以我们使用tail命令查看它的最后10行。可以看到源程序种所定义的宏已经被完全替换。如果一个宏的值中,有其他的宏的名字,也是会被替换的。宏的值后面出现的注释不会被当作宏的值的一部分。如果一个宏的值超过一行,最后一行之前的行末需要加\。我们也可以定义没有值的宏,如#define _DEBUG,这类宏是用于条件编译的,后面有其他的编译预处理指令来检查这个宏是否已经被定义过了。C语言种也有许多预定义的宏,如下代码所示:

运行结果如下:

宏也可以带参数,如#define cube(x) ((x)*(x)*(x)),这非常像函数。从前面的示例中传达出了一种原则,即一切都要括号:整个值要括号,参数出现的每个地方都要括号。同样,也可以带多个参数,如#define MIN(a,b) ((a)>(b)?(b):(a)),也可以组合(嵌套)使用其他宏。这种带参数的宏在大型程序的代码中使用非常普遍,西方程序员经常使用,可以非常复杂,如在###这两个运算符的帮助下“产生”函数,部分宏会被inline函数替代。

#include指令就是指文件包含,它的作用就是将一个源程序文件包含到另外一个源程序文件中,#include <stdio.h>就是将标准输入/输出头文件包含到这里的“HelloWorld”程序中,有时我们还会见到另外一种写法:#include "stdio.h",这两种写法都可以实现文件包含,不同的是,尖括号的写法是标准格式,当使用这种格式时,C编译系统将在系统指定的路径下搜索尖括号中的文件;当使用双引号写法的格式时,系统首先会在用户当前工作的目录中搜索双引号中的文件,如果找不到,再按系统指定的路径进行搜索。因此尖括号写法一般用来包含标准头文件(例如stdio.h或stdlib.h),因为这些头文件极少被修改,并且它们总是存放在编译程序的标准包含文件目录下。双引号写法一般用来包含非标准头文件,因为这些头文件一般存放在当前目录下,你可以经常修改它们,并且要求编译程序总是使用这些头文件的最新版本。

在第一行我们包含了stdio.h这个头文件,这是因为在第4行我们用到了printf输出函数,而printf输出函数的原型就存在于stdio.h头文件中,倘若我们的程序中没有用到printf输出函数(或其他的一些stdio.h头文件中包含的函数),那么我们的程序就不用包含stdio.h头文件了,在一些单片机的C语言程序中通常就是这样的。注意,我们这里说的是函数原型,即stdio.h里只有printf的原型,printf的代码在另外的地方,某个.lib(Windows)或.a(Unix)中,#include <stdio.h>只是为了让编译器知道printf函数的原型,保证你调用时给出的参数值是正确的类型。换一个角度看问题,如果我们自己构造出一个printf函数,就此程序来说也就不用包含stdio.h头文件了。值得注意的是在某些IDE上(例如Turbo C 2.0),允许不引用此头文件而直接调用其中的函数,但这种做法是不标准的,也不建议这样做,以避免出现在其他IDE中无法编译或执行的问题。stdio就是指 “standard input & output”(标准输入输出),.h是头文件的扩展名(header file)。在大程序中,一般的做法就是任何.c都有对应的同名的.h,把所有对外公开的函数的原型和全局变量的声明都放进去。对于变量的声明,可以使用如下代码:extern int i;,声明是不产生代码的东西,而定义是产生代码的东西。在使用和定义这个函数的地方都应该#include这个头文件。而对于不对外公开的函数或全局变量,在前面加上static就能使得它只能在所在的编译单元中使用。同一个编译单元里,同名的结构不能被重复声明,因此我们需要标准头文件结构:

运用条件编译和宏,保证这个头文件在一个编译单元中只会被#include一次。

第2行代码声明了一个main()函数,该函数是程序的入口,每一个C程序必须有且仅有一个main()函数,程序总是从main()函数开始执行。main()函数前面的int表示该函数的返回值类型是整型,main()函数“()”中的void表示该函数是一个无参的函数。代码第3~6行“{}”中的内容是函数体,程序的相关操作都要写在函数体中。有些时候我们还会见到另外一种写法:void main(){......},但是void main的用法并不是任何标准制定的,在最新的 C99 标准中,只有int main(void){......}int main(int argc, char *argv[]){......}这两种定义方式是正确的。从某种意义上来说,“()”是一个函数的标志,倘若没有这个“()”,编译器可能会认为这是一个main变量而不是一个main函数,再往后检查从而造成编译错误,因此即使参数列表为空也要加上“()”。

还有一个问题,当函数参数为空时,int fun(void)int fun()有何区别呢?值得注意的是在传统C中,int fun()表示fun函数的参数表未知,并不表示没有参数。举个例子,下面这段代码:

这个程序很简单,但有一点值得注意,如果要把被调用的函数放在main()函数后面,则必须要在main()函数前面进行函数的原型声明,函数的原型声明和函数的定义是不同的,在第2行我们声明了一个函数原型(函数原型的声明时一定要在语句的后面加上英文分号“;”,否则编译无法通过会报错显示“expected ‘=’, ‘,’, ‘;’, ‘asm’ or ‘__attribute__’ before ‘{‘ token”),编译器认为这是一个无返回值且参数未知的fun函数,在第6行我们向fun函数中传int型值6,编译器会暂时猜测fun函数是一个参数类型为int的函数,在第9行编译器又发现fun函数是一个参数类型为double的函数,最后输出y的值,程序可以编译运行,并且不会报任何错误或警告,但是最终的输出结果却不尽如人意:

实际上在这个程序中我们“欺骗”了编译器,编译器不会报任何的错误,因为这些操作都符合编译的要求,但是结果却与我们想要的不一样,因此平时遇到函数参数为空的情况时最好加上void,有参数时函数的原型声明中参数类型最好写全,以免遇到这种情况以致于自己一时半会儿找不到问题的原因。

调用函数时给的值与参数的类型不匹配是C语言传统上最大的漏洞,因为编译器总是悄悄替你把类型转换好,但是这很可能不是你所期望的,后续的语言(例如C++、Java)在这方面很严格。

继续回到刚开始的程序,第4行代码调用了一个用于格式化输出的函数printf(),该函数用于输出一行信息,可以简单理解为向控制台输出文字或符号等。printf()括号中的内容称为函数的参数,括号内可以看到输出的字符串”Hello World\n”,其中“\n”表示换行操作,它不会输出到控制台。

printf()被称为格式化输出函数,其关键字最后一个字母f即为“格式”(format)之意,printf()函数的调用格式为: printf(“<格式化字符串>”, <参量表>),格式化字符串是用于指定输出参数的格式与相对位置的字符串参数,其中的转换说明(type)用于把随后对应的0个或多个函数参数转换为相应的格式输出,格式化字符串中转换说明以外的其它字符原样输出(正因此本程序中的”Hello World”原样输出)。格式化字符串中的占位符用于指明输出的参数值如何格式化,格式化占位符语法是:%[flags][width][.prec][hlL]type,其中“[]”中的项为可选项。下面介绍每一项的含义和用法:

Flags(标志) 含义
左对齐,右边填充空格(默认右对齐)
+ 在数字前增加符号+或-(默认忽略正数的符号)
0 将输出的前面补上0,直到占满指定列宽为止(若0与-均出现,则0被忽略,即左对齐依然用空格填充)
# Type是O、x、X时,在非0数值前分别输出前缀0、0x、0X表示数制;

Type是e、E、f、g、G时,总是输出小数点;

Type是g、G时,尾部的0保留以表示精度。

空格 输出值为正时加上空格,为负时加上负号(若空格与+同时出现,则空格被忽略)
Width(宽度)或prec(precision,精度) 含义
number 最小字符数(若输出字符的个数超过宽度,并不会被截断而是显示全部)
* 下一个参数是字符数
.number 小数点后的位数(对于字符串类型,指输出的字节上限,超出限制的其他字符将被截断;若仅给出小数点,则宽度为0)
.* 下一个参数是小数点后的位数
类型修饰 含义
hh 单个字节
h short
l long
ll long long
L long double
type(转换说明) 用于 type(转换说明) 用于
i或d int g float
u unsigned int G float
o 八进制 a或A 十六进制浮点
x 十六进制 c char
X 字母大写的十六进制 s 字符串
f或F float,6 p 指针
e或E 指数 n 读入/写出的个数

下面是一个简单的程序和运行结果来验证这些用法:

20160106

这个程序中有一点需要注意,在程序的第2行我们定义了一个全局变量c,在第7行我们定义了一个局部变量c,然而在最终的输出结果上显示的确是局部变量c的值,这是因为在C语言中局部变量会掩盖全局变量,它总遵循这样的规则:在更小的地方可以重新定义更大地方曾经出现过的变量,然后把它给隐藏掉,这在Java中刚好是相反的。

继续回到刚开始的程序,第5行代码中return语句的作用是将函数的执行结果返回,后面紧跟着函数的返回值,返回值一般用0或-1表示,0表示正常,-1表示异常。前面我们说过main()函数是程序的入口,但实际上main()函数也是被其他地方调用的,它并不是程序运行时第一条被执行的代码,因此return语句是有意义的,它将函数的执行结果返回给调用它的那个地方,报告给操作系统,以此来判断main()函数是否被成功执行,即程序是否被成功运行。如果你学过批处理文件,那么我们可以用它来做许多事情。例如我们可以在命令提示符中查看main()函数的返回值:

201601050

当我们修改return后面的值时它就会返回所改的值:

2016010599

当然,如果我们把99改成99.99,那么它还是返回99,因为99.99被传给操作系统之前,被强制类型转换成整数类型了。

现在我们再编写一个简单的practice2程序,它的代码如下:

我们在命令提示符中输入practice1&&practice2回车,就会看到这样的结果:

20160105too

在批处理文件中&&的含义是:如果&&前面的程序正常退出,则继续执行&&后面的程序,否则不执行。所以,要是把practice1.c里面的 return 0; 删除或者改为 return 99; ,那么你只能看到Hello World。也就是说,程序practice2.c就不执行了。另外在批处理文件中我们还经常使用这样的语法:if errorlevel 1 ...;意思是先判断前一个命令执行后的返回码(也叫错误码),如果和定义的错误码符合(这里定义的错误码为1),则执行相应的操作(省略号位置的命令)。也就是说若前一个命令执行出错再执行后续的命令,因为它是错误判断,0表示否定,即这个错误不存在,非0表示这个错误存在,所以return 0表示程序正常运行,没有错误返回。

最后,值得一提的是,在C语言程序中,以分号(;)作为结束标记的代码都可称为语句,被“{}”括起来的语句被称为语句块,由第一个HelloWorld程序中可以看出在第一行的预处理命令并没有用分号结尾,所以它不是C语言的程序语句,但是C语言程序离不开它们。

以上就大致捋清了HelloWorld这个经典程序的基本结构,其实还有很多问题我并没有说清楚,还需要日后的多多思考。



发表评论

电子邮件地址不会被公开。 必填项已用*标注