


一.预处理详解 预处理(Preprocessing) 就是在正式编译之前,对源代码进行文本层面的处理。 可以把它理解成:编译前的"文本替换/准备工作"阶段。打个比方吧。就像你做菜: 预处理 = 洗菜、切菜、备料(准备工作) 编译 = 开火炒菜(正式烹饪) 链接 = 装盘上桌(整合成品) 预处理只负责准备材料,不涉及任何真正的代码逻辑检查或生成机器码。 1. 预定义符号 C语⾔设置了⼀些预定义符号,可以直接使⽤,预定义符号也是在预处理期间处理的。 __FILE__ //进⾏编译的源⽂件 __LINE__ //⽂件当前的⾏号 __DATE__ //⽂件被编译的⽇期 __TIME__ //⽂件被编译的时间 __STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义 但是我一旦取消注释,运行如下: __STDC__ 是 ANSI C 标准要求的一个宏,用来表明编译器是否完全遵循 ANSI C 标准。 但 微软的 MSVC 编译器并没有完全遵循 ANSI C 标准,所以它干脆不定义这个宏,而不是定义为 0。 解决方法如下: 条件编译包起来 #ifdef __STDC__ printf("%d\n", __STDC__); #else printf("该编译器不遵循ANSI C标准\n"); #endif 这样在 VS 里会走 #else 分支,不会报错。 运行如下: 为什么需要这些预定义符号? 一个程序如果特别复杂,比如有几千行代码、几十个函数,一旦出 bug:不知道哪里崩了,不知道是哪个文件出的问题,不知道出问题的时候程序运行到什么状态,这时候再打开调试器一步一步跟,根本无从下手,尤其是在客户现场,你连调试环境都没有。 解决办法让程序自己"说话" —— 在关键位置记录日志,把以下信息写下来:什么时候出的问题(时间),在哪个文件(文件名),在哪个函数(函数名),在第几行(行号) 有了这些信息,就像装了行车记录仪,出问题翻日志一看,位置清清楚楚,排查效率翻倍。 注意:__DATE__ 和 __TIME__ 是编译时确定的,记录的是"这个程序是什么时候编译的"。 写入日志文件(实战应用) #include
int main() { int i = 0; FILE* pf = fopen("test.txt", "a+"); if (pf == NULL) { perror("fopen"); return 1; } for (i = 0; i < 5; i++) { printf("* 错误日志 %d *\n", i + 1); printf("发生时间:%s %s\n", __DATE__, __TIME__); printf("具体位置:%s,函数名为%s,第%d行。\n", __FILE__, __FUNCTION__, __LINE__); printf("\n"); fprintf(pf, "* 错误日志 %d *\n", i + 1); fprintf(pf, "发生时间:%s %s\n", __DATE__, __TIME__); fprintf(pf, "具体位置:%s,函数名为%s,第%d行。\n", __FILE__, __FUNCTION__, __LINE__); fprintf(pf, "\n"); } fclose(pf); pf = NULL; return 0; } 2. #define 定义常量 a.#define 定义标识符 基本语法: #define name stuff 举个例子: #include #define NUM 100 int main() { int a = NUM; printf("%d\n", a); return 0; } 预处理阶段会把 NUM 替换成 100,预处理后 int a = NUM 就变成 int a = 100 了。 其他用法: #define MAX 1000 #define reg register // 给 register 起个短名字 #define forever for(;;) // 用更形象的符号表示死循环 #define BRK_CASE break;case // case 语句自动加 break 使用的时候: reg int x = 0; // 相当于 register int x = 0; forever; // 相当于 for(;;); 死循环 switch(n) { case 1: printf("1\n"); BRK_CASE 2: // 相当于 break; case 2: printf("2\n"); BRK_CASE 3: // 相当于 break; case 3: printf("3\n"); } 续行符: 如果定义的东西太长,可以分成几行写,除了最后一行,每行末尾加反斜杠 \。 #define PRINT_LOG printf("file:%s\tline:%d\t\t" \ "date:%s\ttime:%s\n", \ __FILE__, __LINE__, __DATE__, __TIME__) 注意:\ 后面啥都不能有,空格也不行。 注意:末尾不要加分号 #define MAX 1000; // 不要这样写 加了分号容易出问题,比如: if (x > y) max = MAX; // 展开后 max = 1000;; 两条语句 else max = 0; // else 找不到配对的 if,报错 所以养成习惯,末尾不加分号。 完整代码如下: #include #define NUM 100 #define reg register #define forever for(;;) #define BRK_CASE break;case #define LOG printf("file:%s\tline:%d\t\t" \ "date:%s\ttime:%s\n", \ __FILE__, __LINE__, __DATE__, __TIME__) int main() { int a = NUM; printf("a = %d\n", a); reg int x = 0; printf("x = %d\n", x); LOG; int n = 2; switch (n) { case 1: printf("1\n"); BRK_CASE 2: printf("2\n"); BRK_CASE 3: printf("3\n"); } // forever; // 死循环,想看可以放开注释 return 0; } 放开后的状态: b. #define定义宏 #define 机制包括了⼀个规定,允许把参数替换到⽂本中,这种实现通常称为宏(macro)或定义宏(define macro)。 下⾯是宏的申明⽅式: #define name( parament-list ) stuff 其中的 parament-list 是⼀个由逗号隔开的符号表,它们可能出现在stuff中。 注意: 参数列表的左括号必须与name紧邻,如果两者之间有任何空⽩存在,参数列表就会被解释为stuff的⼀部分。 举例: #define SQUARE( x ) x * x 1.基本用法 #include #define SQUARE(X) X*X int main() { printf("%d\n", SQUARE(2)); // 预处理后变成 printf("%d\n", 2*2); return 0; } 运行结果:4 预处理时,SQUARE(2) 中的 2 会替换掉 X,变成 2*2。 2.宏的缺陷 —— 参数不会先计算 #include #define SQUARE(X) X*X int main() { printf("%d\n", SQUARE(1+1)); return 0; } 运行结果:3 你以为:(1+1)*(1+1) = 4 实际上:1+1*1+1 = 3 宏只是文本替换,不会提前计算参数。替换完再计算,运算符优先级会影响结果。 3.正确写法 —— 加括号 #include #define SQUARE(X) ((X)*(X)) int main() { printf("%d\n", SQUARE(1+1)); // ((1+1)*(1+1)) = 4 return 0; } 运行结果:4 4.再举另一个例子 —— DOUBLE #include #define DOUBLE(X) (X)+(X) int main() { printf("%d\n", 2 * DOUBLE(1+1)); // 2 * (2)+(2) = 6 return 0; } 运行结果:6 你以为:2 * 4 = 8 实际上:2 * 2 + 2 = 6 宏外面有运算符时,也会影响结果。 5.正确写法 —— 整体也加括号 #include #define DOUBLE(X) ((X)+(X)) int main() { printf("%d\n", 2 * DOUBLE(1+1)); // 2 * ((2)+(2)) = 8 return 0; } 运行结果:8 6.结论 用于数值计算的宏,每个参数加括号,整体也加括号,才能避免运算符优先级带来的问题。 #define SQUARE(X) ((X)*(X)) // 正确 #define DOUBLE(X) ((X)+(X)) // 正确 7.宏的替换规则 调用宏时,先检查参数里有没有 #define 定义的符号,有的话先替换 替换文本插入到代码中,参数会被对应的值替换 8.完整代码 #include // 正确写法 #define SQUARE(X) ((X)*(X)) #define DOUBLE(X) ((X)+(X)) int main() { printf("SQUARE(2) = %d\n", SQUARE(2)); printf("SQUARE(1+1) = %d\n", SQUARE(1+1)); printf("2 * DOUBLE(1+1) = %d\n", 2 * DOUBLE(1+1)); return 0; } 提⽰: 所以⽤于对数值表达式进⾏求值的宏定义都应该⽤这种⽅式加上括号,避免在使⽤宏时由于参数中的操作符或邻近操作符之间不可预料的相互作⽤。 c. 带有副作⽤的宏参数 当宏参数在宏的定义中出现超过⼀次的时候,如果参数带有副作⽤,那么你在使⽤这个宏的时候就可能出现危险,导致不可预测的后果。副作⽤就是表达式求值的时候出现的永久性效果。 例如: x+1;//不带副作⽤ x++;//带有副作⽤ MAX宏可以证明具有副作⽤的参数所引起的问题。 为什么是这个结果? 宏展开后变成: z = ((x++) > (y++) ? (x++) : (y++)); 一步步分析: 判断 (x++) > (y++) x++ 的值是 5,然后 x 变成 6 y++ 的值是 8,然后 y 变成 9 5 > 8 为假,所以走 : 后面的 (y++) 执行 (y++) 取 y 当前值 9 赋给 z,然后 y 变成 10 最终结果: x = 6(自增了1次); y = 10(自增了2次); z = 9 如果用函数就不会有这个问题 #include int max(int a, int b) { return a > b ? a : b; } int main() { int x = 5; int y = 8; int z = max(x++, y++); printf("x=%d y=%d z=%d\n", x, y, z); return 0; } 运行结果: x=6 y=9 z=8 函数传参时会先计算 x++ 和 y++ 的值,各自增1次,然后比较,不会有多次自增的问题。 简单概括一下:宏参数不要用 ++、-- 这种带副作用的表达式,否则结果不可控。 d. 宏替换的规则 在程序中扩展#define定义符号和宏时,需要涉及⼏个步骤。 在调⽤宏时,⾸先对参数进⾏检查,看看是否包含任何由#define定义的符号。如果是,它们⾸先被替换。替换⽂本随后被插⼊到程序中原来⽂本的位置。对于宏,参数名被他们的值所替换。最后,再次对结果⽂件进⾏扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。 注意: 1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。 2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。 e. 宏函数的对⽐ 宏通常被应⽤于执⾏简单的运算。 ⽐如在两个数中找出较⼤的⼀个时,写成下⾯的宏,更有优势⼀些。 #define MAX(a, b) ((a)>(b)?(a):(b)) 那为什么不⽤函数来完成这个任务? ⽤于调⽤函数和从函数返回的代码可能⽐实际执⾏这个⼩型计算⼯作所需要的时间更多。所以宏⽐函数在程序的规模和速度⽅⾯更胜⼀筹。更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使⽤。反之这个宏怎可以适⽤于整形、⻓整型、浮点型等可以⽤于 > 来⽐较的类型。宏的参数是类型⽆关的。 和函数相⽐宏的劣势: 每次使⽤宏的时候,⼀份宏定义的代码将插⼊到程序中。除⾮宏⽐较短,否则可能⼤幅度增加程序的⻓度。宏是没法调试的。宏由于类型⽆关,也就不够严谨。宏可能会带来运算符优先级的问题,导致程容易出现错。 宏有时候可以做函数做不到的事情。⽐如:宏的参数可以出现类型,但是函数做不到。 #include #include #define MALLOC(num, type) (type*)malloc((num) * sizeof(type)) int main() { // 分配10个int大小的空间 int* p = MALLOC(10, int); // 使用这块内存 for (int i = 0; i < 10; i++) { p[i] = i + 1; } for (int i = 0; i < 10; i++) { printf("%d ", p[i]); } printf("\n"); // 释放内存 free(p); p = NULL; return 0; } 宏展开后 int* p = (int*)malloc((10) * sizeof(int)); MALLOC(10, int) --> (int*)malloc((10) * sizeof(int)) 也可以分配其他类型 // 分配5个double double* d = MALLOC(5, double); // 分配20个char char* c = MALLOC(20, char); 注意事项: 加括号的重要性: #define MALLOC(num, type) (type*)malloc((num) * sizeof(type)) 如果 num 传的是 3+2,不加括号就会变成 3+2 * sizeof(type),结果就不对了。宏定义时每个参数加括号,整体加括号,养成习惯。 宏和函数的⼀个对⽐ 如果一个运算的逻辑足够简单,建议使用宏。反之,如果一个运算的逻辑足够复杂,建议使用函数。 3. #和## #运算符 #运算符将宏的⼀个参数转换为字符串字⾯量。它仅允许出现在带参数的宏的替换列表中。 #运算符所执⾏的操作可以理解为”字符串化“。 当我们有⼀个变量 int a = 10; 的时候,我们想打印出: the value of a is 10 . 就可以写: #define PRINT(n) printf("the value of "#n " is %d", n); 当我们按照下⾯的⽅式调⽤的时候: PRINT(a);//当我们把a替换到宏的体内时,就出现了#a,⽽#a就是转换为"a",时⼀个字符串 代码就会被预处理为: printf("the value of ""a" " is %d", a); 运⾏代码就能在屏幕上打印: the value of a is 10 代码如下: 1.基本用法 #include #define PRINT(n) printf("the value of " #n " is %d\n", n); int main() { int a = 10; PRINT(a); // 预处理后变成 printf("the value of " "a" " is %d\n", a); // 三个字符串自动拼接成 "the value of a is %d" return 0; } 2.分析如下: 调用 PRINT(a) 把 a 传给宏参数 n; #n 变成 "a" 参数被字符串化; 三个字符串拼接 "the value of " + "a" + " is %d" = "the value of a is %d"; # 运算符把参数变成字符串,这个过程叫"字符串化"。 3.更多示例 #include #define PRINT_INT(n) printf(#n " = %d\n", n) #define PRINT_STR(s) printf(#s " = %s\n", s) #define PRINT_FLOAT(f) printf(#f " = %.2f\n", f) int main() { int age = 18; PRINT_INT(age); float score = 95.5; PRINT_FLOAT(score); char name[] = "小明"; PRINT_STR(name); return 0; } 运行结果: 4.通用打印宏 #include #define PRINT_VALUE(n, format) printf(#n " = " format "\n", n) int main() { int a = 10; float b = 3.14; char c = 'A'; char str[] = "hello"; PRINT_VALUE(a, "%d"); PRINT_VALUE(b, "%.2f"); PRINT_VALUE(c, "%c"); PRINT_VALUE(str, "%s"); return 0; } 运行结果: 5.# 的注意事项 正确写法 #define PRINT(n) printf(#n " = %d\n", n) 错误写法 #define PRINT(n) printf(# n " = %d\n", n) // # 和 n 之间有空格 #define PRINT(n) printf(#n = %d\n", n) // 忘记加引号 # 和参数之间可以有空格,但不建议这样写。 小结:# 就是把参数变成字符串,配合相邻字符串自动拼接的特性,可以灵活构造输出格式。 ## 运算符 ## 可以把位于它两边的符号合成⼀个符号,它允许宏定义从分离的⽂本⽚段创建标识符。 ## 被称为记号粘合这样的连接必须产⽣⼀个合法的标识符。否则其结果就是未定义的。 这⾥我们想想,写⼀个函数求2个数的较⼤值的时候,不同的数据类型就得写不同的函数。 ⽐如: int int_max(int x, int y) { return x>y?x:y; } float float_max(float x, float y) { return x>yx:y; } 但是这样写起来太繁琐了,现在我们这样写代码试试: //宏定义 #define GENERIC_MAX(type) \ type type##_max(type x, type y)\ { \ return (x>y?x:y); \ } 使⽤宏,定义不同函数 GENERIC_MAX(int) //替换到宏体内后int##_max ⽣成了新的符号 int_max做函数名 GENERIC_MAX(float) //替换到宏体内后float##_max ⽣成了新的符号 float_max做函数名 int main() { //调⽤函数 int m = int_max(2, 3); printf("%d\n", m); float fm = float_max(3.5f, 4.5f); printf("%f\n", fm); return 0; } 完整代码如下: #include // 宏定义:生成比较大小的函数 #define GENERIC_MAX(type) \ type type##_max(type x, type y) \ { \ return (x > y ? x : y); \ } // 使用宏生成 int_max 函数 GENERIC_MAX(int) // 使用宏生成 float_max 函数 GENERIC_MAX(float) int main() { int m = int_max(2, 3); printf("int_max(2, 3) = %d\n", m); float fm = float_max(3.5f, 4.5f); printf("float_max(3.5f, 4.5f) = %f\n", fm); return 0; } 运行结果: 宏展开后的代码 // GENERIC_MAX(int) 展开后 int int_max(int x, int y) { return (x > y ? x : y); } // GENERIC_MAX(float) 展开后 float float_max(float x, float y) { return (x > y ? x : y); } 继续添加其他类型 GENERIC_MAX(char) GENERIC_MAX(double) int main() { char c = char_max('a', 'z'); printf("char_max('a', 'z') = %c\n", c); double d = double_max(3.14, 2.71); printf("double_max(3.14, 2.71) = %f\n", d); return 0; } 运行结果: 用宏可以批量生成相似功能的函数,省去重复写代码的麻烦。## 把 int 和 _max 拼成 int_max,float 拼成 float_max。 4.命名约定 ⼀般来讲函数的宏的使⽤语法很相似。所以语⾔本⾝没法帮我们区分⼆者。那我们平时的⼀个习惯是: 把宏名全部⼤写; 函数名不要全部⼤写 5.#undef —— 移除宏定义 a.基本用法 #include #define NUM 100 int main() { printf("NUM = %d\n", NUM); #undef NUM // 移除 NUM 的定义 // printf("%d\n", NUM); // 这行会报错,因为 NUM 已经被移除了 return 0; } 运行结果: #undef 就是取消之前定义的宏,让这个宏名字失效。 取消注释再看 b.为什么需要重新定义? 如果某个宏名字想重新定义成新值,必须先 #undef 移除旧的,否则编译器会报警告。 #include #define NUM 100 #define NUM 200 // 直接重定义,编译器会警告 int main() { printf("%d\n", NUM); return 0; } 可以运行,结果如下: 编译器警告: warning C4005: “NUM”: 宏重定义 补充: 各编译器表现 编译器默认行为VS(MSVC)默认不警告,直接通过gcc默认警告,但能编译通过clang默认警告,但能编译通过 原因: C语言标准规定:宏重定义是合法的,只要新定义和旧定义"完全相同"就可以。 但我的 100 和 200 不一样,严格来说属于未定义行为,编译器可以报错、报警告、或者什么都不报。VS 选择了"什么都不报",让你直接过。 c.正确做法:先移除再重新定义 先 #undef 再重定义(推荐) #include #define NUM 100 #undef NUM // 先移除 #define NUM 200 // 再重定义 int main() { printf("%d\n", NUM); return 0; } 6.命令行定义 a.什么是命令行定义? 许多 C 编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。 作用: 根据同一个源文件,编译出程序的不同版本。 b.使用场景 假设程序中声明了一个数组,长度需要根据机器内存大小调整: 内存小的机器 -->数组小一点 内存大的机器 --> 数组大一点 不用改代码,直接在编译时指定大小就行。 c.完整代码 #include int main() { int array[ARRAY_SIZE]; int i = 0; for (i = 0; i < ARRAY_SIZE; i++) { array[i] = i; } for (i = 0; i < ARRAY_SIZE; i++) { printf("%d ", array[i]); } printf("\n"); return 0; } 我们并没有直接再代码里对于ARRAY_SIZE进行宏定义,而是在编译指令进行的。 拆开来看 部分含义gcc调用 GCC 编译器-D ARRAY_SIZE=10在命令行定义宏,相当于在代码里写了 #define ARRAY_SIZE 10programe.c要编译的源文件-o programe指定输出文件名为 programe(不写的话默认是 a.out) 常见用法总结 命令说明gcc programe.c编译,生成 a.outgcc programe.c -o programe编译,生成 programegcc -D ARRAY_SIZE=10 programe.c -o programe定义宏 + 编译 + 指定输出名gcc -D DEBUG programe.c -o programe定义 DEBUG 宏(不赋值) -D 是在命令行定义宏,-o 是指定输出文件名。 编译指令: gcc -D ARRAY_SIZE=10 programe.c -o programe ./programe 7.条件编译 a.什么是条件编译? 在编译程序时,如果我们想要一条语句(一组语句)编译或者放弃,有条件地选择编译哪些代码,就可以使用条件编译指令。 使用场景: 调试性的代码,删了可惜,留着碍事 不同平台需要编译不同的代码 某些功能只在特定版本中开启 b.基础用法 #ifdef / #endif #include #define _DEBUG // 相当于开关,定义了就是开 int main() { int arr[10] = {0}; int i = 0; for (i = 0; i < 10; i++) { arr[i] = i; } #ifdef _DEBUG printf("%d ", arr[i]); // _DEBUG 定义了,所以这段代码会被编译 #endif return 0; } 运行结果: 把 #define _DEBUG 注释掉 #include // #define _DEBUG // 注释掉,相当于关 int main() { int arr[10] = {0}; int i = 0; for (i = 0; i < 10; i++) { arr[i] = i; } #ifdef _DEBUG printf("%d ", arr[i]); // _DEBUG 没定义,这段代码不会被编译 #endif return 0; } 运行结果:(没有输出) c.常见的条件编译指令 1. 单分支条件编译 text #if 常量表达式 // 常量表达式由预处理器求值 // 为真则编译,为假则不编译 #endif 2. 多分支条件编译 text #if 常量表达式 // 代码块 #elif 常量表达式 // 代码块 #else // 代码块 #endif 3. 判断是否被定义 text #if defined(symbol) // 如果被定义 #ifdef symbol // 如果被定义(等价写法) #if !defined(symbol) // 如果未被定义 #ifndef symbol // 如果未被定义(等价写法) 4. 嵌套指令 text #if defined(OS_UNIX) #ifdef OPTION1 unix_version_option1(); #endif #ifdef OPTION2 unix_version_option2(); #endif #elif defined(OS_MSDOS) #ifdef OPTION2 msdos_version_option2(); #endif #endif 指令含义#ifdef 宏名如果宏被定义了,编译下面的代码#ifndef 宏名如果宏没有被定义,编译下面的代码#if 常量表达式如果表达式为真,编译下面的代码#else否则编译下面的代码#elif否则如果#endif结束条件编译块 d.完整用法如下: 1. #if / #endif —— 条件成立则编译 #include int main() { #if 1 // 1 为真,编译下面的代码 printf("hehe\n"); #endif #if 0 // 0 为假,下面的代码不编译 printf("haha\n"); #endif return 0; } 运行结果: 2. #if / #elif / #else —— 多条件分支 #include int main() { #if 1 == 1 printf("hehe\n"); #elif 1 == 2 printf("haha\n"); #else printf("heihei\n"); #endif return 0; } 运行结果: 3. #ifdef / #endif —— 如果宏被定义 #include #define TEST int main() { #ifdef TEST // TEST 被定义了,编译 printf("test\n"); #endif return 0; } 运行结果: 3.1 用 #if defined() #include #define NAME int main() { #if defined(NAME) // NAME 被定义了,编译 printf("name\n"); #endif return 0; } 运行结果: 4. #ifndef / #endif —— 如果宏没被定义 #include // #define HEHE // 注释掉,HEHE 没定义 int main() { #ifndef HEHE // HEHE 没定义,编译 printf("hehe\n"); #endif return 0; } 运行结果: 4.1 用 #if !defined() #include // #define NAME2 // 注释掉,NAME2 没定义 int main() { #if !defined(NAME2) // NAME2 没定义,编译 printf("name2\n"); #endif return 0; } 运行结果: 5. 嵌套条件编译 #include #define OS_UNIX // 模拟 Unix 系统 #define OPTION1 // 开启选项1 // #define OPTION2 // 关闭选项2 int main() { #if defined(OS_UNIX) printf("Unix 系统\n"); #ifdef OPTION1 printf("Unix 选项1 开启\n"); #endif #ifdef OPTION2 printf("Unix 选项2 开启\n"); #endif #elif defined(OS_MSDOS) printf("MSDOS 系统\n"); #ifdef OPTION2 printf("MSDOS 选项2 开启\n"); #endif #endif return 0; } 运行结果: 8.头文件的包含 8.1 头文件被包含的方式: 8.1.1 本地文件包含 #include "filename.h" 查找策略: 先在源文件所在目录下查找,如果找不到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果还找不到就提示编译错误。 Linux环境的标准头文件路径: /usr/include VS环境的标准头文件路径: C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include 注意按照自己的安装路径去找,VS版本不同路径也不一样。 8.1.2 库文件包含 #include 查找策略: 直接去标准路径下查找,找不到就提示编译错误。 8.1.3 关于两种包含方式 有同学会问:库文件能不能用 " " 包含? 答案是可以的,但是这样做查找效率低一些,而且不容易区分是库文件还是本地文件。所以建议库文件用 <>,本地文件用 ""。 8.2 嵌套文件包含 #include 指令会让另一个文件被编译,就像它实际出现在 #include 指令的地方一样。预处理器先删除这条指令,并用包含文件的内容替换。 问题: 一个头文件被包含10次,就实际被编译10次。如果工程比较大,有公共使用的头文件被大家都能使用,又不做任何处理,后果很严重。 举个例子: test.c: #include "test.h" #include "test.h" #include "test.h" #include "test.h" #include "test.h" int main() { return 0; } test.h: void test(); struct Stu { int id; char name[20]; }; 这样写,test.h 的内容会被拷贝5份在 test.c 中。如果 test.h 文件比较大,预处理后代码量会剧增。 怎么解决? 用条件编译。 8.3 解决头文件重复包含的方法 方法一:头文件守卫 在头文件开头写: test.h: #ifndef __TEST_H__ #define __TEST_H__ // 头文件的内容 void test(); struct Stu { int id; char name[20]; }; #endif // __TEST_H__ 原理: 第一次包含时,__TEST_H__ 没定义,就定义它,然后包含头文件内容 第二次再包含时,__TEST_H__ 已经定义了,就不重复包含了 方法二:#pragma once(简洁) test.h: #pragma once // 头文件的内容 void test(); struct Stu { int id; char name[20]; }; #pragma once 作用是让编译器只包含该头文件一次,和头文件守卫效果一样,但写法更简洁。 区别: #ifndef 是标准C语言写法,所有编译器都支持 #pragma once 不是C标准,但大部分编译器都支持(VS、gcc、clang都行) 8.4 常见笔试题 1. 头文件中的 #ifndef / #define / #endif 是干什么用的? 答:为了防止头文件被重复包含,避免编译时重复编译同一份代码。 2. #include 和 #include "filename.h" 有什么区别? 答: <> :直接去标准库路径查找头文件 "" :先在当前源文件所在目录查找,找不到再去标准库路径查找 嵌套文件包含补充: 项目文件 假设有这些文件: comm.h // 公共模块 comm.c test1.h // 模块1 test1.c test2.h // 模块2 test2.c test.h // 主模块 test.c 包含关系 test1.c 和 test2.c 都用了 comm.h 里的东西。 test.c 又同时包含了 test1.h 和 test2.h。 这样一来,comm.h 的内容就被包含了两遍。 但是这里有问题 预处理的时候 #include 就是直接把头文件内容复制进来。 comm.h 被复制了一次,又被复制了一次,最后 test.c 里就有两份 comm.h 的内容。 会造成的影响 代码量变大了,编译变慢 如果 comm.h 里有结构体定义或者函数声明,重复定义会直接编译报错 解决方法 在 comm.h 开头加上这两行: #ifndef __COMM_H__ #define __COMM_H__ // 头文件内容写这里 #endif 第一次包含的时候 __COMM_H__ 没定义,就定义它,然后把头文件内容复制进来。第二次再包含的时候 __COMM_H__ 已经有了,就跳过不复制了。也可以用 #pragma once,写起来更简单。 头文件被重复包含会出问题,用头文件守卫(#ifndef + #define + #endif)或者 #pragma once 就能解决。 9.其他预处理指令 1. #error —— 强制报错 #error 错误信息 预处理阶段报错,终止编译。 举例: #ifndef DEBUG #error "请先定义 DEBUG 再编译" #endif 2. #pragma —— 编译器指令 2.1 #pragma once 防止头文件重复包含,等价于: #ifndef __HEADER_H__ #define __HEADER_H__ // 内容 #endif 2.2 #pragma warning 控制警告: #pragma warning(disable: 4996) // 忽略某个警告 #pragma warning(once: 4996) // 只警告一次 2.3 #pragma pack() 设置结构体对齐,结构体部分再细讲。 3. #line —— 修改行号和文件名 #line 100 // 改为第100行 #line 200 "new.c" // 改为第200行,文件名为 new.c 举例: printf("%d\n", __LINE__); // 假设是第5行 #line 100 printf("%d\n", __LINE__); // 输出 100 #line 200 "new.c" printf("%s\n", __FILE__); // 输出 new.c 表格小结一下: 指令作用#error预处理时报错#pragma once头文件只包含一次#pragma warning控制警告#pragma pack(n)设置对齐#line修改行号和文件名 二.编译和链接 1. 翻译环境和运⾏环境 在 ANSI C 的任何⼀种实现中,存在两个不同的环境。 第1种是翻译环境,在这个环境中源代码被转换为可执⾏的机器指令(⼆进制指令)。 第2种是执⾏环境,它⽤于实际执⾏代码。 2. 翻译环境 那翻译环境是怎么将源代码转换为可执⾏的机器指令的呢?这⾥我们就得展开开讲解⼀下翻译环境所做的事情。 其实翻译环境是由编译和链接两个⼤的过程组成的,⽽编译⼜可以分解成:预处理(有些书也叫预编译)、编译、汇编三个过程。 C语言代码的执行过程 a. 电脑能直接执行C语言代码吗? 不能。 计算机只能执行二进制的指令,而C语言代码是文本信息。 所以需要把C语言代码转换成二进制的指令,这个转换工作由编译器来完成。 b. 基本流程 C语言代码(文本) ----编译器---> 二进制指令(可执行文件) 文件说明test.c源文件(文本,C语言代码)test.h头文件(文本,声明等内容) ⼀个C语⾔的项⽬中可能有多个 .c ⽂件⼀起构建,那多个 .c ⽂件如何⽣成可执⾏程序呢? 多个.c⽂件单独经过编译器,编译处理⽣成对应的⽬标⽂件。 注:在Windows环境下的⽬标⽂件的后缀是 .obj ,Linux环境下⽬标⽂件的后缀是 .o 多个⽬标⽂件和链接库⼀起经过链接器处理⽣成最终的可执⾏程序。 链接库是指运⾏时库(它是⽀持程序运⾏的基本函数集合)或者第三⽅库。 如果再把编译器展开成3个过程,那就变成了下⾯的过程: 2.1 预处理(预编译) 在预处理阶段,源⽂件和头⽂件会被处理成为 .i 为后缀的⽂件。 在 gcc 环境下想观察⼀下,对 test.c ⽂件预处理后的.i⽂件,命令如下: gcc -E test.c -o test.i 预处理阶段主要处理那些源⽂件中#开始的预编译指令。⽐如:#include,#define,处理的规则如下: 将所有的 #define 删除,并展开所有的宏定义。 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。 处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进⾏的,也就是说被包含的头⽂件也可能包含其他⽂件。 删除所有的注释 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。 或保留所有的#pragma的编译器指令,编译器后续会使⽤。 经过预处理后的 .i ⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到 .i ⽂件中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的 .i ⽂件 来确认。 2.2 编译 编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的汇编代码⽂件。 编译过程的命令如下: gcc -S test.i -o test.s 对下⾯代码进⾏编译的时候,会怎么做呢?假设有下⾯的代码 array[index] = (index+4)*(2+6); a. 词法分析 将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀列的记号(关键字、标识符、字⾯量、特殊字符等)。 上⾯程序进⾏词法分析后得到了16个记号: b.语法分析 接下来语法分析器,将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树。这些语法树是以表达式为节点的树。 c.语义分析 由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。 2.3 汇编 汇编器是将汇编代码转转变成机器可执⾏的指令,每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。 汇编的命令如下: gcc -c test.s -o test.o 2.4 链接 链接是⼀个复杂的过程,链接的时候需要把⼀堆⽂件链接在⼀起才⽣成可执⾏程序。链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。链接解决的是⼀个项⽬中多⽂件、多模块之间互相调⽤的问题。 ⽐如: 在⼀个C的项⽬中有2个.c⽂件( test.c 和 add.c ),代码如下 文件结构 这是一个多文件项目,包含两个源文件: test.c:主程序文件,包含 main 函数 add.c:功能模块文件,包含 Add 函数和全局变量 test.c #include // 声明外部函数 extern int Add(int x, int y); // 声明外部的全局变量 extern int g_val; int main() { int a = 10; int b = 20; int sum = Add(a, b); printf("%d\n", sum); return 0; } add.c int g_val = 2022; int Add(int x, int y) { return x + y; } 说明 关键词作用extern声明外部符号(其他文件中的函数或变量)int Add(int x, int y);告诉编译器这个函数在其他文件中定义extern int g_val;告诉编译器这个变量在其他文件中定义 编译链接过程: test.c --(编译)--> test.obj add.c --(编译)--> add.obj test.obj + add.obj --(链接)--> 可执行文件(.exe) 一个项目可以分多个源文件,用 extern 声明其他文件的函数和变量,编译后链接在一起就成了可执行程序。 详细说明解析如下: 我们已经知道,每个源⽂件都是单独经过编译器处理⽣成对应的⽬标⽂件。 test.c 经过编译器处理⽣成 test.o add.c 经过编译器处理⽣成 add.o 我们在 test.c 的⽂件中使⽤了 add.c ⽂件中的 Add 函数和 g_val 变量。 我们在 test.c ⽂件中每⼀次使⽤ Add 函数和 g_val 的时候必须确切的知道 Add 和 g_val 的地 址,但是由于每个⽂件是单独编译的,在编译器编译 test.c 的时候并不知道 Add 函数和 g_val变量的地址,所以暂时把调⽤ Add 的指令的⽬标地址和 g_val 的地址搁置。 等待最后链接的时候由链接器根据引⽤的符号 Add 在其他模块中查找 Add 函数的地址,然后将 test.c 中所有引⽤到Add 的指令重新修正,让他们的⽬标地址为真正的 Add 函数的地址,对于全局变量 g_val 也是类似的⽅法来修正地址。这个地址修正的过程也被叫做:重定位。 图解如下: 3. 运行环境 1. 程序必须载入内存中 程序没运行的时候是躺在硬盘上的,要运行必须先加载到内存里。 有操作系统的环境(比如 Windows、Linux):这个加载工作由操作系统完成 独立环境(比如单片机):需要手动安排,或者直接把可执行代码烧录到只读内存里 2. 开始执行 程序加载到内存后,就开始执行了,首先调用 main 函数,程序从这里正式跑起来。 3. 执行程序代码 程序运行的时候会用到一些内存空间: 运行时堆栈(stack):用来存函数的局部变量和返回地址,函数调用和返回都靠它 静态内存(static):用来存静态变量,这些变量在程序整个运行期间一直存在,不会消失 4. 终止程序 正常终止:main 函数执行完了,return 返回 意外终止:程序出错了,比如数组越界、访问空指针之类的 5.大概流程 加载到内存 --> 调用 main -->执行代码(栈存局部变量,静态内存存静态变量)--> 程序结束 小结一下:程序先加载到内存,从 main 开始跑,局部变量放栈里,静态变量单独占一块,跑完就结束。 三.简述VS2022 VS2022 是什么? VS2022 是一个集成开发环境,可以理解为它是一个大礼包,里面包含了开发程序所需要的各种工具。它是一个集成开发环境,缩写叫 IDE。 简单说,你下载安装的 VS2022 这个软件,它本身就是一个 IDE,里面自带了很多工具。 VS2022 包含了什么? 它里面自带了很多工具,主要有: 编辑器:用来写代码(你敲代码的那个窗口) 编译器(cl.exe):负责把 C/C++ 代码翻译成机器指令 链接器(link.exe):负责把多个目标文件和库文件合并在一起,生成可执行程序 调试器:用来调试程序,找 bug.