C语言

15. C语言进阶--程序的编译(预处理)+链接

Posted on 2022-02-11,17 min read

7. 程序的编译(预处理)+链接

7.1 程序的翻译和执行环境

在ANSI C的实现中,存在翻译和执行两个环境。翻译环境,将源代码转换为可执行文件;执行环境执行可执行文件。

7.2 编译+链接

7.2.1 翻译环境

翻译包括编译和链接两个步骤。C语言的编译又包括预处理、编译和汇编。依次用到预处理器(preprocessor)、编译器(compiler)、汇编器(assembler)、链接器(linker)。



7.2.2 编译和链接步骤

gcc编译器为例。

  • 预编译(预处理): — 文本操作

    1. 完成头文件的包含 #include
    2. 宏的替换 #define
    3. 注释删除
    gcc -E test.c -o test.i # 对test.c预处理 并将结果报错到 test.i 文件中
    


  • 编译: — 将C代码转化为汇编代码

    1. 语法分词
    2. 词法分析
    3. 语义分析
    4. 符号汇总
    gcc test.i -S # 汇编 生成test.s 文件
    


  • 汇编: — 把汇编代码转化为机器指令(二进制可执行文件)

    • 生成符号表
    gcc test.s -c # 生成 test.o 或 test.obj 目标文件
                  # 文件格式为 elf (可以使用readelf工具打开)
    


    objdump -h test.o # 查看 test.o 文件结构
        # .text   代码段(存放函数的二进制机器指令)
        # .data   数据段(存已初始化的局部/全局静态变量、未初始化的全局静态变量)
        # .bss    bss段(声明未初始化变量所占大小)
        # .rodata 只读数据段(存放 " " 引住的只读字符串)
        # .comment注释信息段
        # .node.GUN-stack :堆栈提示段
    
    


  • 链接: — 把多个目标文件和链接库进行链接

    1. 合成段表
    2. 符号表的合并和重定位(不同的对象文件之间保存了彼此之间的引用,所以需要在链接期间需要整合这些定位)
    # 生成 test.out 文件 elf格式
    # 链接阶段发现被调用的函数未定义
    


    # 查看文件结构
    readelf -h a.out
    objdump -h a.out
    
    




7.2.3 运行环境

  • 程序的执行过程
    1. 加载器(loader)将程序载入内存。
      • 有操作系统的环境中:一般由操作系统完成程序载入内存操作。
      • 独立的环境(裸机)中:程序手工载入内存,或通过可执行代码置入只读内存中。
    2. 程序运行。调用main函数。
    3. 开始执行程序代码。程序使用运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储与静态内存中的变量在程序的整个执行过程一直存在。
    4. 终止程序。终止main函数,或异常结束。
  • 《程序员的自我修养》

7.3 预处理

7.3.1 预处理器

C 预处理器不是编译器的组成部分,而是编译过程中一个单独的步骤。C 预处理器只是一个文本替换工具,会指示编译器在实际编译之前完成所需的预处理。 C 预处理器(C Preprocessor)简写为 CPP。预处理器命令以#开头,从第一列开始。

  • 重要的预处理指令
    指令 描述
    #define 定义宏
    #include 包含一个源代码文件
    #undef 取消已定义的宏
    #ifdef 如果宏已经定义,则返回真
    #ifndef 如果宏没有定义,则返回真
    #if 如果给定条件为真,则编译下面代码
    #else #if 的替代方案
    #elif 如果前面的 #if 给定条件不为真,当前条件为真,则编译下面代码
    #endif 结束一个 #if……#else 条件编译块
    #error 当遇到标准错误时,输出错误消息
    #pragma 使用标准化方法,向编译器发布特殊的命令到编译器中

7.3.2 宏

  • 定义宏

预处理过程中替换。目的是提高代码的可读性。

// 使用示例
#define MAX 1024
// 定义宏 告诉C预处理器 将源文件中所有的MAX替换成1024


#undef MAX
// 取消已定义宏


#ifdef MAX // 判断是否定义
  #undef MAX // 判断为真的操作...
#endif // 结束判断
// #ifdef 和 #endif是一对 上面代码表示 如果定义了MAX 则取消MAX的定义

#ifndef MAX // 判断是否没有定义
  #define MAX 1024 // 判断为真的操作...
#endif // 结束判断
// 上面代码表示 如果没有定义MAX 则定义MAX为1024

// 特殊的
#ifdef DEBUG
   /* Your debugging statements here */
#endi
// 该指令告诉 CPP 如果定义了 DEBUG 则执行处理语句 
// 在编译时 如果向 gcc 编译器传递了 -DDEBUG 开关量 这个指令就非常有用 
// 它定义了 DEBUG 您可以在编译期间随时开启或关闭调试 

#define 定义的标识符是否需要在最后加上; 即:#define MAX 1024;

// 答案:不需要
// #define定义的标识符完全替换
// 比如:
#define MAX 1024
int main()
{
  printf("%d\n", MAX); // 程序正常运行 打印1024
  return 0;
}

// 而如果有分号 ; 
#define MAX 1024;
int main()
{
  printf("%d\n", MAX);
  // 替换之后 printf语句为
  // printf("%d\n", 1024;);
  // 显然程序异常
  return 0;
}

  • 预定义宏

    C语言中的标识符分为预定义标识符和用户标识符。用户标识符即通常使用的变量名,而预定义标识符在C语言中有特定的含义。

    __FILE__ // 进行编译的源文件
    __LINE__ // 文件当前的行号
    __DATE__ // 文件被编译的日期
    __TIME__ // 文件被编译的时间
    __STDC__ // 如果编译器遵循ANSI C,则其值为1,否则未定义
    __func__ // 当前运行的函数 有些编译器中是 __FUNCTION__
    
    // 预定义标识符可用于书写日志
    
    • 实例

      #include <stdio.h>
      
      int main()
      {
        printf("%s\n", __FILE__);
        printf("%d\n", __LINE__);
        printf("%s\n", __DATE__);
        printf("%s\n", __TIME__);
        //printf("%s\n", __STDC__); // VS中未定义
        return 0;
      }
      


  • 参数化的宏

    可以使用参数化的宏模拟函数。在使用带有参数的宏之前,必须使用 #define 指令定义。参数列表是括在圆括号内,且必须紧跟在宏名称的后边。宏名称和左圆括号之间不允许有空格。

    // 例
    // 函数求和
    int Add(int x, int y)
    {
      return x + y;
    }
    
    // 宏求和
    #define ADD(X, Y) ((X)+(Y))
    
    

    宏是完全替换的。

    // 例
    #include <stdio.h>
    #define MUL(x, y) x * y
    
    int main()
    {
      printf("%d\n", MUL(5, 4)); // 20
      printf("%d\n", MUL(2 + 3, 4)); // 14
    }
    // MUL(5,4) 与 MUL(2+3,4) 的结果并不相同 因为宏是完全替换的
    // MUL(5,4) 相当于 5*4
    // MUL(2+3,4) 相当于 2+3*4
    
    // 所以修改MUL宏如下
    #define MUL(x,y) ((x) * (y))
    // 通过给参数添加适当的小括号 提高优先级 从而避免这种问题
    

    带副作用的参数。

    // 何为副作用
    // 例如:
    a = b++; 
    // 将b的复制给a 之后b又自增 则称该语句具有副作用
    
    // 例(带副作用的参数)
    #define MAX(a, b) ((a)>(b)?(a):(b))
    
    int main()
    {
      int a = 3;
      int b = 4;
      printf("%d\n", MAX(a++, b++)); // 5
      printf("%d\n", a); // 4
      printf("%d\n", b); // 6
      return 0;
    }
    // MAX宏 返回a、b中较大值
    // 本来只a、b只分别自增一次
    // 而通过宏替换 使得b自增了两次
    // 如果之后再使用a、b变量 则出现错误的结果
    
  • #define** 替换规则**

    1. 调用宏时,首先对参数进行检查,确定参数是否包含任何由 #define 定义的符号,如果有则先替换参数。
    2. 替换后再将程序放到原位置。对于宏,参数名被值替换。
    3. 最后,再对结果文件进行扫描,确定文件中没有任何由 #define 定义的符号。如果有,重复上述操作。
  • 宏和函数对比

    属性 **#define**定义宏 函数
    代码长度 每次使用,宏代码都会插入程序中。如果宏较大,则程序会增长。 每次调用函数,去函数的地址处,执行函数。
    执行速度 较快。 函数调用和函数返回会有额外的开销(内联函数除外)。
    参数 完全替换。 实参赋值给形参。
    参数类型 宏的参数与类型无关。 函数参数与类型有关,对于不同类型的参数需要不同的函数。
    调试 宏在预编译时完成替换,而调试是在程序编译完成后运行时的,所以宏不便于调试。 可逐语句调试。
    递归 不支持。 支持。
  • 补充

    • 通常定义宏时,习惯上将标识符全大写;
    • 宏参数和 #define 定义中可以出现其他 #define 定义的变量;
    • 宏中不能出现递归;
    • CPP搜索 #define 定义的符号时,字符串常量的内容并不会被搜索;
    • 使用宏时应避免使用自增、自减操作,因为宏是替换,而不像函数将实参赋值给形参,所以也就无法预测自增、自减操作的执行次数(即宏的带副作用的参数)。

7.3.3 预处理器运算符

C语言提供3个运算符 \### 辅助创建宏。显然,这些运算符只能在创建宏时使用。

  • 宏延续运算符\

    一个宏通常写在一个单行上。但是如果宏太长,一个单行容纳不下,则使用宏延续运算符\

    // 例
    #include <stdio.h>
    
    #define print(a) \
      printf(#a)
    
    int main()
    {
      print(1);
      return 0;
    }
    // 输出:
    // 1
    
  • 字符串常量化运算符#

    在宏定义中,当需要把一个宏的参数转换为字符串常量时,则使用字符串常量化运算符#。在宏中使用的该运算符有一个特定的参数或参数列表。

    // C语言中的字符串
    printf("hello" " " "world"); // hello world
    // 并不会产生错误 而是将三个字符串常量拼接并输出
    
    
    // 例
    #include <stdio.h>
    #define print(a) printf("输出:"#a"\n")
    
    int main()
    {
      print(hello c!); // 输出:hello c!
      // 相当于
      // printf("输出:""hello c!"\n");
      // 相当于
      // printf("输出:hello c!\n");
      return 0;
    }
    // print中 可以任意传参
    // print 可将要打印的数据 有输出提示 并自动换行
    
  • 标记粘贴运算符##

    宏定义内的标记粘贴运算符(##)会合并两个参数。它允许在宏定义中两个独立的标记被合并为一个标记。

    // 例
    #include <stdio.h>
    #define i(n) printf("i"#n"=%d\n", i##n)
    
    int main()
    {
      int i1 = 8;
      int i2 = 1024;
      int i3 = i1 * i2;
      i(1);
      // 相当于
      // printf("i""1""=%d\n", i1);
      // i##n 使 i和n 拼接后标识符i1 
      // 注意:在该标识符出现之前 i1必须是已定义的 否则报错
      i(2);
      i(3);
      return 0;
    }
    // 输出结果
    // i1=8
    // i2=1024
    // i3=8192
    
    
  • defined()运算符

    用在常量表达式中的,用来确定一个标识符是否已经使用 #define 定义过。如果指定的标识符已定义,则值为真(非零)。如果指定的标识符未定义,则值为假(零)。

    // 例
    #if !defined(MAX)
      #define MAX 1024
    #endif
    // #if和#endif是一对
    // 该段代码表示 如果没有定义MAX 则定义MAX为1024
    // 预处理指令 #elif、 #else 等使用同理
    

7.3.4 头文件

头文件是扩展名为 .h 的文件,包含了 C 函数声明和宏定义,被多个源文件中引用共享。有两种类型的头文件:程序员编写的用户头文件和编译器自带的系统头文件。使用头文件需要使用预处理指令#include。引用头文件相当于复制头文件中的内容。建议把所有的常量、宏、系统全局变量和函数原型写在头文件中,在需要的时候随时引用这些头文件。

  • 语法

    使用#include可以引用系统头文件和用户头文件。

    // 形式1:用于引用系统头文件 在系统目录的标准列表中搜索名为 file.h 的文件
    #include <file.h>
    
    // 形式2:用于引用用户头文件 在包含当前文件的目录中搜索名为 file.h 的文件
    #include "file.h"
    
    
  • 区别

    #include <file.h>#include "file.h" 的查找策略不同。

    • #include <file.h> 在类库目录中查找 linux系统在目录 /user/includewindows系统在vs安装目录中
    • #include "file.h" 先在项目目录下查找 file.h 头文件,如果没有找到,再去类库目录下查找

    显然,应该根据不同的使用场景选择使用哪种包含方式。包含系统头文件使用 #include <> 显然查找更快,也可以避免用户头文件和系统头文件重名的情况。

  • 多次引用

    如果一个头文件被引用两次,编译器会处理两次头文件的内容。所以在源文件中就会有两份头文件,尽管程序有时可能正常运行,但这显然是没有必要的。C语言中可以通过条件编译语句和 #pragma 两种方法解决这种问题。

    • 条件编译语句

      #ifndef HEADER_FILE
      #define HEADER_FILE
      
      the entire header file
      
      #endif
      
      // 这种结构即包装器#ifndef
      // 当再次引用头文件时 因为 HEADER_FILE 已定义  条件为假 
      // 预处理器会跳过文件的整个内容 编译器会忽略它 
      
    • #pragma

      #pragma once // 文件开头写入
      
      // 即 相同的文件(物理上)只能被包含一次
      

    **对比:**条件编译语句由C标准提供支持,依赖于宏名字不能冲突,不仅可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件不会被同时包含;#pragma once 由编译器提供支持,不会出现宏名碰撞引发的奇怪问题,如果某个头文件有多份拷贝,该方法不能保证他们不被重复包含。

  • 有条件引用

    从多个不同的头文件中选择一个引用到程序中。比如需要指定在不同的操作系统上使用的配置参数,可以通过一系列条件实现,即有条件引用。

    #if SYSTEM_1
       # include "system_1.h"
    #elif SYSTEM_2
       # include "system_2.h"
    #elif SYSTEM_3
       ...
    #endif
    
  • 补充

    • 使用宏定义头文件的名称。

      #define test "test.h"
      #include "test.h"
      
      // 注:只能使用宏定义用户头文件 而不能使用定义系统头文件
      
    • 头文件中一般写:(好的编程习惯)

      • 头文件的包含
      • 类型的定义
      • 函数的声明

      变量声明和定义一般写在 .c 文件中。如果多次包含一个有变量定义的头文件,将产生异常。

7.3.4 命令行定义

许多C编译器提供在命令行中定义符号,用于启动编译过程。

  • 代码
// 例
#include <stdio.h>

int main()
{
  int arr[ARR_SIZE];
  int i = 0;
  for(i = 0; i < ARR_SIZE; i++)
  {
    arr[i] = i;
  }
  for(i = 0; i < ARR_SIZE; i++)
  {
    printf("%d ", arr[i]);
  }
  printf("\n");
  return 0;
}
// 代码中并没有ARR_SIZE的值,而是在程序编译时指定

  • 编译
    gcc -D ARR_SIZE=10 test.c # 指定ARR_SIZE为10 进行编译
    gcc -D ARR_SIZE=100 test.c # 指定ARR_SIZE为100 进行编译
    
    


  • 《C语言深度解剖》

7.3.5 习题1

写一个宏,可以将一个整数的二进制位的奇数位和偶数位交换。

// 函数实现如下:
#include <stdio.h>
int swap(int x)
{
  int ret = ((x & 0x55555555) << 1) + ((x & 0xaaaaaaaa) >> 1);
  return ret;
}

int main()
{
  printf("%d\n", swap(10));
  return 0;
}
// 解析:
// 获取奇数位 按位与 01010101010101010101010101010101 = 0x55555555(十六进制)
// 获取偶数位 按位与 10101010101010101010101010101010 = 0xaaaaaaaa(十六进制)
// 将奇数位右移 偶数位左移 并相加 得数的二进制位的奇数位和偶数位交换的结果

// 宏实现如下:
#define SWAP(X) ((X & 0x55555555) << 1) + ((X & 0xaaaaaaaa) >> 1)
#include <stdio.h>

int main()
{
  printf("%d\n", SWAP(10));
  return 0;
}

7.3.6 习题2

offsetof宏的实现。结构成员相对于结构开头的字节偏移量。

  • offsetof 描述

    // 头文件 <stddef.h> 中
    offsetof (type,member)
    
    // 参数:
    //     type -- 类型
    //     member -- 成员
    // 返回值:
    //     size_t 类型 成员偏移量
    
  • offsetof 使用

    #include <stddef.h>
    #include <stdio.h>
    
    struct S
    {
      char c;
      short s;
      int i;
      float f;
      double d;
    };
    
    int main()
    {
      printf("%d\n", offsetof(struct S, c)); // 0
      printf("%d\n", offsetof(struct S, s)); // 2
      printf("%d\n", offsetof(struct S, i)); // 4
      printf("%d\n", offsetof(struct S, f)); // 8
      printf("%d\n", offsetof(struct S, d)); // 16
      return 0;
    }
    
  • 模拟实现offsetof

    #include <stdio.h>
    #define OFFSETOF(type, member) (int)(&(((type *)0)->member))
    
    struct S
    {
      char c;
      short s;
      int i;
      float f;
      double d;
    };
    
    int main()
    {
      printf("%d\n", OFFSETOF(struct S, c)); // 0
      printf("%d\n", OFFSETOF(struct S, s)); // 2
      printf("%d\n", OFFSETOF(struct S, i)); // 4
      printf("%d\n", OFFSETOF(struct S, f)); // 8
      printf("%d\n", OFFSETOF(struct S, d)); // 16
      return 0;
    }
    
  • 补充

    C语言库中提供的 offsetof 宏,采用全小写,以伪装成函数。


下一篇: 14. C语言进阶--文件操作→