1. 程序编译的四个阶段

C程序的编译过程包含了四个主要阶段,它们是:

  1. 预处理(Preprocessing):在这个阶段,预处理器会处理源代码中的预处理指令,包括宏展开、文件包含、条件编译等。

  2. 编译(Compilation):在编译阶段,编译器将预处理后的代码转换为汇编语言或机器语言。

  3. 汇编(Assembly):在汇编阶段,汇编器将目标代码转换为可重定位目标文件(Object File)。

  4. 链接(Linking):在链接阶段,链接器将可重定位目标文件和库文件进行链接,生成最终的可执行文件。

gcc

这四个阶段通常顺序进行,每个阶段都有各自的工具和处理过程。整个编译过程将源代码转换为可以在特定平台上运行的可执行文件。

在C程序的预处理阶段,主要有以下几个任务:

  1. 宏替换:预处理器会根据#define指令定义的宏,将源代码中的宏标识符替换为相应的宏定义文本。这个过程称为宏替换或宏展开。宏替换可以用来简化代码、增加可读性,以及定义常量或函数宏等。

  2. 头文件展开:预处理器会根据#include指令将指定的头文件内容插入到源代码中。头文件中通常包含了函数原型、宏定义、结构体声明等,这样可以将相同的声明和定义在多个源文件中共享,提高了代码的复用性。

  3. 条件编译:预处理器通过条件编译指令(如#ifdef#ifndef#if等)对代码的编译进行条件判断。根据不同的条件结果,选择性地编译或排除一部分代码。条件编译可以用来实现不同平台的兼容性、调试模式和发布模式的切换等。

  4. 注释删除:预处理器会删除源代码中的注释部分,包括单行注释//和多行注释/* */,因为注释对于编译器来说是无效的代码。

  5. 其他预处理指令处理:预处理器还可以处理和执行其他的预处理指令,如#pragma指令用于向编译器发出特定的指令或提示。

预处理阶段的主要目标是对源代码进行处理和转换,生成经过预处理的代码,为后续的编译阶段做准备。

2. 预处理指令

在C语言中,预处理指令是以#字符开头的指令,用于在编译过程之前对源代码进行一些处理。以下是一些常见的预处理指令:

  1. #include:用于包含头文件,将指定的文件内容插入到当前位置。

  2. #define:用于定义宏,将一个标识符替换为指定的文本。

  3. #ifdef / #ifndef / #endif:用于条件编译,可以根据条件判断选择性地编译一段代码。

  4. #if / #else / #elif / #endif:用于条件编译,可以根据条件判断选择性地编译一段代码,可以使用表达式进行条件判断。

  5. #pragma:用于向编译器发出特定的指令或提示。

2.1 头文件包含

#include 是C语言中的一个预处理指令,用于包含头文件(Header File)。通过使用#include指令,可以将指定的头文件的内容插入到源代码中,以便在代码中可以使用该头文件中定义的函数、结构体、常量等。

一般形式为:

1
2
#include <header_file>
#include "header_file"
  • <header_file> :使用尖括号 <> 包围的头文件是系统提供的标准头文件,预处理器会在系统的标准目录中查找这些头文件,例如:

    1
    2
    #include <stdio.h>
    #include <stdlib.h>
  • "header_file":使用双引号 "" 包围的头文件是用户自定义的头文件,预处理器会在当前文件所在目录或指定的搜索路径中查找这些头文件,例如:

    1
    2
    3
    #include "myheader.h"
    #include "test/utils.h"
    #include "../calc.h"

正确使用#include指令可以方便地组织代码,将相关的声明和定义放在一起,提高代码的可读性和维护性。

2.2 宏定义

#define 是C语言中的一个预处理指令,用于定义宏(Macro)。通过使用#define指令,可以给一个标识符(通常是一个变量名或函数名)分配一个值或表达式,以后在代码中使用该标识符时会被替换为相应的值或表达式。宏定义的一般形式为:

1
#define 标识符 值

以下是#define指令的一些常见用法:

  1. 定义常量宏:

    1
    2
    #define PI 3.1415926
    #define MAX_SIZE 100

    在代码中使用宏时,预处理器会将宏名称替换为相应的值,比如:

    1
    double circleArea = PI * radius * radius;

    会被替换为:

    1
    double circleArea = 3.1415926 * radius * radius;
  2. 定义函数宏:

    image-20230801171132104

    1
    #define SQUARE(x) ((x) * (x))

    在代码中使用函数宏时,预处理器会将函数宏名称替换为相应的表达式,比如:

    1
    int result = SQUARE(5 + 3);

    会被替换为:

    1
    int result = ((5+3) * (5+3));
  3. 定义条件编译宏:

    1
    #define DEBUG

    在代码中使用条件编译宏时,可以使用#ifdef#ifndef等条件编译指令判断是否定义了宏,从而选择性地编译一部分代码,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <stdio.h>
    #define DEBUG
    int main()
    {
    int number = 100;
    #ifdef DEBUG
    printf("你好, 世界!\n");
    #else
    printf("hello, world!\n");
    #endif

    #ifndef DEBUG
    number += 100;
    #endif // !DEBUG
    printf("number = %d\n", number);

    return 0;
    }
    • #define DEBUG:定义了一个宏DEBUG,不定义该宏就不存在

    • #ifdef#ifndef 判断指令的使用方法

      • #ifdef...#endif#ifndef...#endif
      • #ifdef...#else...#endif#ifndef...#else...#endif
    • #ifdef指令表示如果定义了某个宏,如:DEBUG

    • #ifndef指令表示如果没有定义某个宏,如:DEBUG

    宏定义可以提高代码的可读性和可维护性,同时也可以减少重复的代码。但需要谨慎使用宏,避免引入意外的副作用或不易察觉的错误,同时遵循编码规范进行命名和使用。

2.3 条件编译宏

条件编译宏是一种在C语言中使用预处理指令控制编译过程的技术。通过定义和使用条件编译宏,可以在源代码中根据条件判断选择性地编译一部分代码,从而实现不同的编译路径或功能。

常用的条件编译宏有以下几个:

  1. #ifdef#ifndef:这指令用于判断某个宏是否已经被定义。

    1
    2
    3
    4
    5
    #ifdef DEBUG
    // 定义了 DEBUG 宏对应的代码块
    #else
    // 没有定义 DEBUG 宏对应的代码块
    #endif
    • 如果宏 DEBUG 已经被定义,则编译 #ifdef 后面的代码块
    • 如果宏 DEBUG 未定义,则编译 #else 后面的代码块
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include <stdio.h>

    int main()
    {
    #ifdef _WIN32
    printf("这是win32平台\n");
    #else
    printf("这不是win32平台\n");
    #endif // _WIN32
    }

    通过上面的代码我们就可以轻而易举地判断出当前是不是WIN32平台。

  2. #if:该指令用于在编译时对表达式进行求值,根据结果判断是否编译代码块中的内容。此处的表达式要求在预处理阶段值是可以被求出的,常见的包括宏定义的值、常量、运算表达式等。

    1
    2
    3
    4
    5
    6
    7
    #if (VALUE == 1)  // 小括号可以省略不写
    // 在VALUE为1时执行的代码
    #elif (VALUE == 2)
    // 在VALUE为2时执行的代码
    #else
    // 在其他情况下执行的代码
    #endif

    根据宏 VALUE 的值,编译器会根据条件选择性地编译 #if#elif#else 后面的代码块。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #define VALUE 5
    int main()
    {
    int number = 5;
    #if VALUE > 5
    number += 10;
    #elif VALUE < 5
    number *= 10;
    #else
    number++;
    #endif
    printf("number = %d\n", number);
    }

    执行程序输出的结果为6

  3. #endif:用于结束条件编译块。

条件编译宏的作用在于根据编译时的条件进行代码的选择性编译,从而实现不同环境、不同配置或不同功能的编译版本。这可以用于实现调试模式和发布模式的切换,平台适配,以及选择性地编译不同的功能模块等。通过合理使用条件编译宏,可以提高代码的灵活性、可维护性和可移植性。

2.4 pragma

#pragma 是C和C++语言中的一个预处理指令,用于向编译器发出特定的指示或命令。它的作用是告诉编译器执行一些与编译器相关的特定操作,或者对编译器进行设置。

#pragma 的一般形式是:

1
#pragma directive

其中,directive 表示具体的指示或命令,不同的编译器支持的 pragma 指令可能有所不同。

一些常见的 pragma 指令用法包括:

  1. #pragma once:用于防止头文件的重复包含。#pragma once 告诉编译器只包含一次当前的头文件,避免重复引用。

    1
    2
    #pragma once
    // 头文件的内容
  2. #pragma pack:用于设置结构体的内存对齐方式。#pragma pack 可以设置结构体成员的对齐方式,以便在内存中紧凑地存储数据。

    1
    2
    3
    4
    // 将当前的对齐方式压栈,并设置为 n 字节对齐
    #pragma pack(push, n)
    // 结构体定义和成员
    #pragma pack(pop) // 恢复之前的对齐方式
  3. #pragma warning:用于控制编译器警告的输出级别。#pragma warning 可以修改编译器输出的警告信息级别。

    1
    2
    3
    4
    // 禁用指定警告
    #pragma warning(disable: warning_number)
    // 恢复指定警告到默认级别
    #pragma warning(default: warning_number)

这些是一些常见的示例,实际上各个编译器还支持各种不同的 pragma 指令,具体的使用方法和支持的指令可以参考编译器的文档或手册。需要注意的是,pragma 指令的具体行为和效果在不同的编译器之间可能有所不同,并且使用 pragma 指令可能导致代码的可移植性降低。因此,在使用 pragma 指令时应当谨慎,并考虑平台和编译器的兼容性。

3. 其他

3.1 井号运算符 #

在C和C++中,# 运算符(井号运算符)用于将宏参数转换为字符串常量。它通常与宏定义一起使用,用于将宏参数的值转换为字符串形式。

一个典型的使用示例是宏定义中的字符串化宏。通过在宏定义中使用 # 运算符将宏参数转换为字符串,可以在宏展开时将参数的值以字符串的形式表示。

版本1

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#define STRINGIZE(x) #x

int main()
{
int number = 666;
const char* str = STRINGIZE(number);
printf("%s\n", str);

return 0;
}

执行程序输出的结果为:number

  • STRINGIZE 是一个宏定义,它使用 # 运算符将其参数 x 转换为字符串常量。
  • STRINGIZE(number)展开之后就会将变量number变成字符串number

很显然上面的结果不是我们想要的,因此可以对定义的宏进行升级改造。

版本2

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#define STRINGIZE(x) printf(""#x" value is %d\n", (x))

int main()
{
int number = 666;
STRINGIZE(number);

return 0;
}

程序输出的结果:

1
number value is 666
  • 在程序中将STRINGIZE定义成了一个函数宏,对应的是printf
  • 使用#x将参数转换成为字符串之后,如果想要和其它字符串进行拼接,需要将#x放到一个上引号中,即:“#x”

需要注意的是,# 运算符只能用于宏定义中,不能在其他上下文中使用。它的作用是在预处理阶段将宏参数转换为字符串常量,而不是在运行时进行字符串操作。这意味着,# 运算符不能用于将变量或表达式转换为字符串,只能用于宏参数的字符串化操作。

3.2 拼接运算符 ##

## 是宏预处理运算符,称为连接运算符或拼接运算符。它只能在宏定义中使用,用于将两个符号(可以是标识符、关键字或其他字符)连接在一起形成一个新的标识符。

下面是一个使用##运算符的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#define NAME(n) yyds##n
#define STRNAME(n) "yyds_"#n""
#define STRINGIZE(x) printf("yyds"#x" value is %d\n", yyds##x)

int main()
{
int NAME(1) = 100;
int NAME(2) = 200;
int NAME(3) = 300;
STRINGIZE(1);
STRINGIZE(2);
STRINGIZE(3);

printf("%s\n", STRNAME(Leifeng));
printf("%s\n", STRNAME(9527));

return 0;
}

程序执行的结果如下:

1
2
3
4
5
yyds1 value is 100
yyds2 value is 200
yyds3 value is 300
yyds_Leifeng
yyds_9527

在上面的程序中用到了三个自定义宏:

  • 通过NAME宏可以得到一个标识符,用于表示变量的名字
  • 通过STRNAME宏可以得到一个字符串,可以用于printf打印
  • 通过STRINGIZE宏来打印变量的值

通过使用##运算符,可以根据宏的参数动态地生成标识符。这在一些特定的宏定义情况下非常有用,比如生成一系列类似名称的变量或函数。

需要注意的是,##运算符只能在宏定义中使用,不能在其他地方使用,否则会导致编译错误。此外,使用##运算符时应谨慎,确保正确的拼接和标识符的命名规则。