目录
目录
前言
一.c语言的编译链接
1.翻译环境
编译阶段可以分为预处理,编译,汇编三个阶段
预处理阶段
编译阶段
词法分析
语法分析
语义分析
汇编阶段
链接阶段
2.运行环境
二.预处理详解
#define定义常量
#define定义宏
宏和函数的对比
#和##运算符
#运算符
##运算符
条件编译
头文件的包含
前言
本文将了解到c语言是怎么编译怎么链接的,c语言文件是怎么变成能够被计算机识别的文件底层处理。同时还将详细介绍预处理宏的相关知识。以及了解头文件的相关知识,比如#include<stdio.h>与#include"stdio.h"的区别
一.c语言的编译链接
我们都知道计算机是无法直接识别人类语言的,它只能识别机器语言。如果不会外语而要与外国人交谈,通过翻译就可以把我的语言转变成他所能理解的语言。对于c语言来说,也需要翻译才能把高级语言转变成计算机能识别的机器语言,对于c语言来说翻译工作是通过编译器编译来实现的。
C语言的任何一种实现中,存在两个不同的环境,一个是把源代码转变为机器指令的翻译环境,一个是把已经转变过了的机器指令进行运行的运行环境。实际上说就是两个步骤,翻译我要说的话,然后理解了之后去执行我说的话。
1.翻译环境
翻译环境分为编译和链接两个过程,在一个项目中可以有多个.c文件,填满一同构成了一个程序,通过编译(翻译)可以编译出相对应的目标文件.obj(这个是和.c文件一一对应的)。这些被翻译过后的.obj实际上已经是二进制形式了,但是计算机依旧无法直接识别和执行.obj 文件,因为它仅包含了编译后的机器代码,而没有包含操作系统特定的加载和执行代码。链接器负责将.obj 文件与其他必要的文件(如库文件)进行链接,生成可执行文件或共享库,使其能够在操作系统上运行。说白了链接就是把你需要的目标文件全整合到一起去形成一个可以执行的程序,要由链接器(Linker)将多个目标文件和库文件合并成最终的可执行文件,然后才能被计算机识别和执行。
编译阶段可以分为预处理,编译,汇编三个阶段
预处理阶段
在这个阶段会去处理代码里#define以及头文件#include形式的代码,#define是定义宏的预处理指令,宏定义可以将一个标识符与一个文本片段关联起来,当源代码中出现该标识符时,预处理器会将其替换为对应的文本片段。比如 #define MaxSize 100;代码里本来是 arr[MaxSize];实际上就是arr[100],定义成宏可以方便修改,预处理阶段会直接把所有MaxSize还原成原来的100。预处理器会遍历源代码,查找所有的宏调用,并将其展开为宏定义中的文本。同时会删除所有#define将 ,#define
删除"时,意味着将宏定义从源代码中移除,预处理器将不再对该宏进行替换可以获得源代码中所有宏被替换后的文本内容
对于#include预编译指令来说,用于在源代码中包含其他文件的内容。是在预处理阶段将被包含文件的内容插入到指令所在的位置,实际上可以理解为在上面加了#include文件里的代码,只是我们看不见而已,举个例子在一个文件对函数进行定义声明,在另一个文件里进行函数的实现,但是在实现的这个文件里可以用声明文件里定义的变量,就是因为在预处理阶段另一个文件的内容会直接放到当前文件的上方,所以可以直接使用。
同时预处理阶段还会直接把注释的内容直接删除。把代码添加行号和文件名标识,方便后续编译器生成调试信息或保留所有的#pragma的编译器指令,以便后续使用。
编译阶段
在这个阶段会把高级语言转变成汇编语言,通过词法分析,语法分析,语义分析及优化来转变成汇编语言
词法分析
源代码会被分解成一个个的词法单元(Tokens)。词法单元是编程语言中的最小语法单位,包括关键字、标识符、运算符、常量、字符串字面量等
例如,对于源代码中的表达式 int a = 10 + b;
,词法分析器可能会生成以下词法单元序列:
- 词法单元类型:关键字,内容:int
- 词法单元类型:标识符,内容:a
- 词法单元类型:运算符,内容:=
- 词法单元类型:常量,内容:10
- 词法单元类型:运算符,内容:+
- 词法单元类型:标识符,内容:b
- 词法单元类型:运算符,内容:;
词法分析阶段的输出结果将作为下一阶段的输入,例如语法分析器(Parser)将使用词法分析器生成的词法单元序列来构建语法树,进一步分析和理解源代码的结构和语义。
语法分析
语法分析阶段也称为解析器(Parser)阶段,它接收词法分析器生成的词法单元序列,并根据预定义的语法规则验证源代码的语法正确性,并构建抽象语法树(Abstract Syntax Tree,AST)或语法分析树(Parse Tree)。
语法分析器使用上下文无关文法(Context-Free Grammar)来描述语言的语法结构,并通过语法规则进行递归下降、LR分析、LL分析等算法来进行语法分析。它会检查词法单元序列是否符合语法规则,并生成一个结构化的表示形式,以便后续的编译步骤使用
int a = 10 + b语法树如下
语义分析
语义分析阶段对抽象语法树进行进一步的分析,以确定源代码的语义是否合法,并进行类型检查、作用域分析、语义约束检查等操作。
语义分析器会检查变量的声明和使用是否匹配、函数调用的参数是否正确、类型转换是否合法等。它还会处理作用域规则,确保变量和函数在正确的作用域内使用,并进行类型推导和类型检查,以保证源代码的语义正确性。
如果源代码中存在语义错误,语义分析器会产生相应的错误信息,指示出错误的位置和类型
如下是语义标识后的语法树
汇编阶段
汇编阶段是把汇编语言汇编成机器语言的过程,将汇编代码转变成机器可执行的指令,每一个汇编语句都对应一条机器指令。根据汇编指令和机器指令的对照表一一的进行翻译。
当将汇编代码转换为机器可执行的指令,我可以举一个简单的例子来说明。
假设我们有以下汇编代码:
mov eax, 10
add eax, ebx
这段汇编代码的作用是将寄存器 eax
的值设置为 10,然后将 ebx
的值加到 eax
上。
下面是这段汇编代码转换为机器可执行的指令的示例(使用x86架构):
Opcode Operands Explanation
----------------------------------
B8 0A 00 00 00 mov eax, 10 ; 将立即数 10 移动到 eax 寄存器
03 C3 add eax, ebx ; 将 ebx 寄存器的值加到 eax 寄存器
在这个示例中,每条指令都有一个特定的操作码(Opcode)和操作数(Operands)。操作码表示指令的类型和功能,操作数表示指令的操作对象。在这个例子中,mov
指令使用操作码 B8
,并且操作数是立即数 10
和寄存器 eax
。add
指令使用操作码 03
,并且操作数是寄存器 ebx
和寄存器 eax
。
这些指令的执行将根据特定的计算机架构和指令集体系结构进行解释和执行。通过将汇编代码转换为机器指令,计算机可以按照指令的顺序和操作数执行相应的操作,实现程序的功能。
链接阶段
链接其实就是多个目标文件合并成一个可执行文件的过程,主要解决的是一个项目中多文件,多模块之间相互调用的问题。分为静态链接和动态链接两种方式,静态链接将所有代码和数据复制到可执行文件中,而动态链接通过引用动态链接库中的代码和数据来实现。
假设我们有一个项目,包含以下两个源文件和一个头文件:
file1.cpp:
#include "file2.h"
void function1() {
function2();
}
file2.cpp:
#include <iostream>
void function2() {
std::cout << "Hello, World!" << std::endl;
}
file2.h:
#ifndef FILE2_H
#define FILE2_H
void function2();
#endif
在这个例子中,file1.cpp
中的 function1
调用了 file2.cpp
中的 function2
。为了让 function1
能够正确调用 function2
,我们需要进行链接。
在链接过程中,链接器会解析符号引用和符号定义,将 function1
中对 function2
的引用与 function2
的定义进行匹配。在这个例子中,链接器会将 function1
中对 function2
的引用解析为 file2.o
中的 function2
的定义,并将其替换为正确的内存地址。
最终生成的可执行文件可以执行 function1
,当调用 function1
时,它会调用 function2
,并打印 "Hello, World!"。
通过链接,我们可以将多个文件和模块组合在一起,使它们能够相互调用,并最终生成可执行的程序。这样,我们可以更好地组织和管理大型项目,并实现模块化的开发和代码复用。
2.运行环境
1. 程序必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序
的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成。
2. 程序的执⾏便开始。接着便调⽤main函数。
3. 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(stack),存储函数的局部变量和返回
地址。程序同时也可以使⽤静态(static)内存,存储于静态内存中的变量在程序的整个执⾏过程
⼀直保留他们的值。
4. 终⽌程序。正常终⽌main函数;也有可能是意外终⽌。
二.预处理详解
#define定义常量
这个都挺熟悉的,就是用自定义的关键字去代替代码要代替的常量
比如这个扫雷初始化和打印的函数,都用到了数组char board[11][11],如果我已经完全写完了所有程序代码,现在要改需求把char board[11][11]改成9X9大小的数组,那么我改完了初始化函数的大小还得去改输出函数的大小。如果这个程序代码有很多函数都用到了这个数组,那么都要找出来改掉。直接#define 定义常量,那么在第一行改了,所有的都会变更,简化成了操作。
也许会有人疑问,#define 常量后面加不加分号呢,加分号和不加分号区别差别大不大
如果#define MaxSize 100加了分号,那么在替换的时候会把分号一块替换进去,如arr[MaxSize][MaxSize]会被替换成arr[100;][100;],不符合数组的语法这样肯定会报错
再如下面一个例子
#include <stdio.h>
#define MaxSize 1000
int main()
{
int max = 0;
if (1)
max = MaxSize;
else
max = 0;
}
如果我加上分号的话,直接就报错了,因为这样实际上等价成max=100;; ,产生了两个分号,肯定会报错
如果我把原来文本里的分号去掉,然后#define MaxSize加分号其实就不会报错了,因为它把100连同分号一块替换过去。其实就等价于max=100;
所以#define加不加分号其实都可以,但是还是推荐不加分号
#define定义宏
这个其实与函数类似,都是传参数过去进行一系列操作
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#define add(a,b) a+b
int add1(int a, int b)
{
return a + b;
}
int main()
{
int a = 5;
int b = 5;
printf("宏处理的结果:%d\n", add(a, b));
printf("函数处理的结果:%d\n",add1(a,b));
}
宏处理结果和函数结果相同,函数是传参数a,b过去,进行操作之后返回结果。宏也是接受参数,然后把操作之后的结果直接替换。
但是差别也明显,宏适合那种简单一点的操作,如果很复杂的话可能大幅度增加程序长度。调⽤函数和从函数返回的代码可能⽐实际执⾏这个⼩型计算⼯作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜⼀筹。
更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使⽤。反之,宏可以适⽤于整形、⻓整型、浮点型等可以⽤于 > 来⽐较的类型。宏是类型⽆关的
预处理阶段的操作我们是看不见的,是在底层自动实现的,所以宏是没法调试的,而且可能带来运算符优先级的问题,容易出现错误
在传参的时候,如果参数是表达式,函数传参会直接传表达式的结果,而宏会原模原样传参过去
#include <stdio.h>
#define square(x) x*x
int square1(int a)
{
a = a * a;
return a;
}
int main()
{
int a = 5;
int b = 5;
printf("宏处理的结果:%d\n", square(a+1));
printf("函数处理的结果:%d\n",square1(a+1));
square传参过去是原模原样传过去,所以是5+1*5+1,先算乘法然后才是加法,所以结果是11
而squeare1函数传参之前会直接把a+1计算出来,把结果传参过去,所以是6*6,结果是36
宏加上括号改变优先级,#define square(x) (x)*(x)结果也能正确
宏和函数的对比
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用,宏代码都会插到程序中。除了简短的宏外,程序的长度会大幅度增长 | 函数代码只出现一个地方;每次调用函数时,都调用通一个地方的同一份代码 |
执行速度 | 更快 | 需要调用函数和返回值,更慢一点 |
操作符优先级 | 如果不加括号邻近操作符的优先级可能会产生不同的结果 | 表达式形式传参时会将结果值传给函数 |
带有副作用的参数 | 参数可能被替换到宏体的多个位置,如果宏的参数被多次计算,带有副作用的参数求值会产生不可预料的结果 | 函数参数只在传参的时候求值一次,结果容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数操作是合法的,它就可以使用于任何参数类型 | 函数参数与类型有关,如果类型不同,那么就需要不同的函数 |
调试 | 不方便调试 | 函数是可以逐语句调试的 |
递归 | 宏不能递归 | 函数可以递归 |
#和##运算符
#运算符
#include <stdio.h>
#define Print(x, type) printf("%d is %s", x, #type)
int main() {
int a = 5;
Print(a, int);
return 0;
}
虽然宏可以直接传参类型,但是不能之间用%s去打印type,因为type是标识符还不是字符串,所以用#type字符串化,然后才能用%s打印
再举个例子,如果我有个变量 int a=5; 现在要打印the value of a is 5应该去怎么定义宏,如果是直接#define Print(n) printf("the value of a is%d",n)其实也可以打印出来,但是如果换个变量名b这个打印出来的依旧是the value of a is 5,这样就错了。这时候就需要用#操作符了,将
请注意printf("the value of n is %d",n)这样写里面的n是不会替换成a的,双引号默认不会去替换,会直接默认打印原模原样的双引号内容,所以结果是the value of n is 5。#运算符其实就是一个提示,提示要将宏的参数转换成字符串,所以它会先替换成宏的参数
##运算符
##运算符也是在宏里面用的,所以它必然会伴随着替换宏的参数,它的作用是将两边的符号合成一个符号,比如 type##_max ##前面的符号会替换成宏的参数,所以结果是int_max
举个例子,一个函数求两个数的较大值,不同的数据类型就得写不同的函数
#include<stdio.h>
int int_max(int x, int y)
{
return x > y ? x : y;
}
float float_max(float x, float y)
{
return x > y?x:y;
}
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<stdio.h>
//宏定义
#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;
}
#undef用于移除一个宏定义
#undef NAME
条件编译
条件编译是指如果满足条件才去编译以下的句子,如果不满足就不编译,常用于调试性的代码,因为这些代码在最终版本一般都用不上。
一般用法和if语句类似。
#if 常量表达式
//实际需要条件编译的语句
#endif结尾的标志
同样也可以多分支条件编译,通过#elif和#else来实现
#include <stdio.h>
#define OPTION_A 1
#define OPTION_B 2
#define OPTION_C 3
#define OPTION OPTION_A
int main() {
#if OPTION == OPTION_A
printf("Option A is selected.\n");
#elif OPTION == OPTION_B
printf("Option B is selected.\n");
#elif OPTION == OPTION_C
printf("Option C is selected.\n");
#else
printf("Invalid option.\n");
#endif
return 0;
}
上面那种是通过表达式来判断条件编译,还可以通过判断是否被定义来进行条件编译
一般是通过#ifdef(xxx)或者#ifndef(xxxx)来实现条件编译的
#ifdef等价于#if defined(xxx) #ifndef(xxxx)等价于#if !defined(xxxx)
#include <stdio.h>
#define OPTION_A
#define OPTION_B
int main() {
#ifdef OPTION_A
printf("Option A is defined.\n");
#ifdef OPTION_B
printf("Option B is defined.\n");
#else
printf("Option B is not defined.\n");
#endif
#else
printf("Option A is not defined.\n");
#endif
return 0;
}
在上面的代码中,我们定义了两个选项 OPTION_A
和 OPTION_B
。首先,我们使用 #ifdef
检查 OPTION_A
是否定义。如果定义了 OPTION_A
,则输出"Option A is defined.",并继续进入嵌套的条件编译部分。
在嵌套的部分中,我们使用 #ifdef
检查 OPTION_B
是否定义。如果定义了 OPTION_B
,则输出"Option B is defined.",否则输出"Option B is not defined."。
如果 OPTION_A
没有定义,将跳过嵌套的条件编译部分,直接执行 #else
后面的代码,输出"Option A is not defined."。
通过嵌套使用 #ifdef
、#ifndef
和 #if
,可以根据多个条件进行更复杂的条件编译。这样可以根据不同的条件组合编译不同的代码块,以满足更灵活的需求。
头文件的包含
头文件的包含分为include<stdio.h>和#include"stdio.h"两种
前者是库文件包含,在查找这个文件时会直接去标准路径下去查找,如果找不到就提示编译错误
后者是本地文件包含,查找文件时会现在源文件所在的目录下查找,如果没找到会像查找库文件一样去标准位置查找头文件,如果还没找到就提示编译错误
库文件包含也可以写成双引号本地文件查找的形式,只是查找效率变低了
#include ?指令可以使另外⼀个⽂件被编译。就像它实际出现于 #include 指令的
地⽅⼀样。
这种替换的⽅式很简单:预处理器先删除这条指令,并⽤包含⽂件的内容替换。
⼀个头⽂件被包含10次,那就实际被编译10次,如果重复包含,对编译的压⼒就⽐较⼤。
要么就干脆不写,要么就条件编译#ifdef判断一下是否已经被包含了,如果没被包含才会去执行包含的语句
#ifndef __TEST_H__
#define __TEST_H__
//头⽂件的内容
#endif //__TEST_H__
或者直接
#pragma onc
#pragma once
是一种预处理指令,用于确保头文件只被编译一次,以防止重复包含。当编译器遇到 #pragma once
时,它会检查当前的文件是否已经被包含,如果是,则跳过后续的包含操作