【C】 02 - 程序结构和预处理
在正式进入C的语法之前,有必要对其整体外观和组成元素作一个浏览。这部分内容对大多数人是比较陌生的,但它们却是C的起点和骨架。而这些内容涉及的背景或细节又可以展开为专门的课题,这里也只是浅尝则止,说明个大概即可。
1. C程序组成
任何一个程序都首先以源文件(source file)的形式存在,它是一个普通的文本文件。C程序一般由一系列后缀为.c和.h的文件组成,前者包含了程序的执行内容,后者包含了各种声明或定义。其实文件名并不重要,这样的后缀名仅是约定俗成的习惯。但建议保持这样的风格,一是为了看程序的人能一目了然,二是在集成开发环境(IDE)里它们已经成为C文件的标识。
文本文件有许多字符组成,这些字符的编码方法由编辑器决定。对C编译器有意义的是它们所表示的字符而非编码本身,预处理开始前会将这些字符映射成source character set(一般是UTF-8)。预处理就是在该字符集下进行的,预处理后还会将字符和字符串映射成execution character set,它由目标平台决定,但一般和前者相同。
这两种字符集都包含base character set,它就是我们正常使用英文和符号(编码在两个字符集中相同),字母是区分大小写的。新标准还支持extended character set,它可出现在两种字符集中。比如在source字符集中可在多处使用unicode:identifier、char constant、string literal、headfile name、comment、preprocessing token。示例如下(需编译器支持或打开开关),但不建议这样的编码风格。
// Define variable α wchar_t \u03B1 = L'α';
C语言的编译是以translation unit为单元的,它是预处理后的.c文件。各单元的编译互不相干,连接器最终会把它们和库一起整合成执行文件。关于编译、连接和调试,我打算另开课题,这里不深入讨论。以下是一个多文件程序的常见错误,但在编译连接时并不会报错,因为无法跨unit检查语法。运行时文件2中会把数组a的元素当地址使用,出现错误。
// File 1 int a[3]; // File 2 extern int *a; // should be a[]
C程序可能独立运行(嵌入式),也可能运行在操作系统中。C规范对这两种情况的要求稍微有点不同,分别叫freestanding implementation和hosted implementation。其中后者要求实现更多的库,而且必须有一个main函数。而前者只需要少数必要的库,程序入口不作规定(但建议也用main)。main函数可有以下两种形式,对第二种形式,规范要求argv[0]为程序名,argv[argc] = NULL。
int main(void); int main(int argc, char* argv[]); // or char** argv
程序运行时,内存中除了常量区(代码和字符串)、数据区外还会有堆和栈区。一般栈底在高地址,向低地址增长。hosted程序的地址一般是逻辑地址,运行时由OS负责映射为物理地址。
2. 预处理步骤
第0步,字符集映射。将源程序文本文件的字符映射为source字符集,甚至包括将换行符的统一编码。C还要求每行都以换行符结束,如果文件尾没有换行,编译器会warning(需打开)。
第1步,trigraph sequance。为支持某些古老的键盘,C使用??x来转义它们没有的符号。所以请在代码中避免使用??序列,字符串中可使用\?转义。下表是规范支持的trigraph sequance,不在该表中的不进行转义。
??( | ??) | ??< | ??> | ??= | ??/ | ??' | ??! | ??- |
[ | ] | { | } | # | \ | ^ | | | ~ |
第2步,去除“\+回车”。将该组合去除,不产生或消除空白。所以identifier中也可以被断开,但它一般用于宏定义和字符串换行(见示意代码)。注意下一行的前导空白会被保留,所以不能为了格式对齐而添加空格。
#define INC(a) \ { \ a++; \ } char str[] = "this is a \ long string";
第3步,preprocessing token。将注释换成一个空格,解析pp token和空白(white space)。空白包括空格、换行、tab等,换行被保留,其它空白的处理基于实现。注释/**/可跨行,不可以嵌套。如果想临时注释掉一段代码,最好用#if 0。注释//作用到本行末,旧C不支持该用法。
// should use #if 0 /* int a; /* declare var */ */
第4步,预处理指令。展开宏,导入包含文件,执行预处理指令,直至结束。预处理指令(directive)以#开头,仅包括当前行,#前后可以有空白。
第5步,字符串处理。字符(串)映射为execution字符集,包括将转义序列\x编码。将相邻字符串拼接为一个字符串,只添加一个结束符'\0'。
char str[] = "This is a " "long string";
第6步,C token。将pp token映射为C token,空白被丢弃。
3. Token 解析
编程语言在字符集的基础上进行词法(Lexical)、语法(Syntax)和语义(Semantic)的分析。C词法分析就是将程序分解为token,这一步在预处理阶段完成。token一般不会被赋予太多的意义,只是根据序列特征大致分类,编译器根据这些特征解析出一个个token。token解析采用贪婪原则(也称最长原理),一个token的下一个字符与它不能再组成有意义的token。不满足贪婪原则的分割,即使有意义,也是不被采用的。预处理将token大致分为四类:identifier,data constant,string,punctual。
identifier即标识,它包括directive、keyword、object、function、tag、member、name、lable、macro等,简单说就是用来表示某个东西的名字。identifier的词法大家都熟悉,就是由字母、数字和'_'组成,但不由数字开头。identifier不宜过长,因为有些编译器会进行截取。另外在起名字时尽量回避关键字还有__xxx__和_Axx_(大写字母开头),它们都预留给系统使用。
data constant就是各种常量,包括整形、浮点、字符等。它们有自己的格式,在下一章将有描述。headfile name和string literal以<>或“”作为边界,其中可以含有空格。在其它场合,空白和符号往往是分割token的边界。
punctual就是各种符号。它同样也遵循贪婪原则,以最长的有意义符号串作为一个token,注意其中不能有空白。另外C还支持digraph转义序列(下表),但和trigraph不同,它是在token解析时进行的。
<: | :> | <% | %> | %: | %:%: |
[ | ] | { | } | # | ## |
需要强调的是,token解析是在预处理阶段完成的,而且除特殊情况外不重新解析。预处理里中的token到C编译时会做一些调整(字符转义、丢弃空白等),但token的分割已经完成。也就是说token解析完成后,程序的组成单位就是token,而不是字符集了。由此可见,宏定义不光是简单的字符串替换,至少它还影响了token的解析。以下的例子能很好的说明本段的一些内容。
#define plus + a+++b; // (a++) + b a+ ++b; // a + (++b) a+ + +b; // illegal a plus++b; // a + (++b) a+ =b; // illegal a plus=b; // illegal a/*p; // should be a/ *p
4. 预处理指令
4.1 宏
宏是预处理中最复杂也是最强大的功能,这里用单独篇幅说明宏的使用。简单来说,宏就是将宏identifier用其定义的token序列替代。替代的token序列的头和尾的空白被去除,中间的空白可能被合并,包括宏参数也是这样处理的,这与我们一直认识的“替代”还是有差别的。另外,宏只做替换,对常量表达式并不做计算。
#define r 1 #define C (2*r*3.14) // (2*1*3.14), not 6.28 #define mul(a, b) ( (a) * (b) ) mul( 2 + 3 , 5 ); // ( (2 + 3) * (5) ), attention to the space
宏中有两个可以改变token的操作符:#和##。#叫stringify operator,它可以将宏参数字符串化。宏实参可能为token序列,其中可能有字符串,这时将"和串中的\用\转义(非串中的\不转)。#仅作用于宏参数,不可用于一般token。
#define str #hi // not "hi" #define str(a) #a str(\a"hi\!"); // "\a\"hi\\!\""
##叫token pasting operator,用于合并token。它的左右操作对象可以是宏参数,也可以是一般token,它与操作对象间的空白会被去除。##甚至可以连用,串起更多的操作对象。#和##只在宏展开时起作用,如果宏结果中出现#或##,将不再起作用。
#define twoj # ## # // ## #define fun(pre, post) pre##_f_##post fun(res, get)(); // res_f_get()
不带参数的宏叫object-like macro,带参数的叫function-like macro。函数宏的定义中,宏名与()之间不能有空白,参数可为空。调用时宏名与()之间可有空白,而且新规范允许宏实参为空。函数宏定义最好能让使用者自由添加';',见do while语句。
#define add1 (a, b) (a+b) add1(1, 1); // wrong. (a, b) (a+b)(1, 1) #define add2(a, b) (a+b) add2 (1, 1); // ok, 1+1 #define fun1() // ok #define fun2(a, b) add##a##b fun2(); // add fun2(1); // add1 fun2(, 2); // add2
新规范中支持宏的变长参数,只需将末尾的参数用...表示即可。不同于C的变长参数,宏中不需要前导参数。在定义中用_VA_ARGS_代替实参,实参为token序列(包含','),头尾没有空白,中间可能有空白。
#define show(...) printf(#_VA_ARGS_) show( hi, there! ); // printf("hi, there!") #define fun(a, ...) a##_VA_ARGS_ fun(1, 2, 3); // 12, 3 fun(1); // 1
宏展开中最复杂的情况是宏嵌套,但其实只要弄清三点即可:(1)宏实参遇到#或##时,立即产生作用,不再继续展开;(2)其它地方宏实参要先自行展开再带入结果;(3)对结果中出现的曾经完整展开的宏或#、##,不作处理,其它宏则继续展开。结合(1)(2),如果想先展开再做#或##,可以将#或##操作本身包在宏里。以下代码中,展开内层M(0)时外层M尚未完全展开,所以内层M(0)可展开。而f()展开为f后,f()不可再次展开。
#define one 1 #define show(a) printf(#a" = %d\n", a) show(one); // printf("one = %d\n", 1) #define name edward #define str(s) #s #define show(a) printf(str(a)) show(name); // printf("edward") #define M(x) x #define f() f M(M(0)); // 0 f()(); // f()
宏展开是比较靠前执行的,#include和#if指令中都可以使用宏定义。由于整个文件名(包括<>"")是一个token,宏也要定义完整的文件名。宏不可以重定义,除非先#undef或定义完全一样,这里的一样是指参数个数和token序列一样,参数名可以不一样。
#define name1 stdio #define name2 <stdio.h> #include <name1.h> // <name1.h> #include name2 // <stdio.h> #define add() #undef add #define add(a, b) a+b // ok #define add(x, y) x+y // ok #define add(x, y) x + y // illegal
系统提供了一些预定义宏,可以在程序中使用。以下是规范要求必须定义的宏,这些宏不可以#undef。有些不断变化的宏(如__LINE__)其实是系统变量,规范要求每个函数开始都有一个隐藏定义static const char __FUNC__[] = file_name。
Macro | Type | Description |
__FILE__ | "path\name" | 包含路径,实际文件 |
__LINE__ | integer | 实际文件 |
__DATE__ | "Mmm dd yyyy" | 无值的位补0 |
__TIME__ | "hh:mm:ss" | 无值的位补0 |
__STDC__ | 0 or 1 | 是否与规范兼容 |
__STDC_VERSION__ | yyyymmL | 所兼容规范版本 |
__STDC_HOSTED__ | 0 or 1 | 是否有OS |
因为宏只是token替换,它隐含很多不利之处,有时要使用其它替代方法。用宏定义的整型无法在调试时显示,可以用枚举常量替代。宏定义的字符串常量可能产生多份,可以用const string替代。函数宏无参数检查且有副作用,可以用inline函数替代。
4.2 其它指令
#include指令将头文件包含在本unit中,后面跟头文件名。< >包含的文件到库目录中寻找,编译环境一般可以指定该目录。" "包含的文件先从当前目录下查找,再到库中查找,从而可以先使用自定义的库。为了消除重复包含,可以用宏(见示例)或编译器扩展语句。
为增加移植性和灵活性,预处理支持conditional compiling。它一般以#if、#ifdef或#ifndef分支开头,后面跟着0个或多个#elif分支,末尾最多一个#else分支,最后以#endif结束。条件表达式的结果要是整型,可以是整型常量、宏或defined运算符,不可使用字符(串)、浮点常量。defined是预处理的唯一关键字,它有defined(M)和defined M两种形式。
#line指令后跟行号和可选的文件名,这个指令一般用于生成.c文件的文件中,使行号(文件名)指向原始文件,而非.c文件。#error指令后跟任意token序列,这些token不能展开宏。预处理遇到#error会挂起,并且显示token序列。
#pragma指令由编译器自定义行为,STDC开头的指令预留给规范使用,目前已定义了一些功能开关。#pragma中不可以展开宏,新规范中使用关键字_Pragma("command")支持宏展开,它等价于#pragma command。
// headfile, only included once #ifndef HEAD.H #define HEAD.H // ... #endif #if defined X // the same as #ifdef X #elif defined(Y) // ... #elif #else #endif #line 100 #line 100 "\test.c" #define name edward #error fail, name! // show fail, name! #define str(cmd) #cmd _Pragma(str(align(4))); // the same as #pragma align(4)