C语言的编译和链接以及预处理详解

一.翻译环境和运行环境

1.翻译环境

翻译环境是指在进行程序翻译(编译或解释)的过程中所需的软件和工具的集合。它包括编译器、解释器、链接器等。翻译环境提供了将源代码转换为可执行代码的功能,以便程序能够在运行环境中执行。

2.运行环境

运行环境是指程序在执行过程中所依赖的软件和硬件环境。它提供了程序运行所需的资源和支持,包括操作系统、库文件、硬件设备等。运行环境负责加载、执行和管理程序,并提供程序运行所需的各种服务。

翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(有些书也叫预编
译)、编译、汇编三个过程。

 根据上述图形可知,我们所写的程序文件.c,要先经过编译器cl.exe编译处理,生成对应的目标文件.obj(Linux系统下后缀名是.o),接着经过链接器生成可执行的程序.exe。

3.预处理

预处理是编译过程的第一阶段。在这个阶段,预处理器会处理以#开头的预处理指令,并对源代码进行相应的替换、插入和删除操作。预处理的主要目标是准备源代码以供后续的编译阶段使用。

预处理的主要任务包括:

  • 处理#include指令,将指定的头文件内容插入到相应位置。
  • 处理#define指令,将宏定义替换为相应的文本。
  • 处理条件编译指令(如#if#ifdef等),根据条件选择性地编译代码块。
  • 处理其他预处理指令(如#error#pragma等)。 
#include <stdio.h>
#define PI 3.14159

int main() {
    int radius = 5;
    double area = PI * radius * radius;
    printf("Area: %f\n", area);
    return 0;
}

在预处理阶段,#include <stdio.h>会将stdio.h头文件的内容插入到相应位置,而#define PI 3.14159会将所有的PI替换为3.14159

4.编译

编译是编译过程的第二阶段。在这个阶段,编译器将预处理后的源代码翻译成汇编语言。编译器会将C语言代码转换为汇编语言的等效表示,其中包括了机器指令、数据定义、符号表等信息。

编译的主要任务包括:

  • 词法分析:将源代码分解为单词,如关键字、标识符、运算符等。
  • 语法分析:根据语法规则检查单词组合是否符合语法规范。
  • 语义分析:检查语句的语义是否正确,如类型匹配、变量声明等。
  • 生成中间代码:将源代码转换为中间代码表示,如三地址码、抽象语法树等。
  • 优化:对中间代码进行优化,以改善程序性能和可读性。
int add(int a, int b) {
    return a + b;
}

int main() {
    int x = 5;
    int y = 10;
    int sum = add(x, y);
    return 0;
}

 在编译阶段,编译器会将C语言代码翻译成汇编语言的等效表示,例如x86汇编语言。

5.汇编


汇编是编译过程的第三阶段。在这个阶段,汇编器将汇编语言代码翻译成机器语言指令,生成可重定位目标文件。这些目标文件包含了机器指令、符号表、重定位表等信息。

汇编的主要任务包括:

  • 将汇编语言指令翻译为机器语言指令。
  • 处理符号引用,生成符号表和重定位表。
  • 处理数据定义,生成数据段。
  • 处理程序入口点和代码段。
section .data
    message db 'Hello, World!', 0

section .text
    global _start

_start:
; write message to stdout
    mov eax, 4
    mov ebx, 1
    mov ecx, message
    mov edx, 13
    int 0x80

    ; exit program
    mov eax, 1
    xor ebx, ebx
    int 0x80

 在汇编阶段,汇编器会将汇编代码翻译成机器语言指令,并生成可重定位的目标文件。

预处理、编译和汇编是将C语言源代码转换为可执行文件的三个主要阶段。预处理阶段处理预处理指令,展开宏定义和条件编译,以准备源代码。编译阶段将预处理后的代码转换为汇编语言表示,进行词法、语法和语义分析,并生成中间代码。汇编阶段将汇编语言代码转换为机器语言指令,并生成可重定位目标文件。这些目标文件最终可以被链接器链接成可执行文件。

6.链接

链接是编译过程的最后一个阶段,它将多个目标文件和库文件合并成一个可执行文件或共享库文件。链接器负责解析目标文件之间的符号引用关系,并进行符号的重定位,最终生成一个完整的可执行文件。

链接的主要任务包括:

  1. 符号解析:链接器会解析目标文件中的符号引用,找到对应的符号定义。每个符号都有一个标识符和一个地址,符号解析的目的是将引用和定义关联起来。

  2. 符号重定位:在解析符号引用后,链接器会根据地址信息对符号进行重定位,以确保所有符号的地址正确。符号重定位的过程包括修改目标文件中的符号引用的地址,使其指向正确的符号定义的地址。

  3. 地址空间分配:链接器将目标文件中的代码段、数据段等分配到最终的地址空间中。它会为每个目标文件分配一段连续的地址空间,并通过重定位来解决不同目标文件之间的地址冲突。

  4. 符号表生成:链接器会生成一个全局符号表,记录所有的符号定义和符号引用信息。这个符号表在链接过程中起到重要的作用,用于解析符号引用和进行符号重定位。

  5. 代码合并:链接器将多个目标文件中的代码段合并成一个连续的代码段。这样可以消除重复的代码,并使程序的执行更加高效。

链接的示例:
假设有两个源文件:main.cfunctions.c,分别包含主函数和一些辅助函数。在编译阶段,每个源文件会被编译成一个目标文件:main.ofunctions.o。然后,在链接阶段,链接器将这两个目标文件合并成一个可执行文件。

gcc -o main main.o functions.o

 

在上述命令中,-o main指定生成的可执行文件名为mainmain.ofunctions.o是要链接的目标文件。

链接器将解析main.ofunctions.o中的符号引用关系,根据符号定义的地址进行重定位,并将代码段和数据段合并到最终的可执行文件中。最终生成的main可执行文件就可以在运行环境中执行。

需要注意的是,链接过程中可能还会涉及到库文件的链接,库文件包含了一些常用的函数和代码,可以被多个程序共享使用。链接器会根据需要将库文件中的代码合并到可执行文件中,以满足程序的需求。

链接是编译过程的最后一个阶段,它将多个目标文件和库文件合并成一个可执行文件或共享库文件。链接器通过解析符号引用关系、进行符号重定位和地址空间分配,最终生成一个完整的可执行文件。链接过程中还涉及符号表生成、代码合并等操作。链接的目标是将程序的各个模块整合起来,使之能够在运行环境中正确执行。

二.预定义符号

预定义符号:C语言中有一些预定义的符号,它们提供了一些常用的信息和功能。例如,__LINE__表示当前代码行号,__FILE__表示当前源文件名,__DATE__表示当前编译日期等。这些符号在预处理阶段被替换为相应的值。

__FILE__ //进⾏编译的源⽂件
__LINE__ //⽂件当前的⾏号
__DATE__ //⽂件被编译的⽇期
__TIME__ //⽂件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义

 

三.宏定义

1.#define

#define定义常量:
使用#define指令可以在预处理阶段定义常量。定义的常量会在源代码中被替换为相应的值。常量一般使用大写字母命名,以便与变量区分。

#include <stdio.h>

#define PI 3.14159
#define MAX_VALUE 100

int main() {
    double radius = 5.0;
    double circumference = 2 * PI * radius;
    printf("Circumference: %f\n", circumference);
    printf("Max value: %d\n", MAX_VALUE);
    return 0;
}

 在上述示例中,#define PI 3.14159定义了一个常量PI,它会在源代码中被替换为3.14159。同样地,#define MAX_VALUE 100定义了一个常量MAX_VALUE,它会在源代码中被替换为100

 #define定义宏:
除了定义常量,#define指令还可以用来定义宏。宏是一种在代码中进行简单文本替换的机制,可以用来定义函数、执行简单的表达式或语句等。

#include <stdio.h>

#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main() {
    int num1 = 5;
    int num2 = 8;
    int square = SQUARE(num1);
    int max = MAX(num1, num2);
    printf("Square: %d\n", square);
    printf("Max: %d\n", max);
    return 0;
}

 在上述示例中,#define SQUARE(x) ((x) * (x))定义了一个宏SQUARE,它接受一个参数x,并返回x的平方。当在代码中使用SQUARE(num1)时,预处理阶段会将其替换为((num1) * (num1))。类似地,#define MAX(a, b) ((a) > (b) ? (a) : (b))定义了一个宏MAX,它接受两个参数ab,并返回较大的值。

2.#undef

#undef指令用于取消已定义的宏。使用#undef可以在宏定义之后撤销宏的定义。

#include <stdio.h>

#define MAX_VALUE 100

int main() {
    #undef MAX_VALUE
    printf("Max value: %d\n", MAX_VALUE);  // 编译错误,MAX_VALUE未定义
    return 0;
}

 在上述示例中,#undef MAX_VALUE取消了前面定义的宏MAX_VALUE,因此在后续代码中使用MAX_VALUE会导致编译错误。

3.#和##

###是预处理指令中用于字符串化和连接的运算符。

  • #运算符用于将宏参数转换为字符串常量。
  • ##运算符用于将两个宏参数连接成一个标识符。
#include <stdio.h>

#define STRINGIFY(x) #x
#define CONCATENATE(x, y) x##y

int main() {
    int num = 10;
    printf("Num as string: %s\n", STRINGIFY(num));
    int concatenated = CONCATENATE(num, 1);
    printf("Concatenated value: %d\n", concatenated);
    return 0;
}

 

在上述示例中,STRINGIFY宏使用了#运算符,将宏参数num转换为字符串常量。当使用STRINGIFY(num)时,它会在预处理阶段被替换为"num"

CONCATENATE宏使用了##运算符,将两个宏参数连接成一个标识符。当使用CONCATENATE(num, 1)时,它会在预处理阶段被替换为num1

4.带有副作用的宏参数


宏参数可以是任意表达式,包括具有副作用(side effect)的表达式。然而,需要注意的是,在宏中多次使用具有副作用的表达式可能会导致意外的行为。

#include <stdio.h>

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

int main() {
    int num = 5;
    int square = SQUARE(num++);
    printf("Square: %d\n", square);
    printf("Num: %d\n", num);
    return 0;
}

在上述示例中,宏SQUARE的参数是num++,即后置递增表达式。在宏展开时,num会先被使用并进行平方运算,然后再递增。因此,square的值为25,而num的值为6。

5.宏替换的规则

在替换宏时,需要遵循一些替换规则:

  • 在替换过程中,宏参数要用括号括起来,以避免优先级问题。
  • 宏的整个定义必须在一行内完成,如果需要多行,可以使用反斜杠(\)进行续行。
  • 宏不会被类型检查,因此在使用宏时需要确保参数的类型正确。

6.宏函数的对比

宏函数是使用预处理指令定义的宏,它在代码中进行简单的文本替换。与函数不同,宏函数没有参数类型检查和作用域。宏函数在编译阶段展开,而函数在运行时执行。

#include <stdio.h>

#define SQUARE_MACRO(x) ((x) * (x))

int square_function(int x) {
    return x * x;
}

int main() {
    int num = 5;
    int square_macro = SQUARE_MACRO(num);
    int square_function = square_function(num);
    printf("Square (macro): %d\n", square_macro);
    printf("Square (function): %d\n", square_function);
    return 0;
}

 在上述示例中,SQUARE_MACRO是一个宏函数,而square_function是一个普通函数。宏函数在预处理阶段展开为((num) * (num)),而函数square_function在运行时执行。

四.命令行定义

1.命名约定

在宏定义中,可以使用各种命名约定来增加可读性和避免命名冲突。一些常见的命名约定包括:

  • 使用大写字母来表示常量和宏。
  • 使用下划线来分隔单词。
  • 使用全大写的缩写词。

2.命令行定义

编译器通常提供了命令行选项来定义宏。使用命令行定义的宏可以在编译时传递给预处理器。

gcc -DDEBUG main.c -o main

 在上述示例中,使用-DDEBUG命令行选项定义了一个名为DEBUG的宏。在编译时,预处理器会将代码中出现的DEBUG宏替换为1

五.条件编译

条件编译指令允许根据条件选择性地包含或排除代码。预处理器根据条件的真假来决定编译哪些代码。

#include <stdio.h>

#define DEBUG

int main() {
#ifdef DEBUG
    printf("Debug mode\n");
#else
    printf("Release mode\n");
#endif

    return 0;
}

 

上述示例中,#ifdef指令用于检查是否定义了DEBUG宏。如果DEBUG宏被定义,则编译器会包含printf("Debug mode\n");这一行代码。如果未定义DEBUG宏,则编译器会包含printf("Release mode\n");这一行代码。

另外,还有以下条件编译指令:

  • #ifndef: 如果未定义指定的宏,则编译下面的代码。
  • #if: 如果指定的条件为真,则编译下面的代码。
  • #elif: 如果前面的条件为假,而该条件为真,则编译下面的代码。
  • #else: 如果前面的条件都为假,则编译下面的代码。
  • #endif: 结束条件编译块。

六.头文件包含

使用#include指令可以包含其他头文件,使得其中的声明和定义在当前文件中可用。预处理器会将头文件的内容插入到指令所在位置。

// header.h
#ifndef HEADER_H
#define HEADER_H

void sayHello();

#endif

// main.c
#include <stdio.h>
#include "header.h"

int main() {
    sayHello();
    return 0;
}

// header.c
#include "header.h"

void sayHello() {
    printf("Hello, World!\n");
}

 在上述示例中,header.h头文件中包含了函数声明void sayHello();。在main.c文件中,使用#include "header.h"将头文件包含进来,使得sayHello函数在main函数中可用。而在header.c文件中,使用#include "header.h"将头文件包含进来,以便实现sayHello函数的定义。

七.其他的预处理指令

还有其他一些预处理指令可用于进行条件判断、错误处理和行号控制等。

  • #error:用于在预处理阶段生成一个错误消息,并终止编译过程。
  • #pragma:用于向编译器发送特定的指令或控制信息。
  • #line:用于更改当前行号和文件名信息。
  • #ifdef#ifndef#if#elif#else#endif:用于条件编译,根据条件选择性地编译代码。
  • #include:用于包含其他头文件。
  • #define#undef:用于定义和取消定义宏。
  • #ifdef#ifndef#endif:用于条件编译,根据条件选择性地编译代码。

这些预处理指令提供了很大的灵活性,可以在编译之前对源代码进行宏替换、条件编译和头文件包含等操作,以便生成最终的编译代码。

希望以上的内容能给你带来帮助!

  • 48
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值