程序编译和链接的过程/预处理符号和用法【C语言】

本文详细阐述了C程序的翻译环境和执行环境,涵盖编译、链接、运行过程。重点讨论了预处理阶段,包括预定义符号、宏定义及其规则,以及条件编译、文件包含等。还解析了offsetof宏的模拟实现,展示了如何计算结构体中变量的偏移量。此外,文章提供了一道笔试题,要求实现类似offsetof的宏功能。
摘要由CSDN通过智能技术生成

1. 程序的翻译环境和执行环境

标准规定C程序中需要有两种环境

  1. 翻译环境:源代码被转换为可执行的机器指令的环境
  2. 执行环境:用于执行代码的环境

在这里插入图片描述

2. 编译与链接

注:

.c后缀的文件称为源文件,需要编译

.h后缀的文件不需要编译

2.1 翻译环境

每个源文件(.c)都需要经过编译器单独处理,生成目标文件(.obj)。目标文件再与链接库结合,由编译器处理,生成可执行程序(.exe)

组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

2.2 编译的三个阶段

2.2.1 预编译(.i)

  1. 包含头文件(#include)
  2. 删除注释
  3. 符号和宏(#define)的替换

可见,预编译(预处理)的阶段是对代码文本的操作

2.2.2 编译(.s)

将C语言代码翻译为汇编代码

  1. 词法分析:将一长串的代码分割为若干部分,让编译器知道哪里是循环,哪里是main函数等
  2. 语法分析:将若干部分串回来,让编译器判断代码是否符合规定的法则
  3. 语义分析:在将代码翻译为汇编代码之前,也要让编译器知道代码是怎么做的(它不能判断其是否符合逻辑)
  4. *符号汇总:

2.2.3 汇编(.o)

  1. 将编译产生的汇编代码翻译为机器能直接接收的二进制指令(机器指令)
  2. 生成符号表:将函数的地址和名字记录,形成表

2.3 链接

  1. 合并段表

    在这里插入图片描述

  2. 符号表的合并与重定位

    如上述的add函数,合并段表以后就有两个add的地址,编译器取有效的地址作为add函数的地址

2.4 运行环境

  1. 程序必须载入内存中。

    在有操作系统的环境中:一般由操作系统完成。

    在独立的环境中:程序的载入必须由手动操作,也可能是通过可执行代码植入只读内存来完成。

  2. 程序的执行便开始。接着便调用main函数。

  3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。

  4. 终止程序:a. 正常终止main函数;b. 意外终止,如断电、崩溃等。

3. 预处理

3.1 预定义符号

部分语言内置的预定义符号

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

例子

printf("file:%s line:%d\n", __FILE__, __LINE__);

3.2 #define

3.2.1 #define 定义标识符

#define name stuff

例子

#define MAX 1000
//不要加分号
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,
//每行的后面都加一个反斜杠(续行符)
//但不能加空格

#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )

仅示例,以下这三种写法是不被大多数人接受的,因为实际中代码不是为了自己而写

#define reg register //为 register这个关键字,创建一个简短的名字
register int num1 = 0;
reg int mun2 = 0;
//两种定义方式等价
#define CASE break;case //在写case语句的时候自动把 break写上。
#define do_forever for(;;) //用更形象的符号来替换一种功能

3.2.2#define定义宏

#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义
宏(define macro)。

声明

#define name( parament-list ) stuff

parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。

注意:

参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。

#include<stdio.h>
#define SQUARE(x) x*x
int main()
{
	int a = 5;
	printf("%d\n", SQUARE(a));
	return 0;
}

结果为25

printf("%d\n", SQUARE(a));改为printf("%d\n", SQUARE(a+1));

结果会是36吗?

答案是11= 1 + 5 * 1 + 5

通过这个例子可以体会到宏仅仅是替换,而不是像函数那样传参

我们期望结果是36,但因为操作符优先级,导致了结果错误。所以为了达到期望的结果,我们可以使用括号

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

(1 + 5) * (1 + 5) = 36

仅仅这样够吗?

#include<stdio.h>
#define DOUBLE(x) (x)+(x)
int main()
{
	int a = 5;
	int ret = 10 * DOUBLE(a);
	printf("%d\n", ret);
	return 0;
}

以上面的思路,结果不是10 * 10 = 100,而是10 * 5 + 5 = 55

所以为了完全规避错误,应该再加一层括号

#define DOUBLE(x) ( (x)+(x) )

3.2.3 #define 替换规则

在程序中扩展#define定义符号和宏时,分为以下几个步骤。

  1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值替换。
  3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述过程,直到文本不包含宏定义的符号。

注意:

  1. 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能递归。

  2. 当预处理器(预编译)搜索#define定义的符号的时候,字符串常量的内容并不被包含在内。

    #define MAX = 1000
    #include<stdio.h>
    int main()
    {
    	int a MAX;
    	printf("MAX = %d", a);
    	//如这里的常量字符串中的MAX是不会被扫描的
    	return 0;
    }
    
    

3.2.4 #和##

任何将参数插入字符串中?

void print(int a)
{
	printf("a的值为%d\n", a);
}

#include<stdio.h>
int main()
{
	int a = 10;
	int b = 20;
	print(a);
	print(b);
	return 0;
}

在这里插入图片描述

以上代码希望每次打印出来的语句x的值是与x对应的,但这种效果函数无法做到

再来看一例代码

#include<stdio.h>
int main()
{
	printf("hello world\n");
	printf("hello" " world\n");
	return 0;
}

在这里插入图片描述

在使用打印函数时,将字符串拆开后打印的效果是一样的,因为该函数会将各部分的字符串连接成一个字符串来打印。

启发:

如果将上述的x变成一个“x”常量字符串插入要打印的语句中,那么打印出来的结果就会随x的变化而变化

下面介绍#在此处的用法

#include<stdio.h>
#define PRINT(x) printf(#x"的值为%d\n", x);
int main()
{
	int a = 10;
	int b = 20;
	PRINT(a);
	PRINT(b);
	return 0;
}

在这里插入图片描述

##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。

用例

#include<stdio.h>
#define F(x, y) x##y
int main()
{
	int xy = 88;
	printf("%d\n", F(x, y));
	return 0;
}

在这里插入图片描述

3.2.5 部分宏参数的副作用

引例

x = x + 1;
x++;

前者未改变x本身的值,无副作用

后者改变了x本身的值,有副作用

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果,如上例中的x++。

#define MAX(X, Y) ((X)>(Y)?(X):(Y))
#include<stdio.h>
int main()
{
	int a = 10;
	int b = 11;
	int c = MAX(a++, b++);
	printf("%d\n", a);
	printf("%d\n", b);
	printf("%d\n", c);
	return 0;
}

在这里插入图片描述

分析

前面已经强调,宏在编译阶段是直接将#define定义的符号替换,而不是向函数那样传参

对于int c = MAX(a++, b++);#define MAX(X, Y) ((X)>(Y)?(X):(Y))

在编译阶段实际上代码已经是

int c = ( (a++)>(b++)?(a++):(b++) );

b实际上自增了两次

像这样带有副作用的宏,实际中谨慎使用

3.2.6 宏和函数对比

就上面的比较大小的功能,使用宏还是使用函数实现,哪个更好?

答案是宏,为什么?

#define MAX(X, Y) ((X)>(Y)?(X):(Y))
#include<stdio.h>
int Max(int x, int y)
{
	return x > y ? x : y;
}

int main()
{
	int a = 10;
	int b = 11;
	
	int ret1 = MAX(a, b);
	int ret2 = Max(a, b);
	
	printf("%d\n", ret1);
	printf("%d\n", ret2);
	
	return 0;
}

计算机的处理速度很快,我们无法体会到它们的差异

首先回想之前的知识:函数需要接收参数,也要返回参数,在调用函数之前需要做准备工作;宏仅仅是替换。

让我们大概看一下汇编代码,只需体会它们在数量上的差别即可

Max函数的汇编代码

在这里插入图片描述

MAX宏的汇编代码

在这里插入图片描述

单从汇编代码的数量上我们可以直观的感受到两者的差别。

为什么不用函数来完成这个任务?

  1. 宏比函数在程序的规模和速度方面更胜一筹,因为宏不需要像函数那样在调用前和调用完毕后进行的一系列工作,省去了函数调用和返回的开销,直接替换宏定义的符号
  2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在特定类型的表达式上使用,如上例只能比较int型的数据。然而宏可适用于任何可比较的数据类型。所以宏是和数据类型无关的,它做的事只有一个——替换。

一个例子体会宏的妙处

//malloc的使用有时会让人感觉有点麻烦
//用宏定义该函数,只需传数量和类型即可

#define MALLOC(num, type) ( type *)malloc(num * sizeof(type) )

MALLOC(10, int);//类型作为参数
//等价于(被替换后)
(int *)malloc(10 * sizeof(int));

宏并不是完美的

  1. 每次使用宏的时候,将宏定义的符号替换,然后插入到代码中。除非宏比较短,否则可能大幅度增加程序的长度。

    #define ADD(X, Y) ((X)+(Y))
    #include<stdio.h>
    int main()
    {
    	int a = 1;
    	int b = 2;
    	int c = 1;
    	int d = 2; 
    	int e = 1;
    	int f = 2; 
    	//......
    	ADD(a, b);
    	ADD(c, d);
    	ADD(e, f);
    	//......
    	return 0;
    }
    //在编译时,ADD被替换以后,这些代码会变得很多
    //如果是函数,仅仅返回的是一个值,相比之下函数更简洁
    
  2. 宏本身产生的错误是不能通过调试发现的

    宏定义的符号在编译时已经被替换,而我们调试的代码是生成.exe以后的代码,这时我们眼前的宏定义的符号在计算机看来符号已经不是它了,而是符号对应的宏

  3. 宏与类型无关,虽然它很妙,但使用起来不够严谨

  4. 宏可能会带来运算符优先级的问题,导致程容易出现错,就如前面的例子(a++)提到的那样

注:

约定:宏名全部大写;函数名部分大写或不大写

3.3 #undef

用于移除一个宏定义语句

#undef NAME
//如果现存的一个符号需要被重新定义,那么它的旧符号首先要被移除。

3.4 命令行定义

许多C 的编译器允许在命令行中定义符号,用于启动编译过程。
例如:当我们根据同一个源文件要编译出不同程序的不同版本时,这个功能就有它的用武之地

假定某个程序中声明了一个某个长度(这个长度稍后在编译前定义)的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大一些,我们需要一个数组也能够与之对应

#include <stdio.h>
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;
}

在Lunix环境下的编译指令

gcc -D ARRAY_SIZE=10 programe.c
//programe是当前源文件的文件名
//gcc - 编译
// -D - 定义(define)

当按下回车,编译器便能进行编译,生成.exe文件

3.5 条件编译

在日常初学编程时,我们通常要对不同的代码分别编译,这时我们的习惯是将某部分代码注释掉,以便它们不被编译。但实际上,这样做不适合工程量很大的实际项目,我们需要有个像条件开关(switch)一样的工具,控制某些语句在某些条件下是否被编译。

编译指令1

#if 常量表达式
//...
#endif
//常量表达式由预处理器求值。

#include<stdio.h>
int main()
{
#if 0//常量表达式的值为真即可
	printf("1 ");
#endif
	printf("2 ");
	return 0;
}

结果:1 2

如果将#if 1 == 1改为#if 0 呢?

编译指令2:多个分支的条件编译


#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif

#include<stdio.h>
int main()
{
#if 1 == 1
	printf("1 ");
#elif 2 == 1//elif是else if的简写
	printf("3 ");
#endif
	printf("2 ");
	return 0;
}

结果:1 2

编译指令3:判断是否被定义

//两种写法等价
#if defined(symbol)
#ifdef symbol

//两种写法等价
#if !defined(symbol)
#ifndef symbol

//两者互为否定

#include<stdio.h>
#define DEBUG1 0//这里的常量存在与否,不论何值都没关系
//只要有定义即可
int main()
{
#ifdef DEBUG1
	printf("DEBUG ");
#endif 
#ifndef DEBUG2
	printf("NOT DEBUG2");
#endif
	return 0;
}

注:#ifndef 的n是not的意思

结果为DEBUG NOT DEBUG2

第一部分:因为DEBUG1有定义,所以#ifdef DEBUG1值为真,打印

第二部分:因为DEBUG2未定义,所以#ifndef DEBUG2值为真,打印

编译指令4:嵌套指令

#if defined(A)
		#ifdef OPTION1
			a_option1();
		#endif
			#ifdef OPTION2
				a_option2();
			#endif
#elif defined(B)
		#ifdef OPTION2
				b_option1();
		#endif
#endif

用缩进表示了各个嵌套的配对情况

小结

实际上,条件编译是被广泛应用的,比如这里随意打开一个编译器给出的头文件源代码

在这里插入图片描述

所以这个知识点蛮重要的

3.6 文件包含

在编译的第一个阶段预编译中,头文件被包含在当前源代码文件中(.c),是如何被包含的呢?

例如#include<stdio.h>这条语句,在编译时预处理器删除这条指令,如何将该头文件(.h)的内容替换至该位置,其实是有几百行的。

而每增加一条包含头文件的语句,都会将内容复制一次。

3.6.1 头文件被包含的方式

库文件包含

#include <filename.h>

本地文件包含

#include "filename"

查找流程:

先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在默认位置查找头文件。若找不到会提示编译错误。

由此看来用双引号包含头文件似乎是万能的方式,但这么做会影响效率。

举个栗子,假设一个工程要使用到很多个人做的函数,它们本身就已经包含了不少头文件,再加上自己引用的头文件,实际上已经包含了很多次头文件了,这就得让机器多找很多次头文件,影响效率。

所以两种引用头文件的方式要区分开,形成习惯。

那有没有什么方法解决这个问题呢?且看~

3.6.2 嵌套文件包含

像出现以上这种嵌套文件包含的情况,我们可以使用条件编译提高包含的效率,减少头文件包含的次数

每个头文件的开头写:

#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__

这是一种久远的写法

或:

#pragma once

这是一种较新的写法,一些老旧的编译器无法通过编译

比如stdio.h的源代码在开头就这么写了
在这里插入图片描述

4. 一道笔试题

这是某年百度公司招聘系统工程师的笔试题
请编写宏,计算结构体中某变量相对于首地址的偏移,并给出说明
分析:这其实是让我们模拟实现offsetof函数,首先请回顾该函数的用法

#include<stdio.h>
#include<stddef.h>
struct Stu
{
	char name[20];
	int age;
	char adrs[20];

};
int main()
{
	int ret = offsetof(struct Stu, age);
	printf("%d\n", ret);
	return 0;
}

传入参数为结构体名和成员名

在模拟实现之前,要明确一点:某位置的偏移量是由该位置的地址与起始位置做差得到的,然而在宏定义中,我们无法得知它们的地址,因为我们都没有使用它,自然找不到成员的地址。
offsteof函数本身用了一种巧妙的方法:将0作为结构体的起始地址,成员变量的地址即为偏移量

即将0这个数字强转为结构体指针类型,这时就可以认为在0地址处创建了一个结构体,然后访问成员变量(此时类型是成员变量的类型),接着取其地址(因为偏移量是地址之差),再强转为int或size_t(unsignud
int)类型

#include<stdio.h>
#define OFFSETOF(struct_name, member_name) (int)(&(((struct_name*)0)->member_name))
struct Stu
{
	char name[20];
	int age;
	char adrs[20];

};
int main()
{
	int ret = OFFSETOF(struct Stu, age);
	printf("%d\n", ret);
	return 0;
}

结果:20


4/5/2022
Man9o
欢迎指正!

  • 16
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值