首页 > 代码库 > 【C】 02 - 程序结构和预处理

【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 1int a[3];// File 2extern 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++) + ba+ ++b;          // a + (++b)a+ + +b;         // illegala plus++b;       // a + (++b)a+ =b;           // illegala plus=b;        // illegala/*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) #astr(\a"hi\!");        // "\a\"hi\\!\""

  ##叫token pasting operator,用于合并token。它的左右操作对象可以是宏参数,也可以是一般token,它与操作对象间的空白会被去除。##甚至可以连用,串起更多的操作对象。#和##只在宏展开时起作用,如果宏结果中出现#或##,将不再起作用。

#define twoj  # ## #                     // ###define fun(pre, post)   pre##_f_##postfun(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##bfun2();                            // addfun2(1);                           // add1fun2(, 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, 3fun(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()      fM(M(0));                                 // 0f()();                                   // 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。

MacroTypeDescription
 __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)
 

【C】 02 - 程序结构和预处理