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

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

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被替换以后,这些代码会变得很多
//如果是函数,仅仅返回的是一个值,相比之下函数更简洁
  1. 宏本身产生的错误是不能通过调试发现的
    宏定义的符号在编译时已经被替换,而我们调试的代码是生成.exe以后的代码,这时我们眼前的宏定义的符号在计算机看来符号已经不是它了,而是符号对应的宏
  2. 宏与类型无关,虽然它很妙,但使用起来不够严谨
  3. 宏可能会带来运算符优先级的问题,导致程容易出现错,就如前面的例子(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

欢迎指正!

目录
相关文章
|
1月前
|
C语言 索引
C语言编译环境中的 调试功能及常见错误提示
这篇文章介绍了C语言编译环境中的调试功能,包括快捷键操作、块操作、查找替换等,并详细分析了编译中常见的错误类型及其解决方法,同时提供了常见错误信息的索引供参考。
|
28天前
|
存储 算法 C语言
"揭秘C语言中的王者之树——红黑树:一场数据结构与算法的华丽舞蹈,让你的程序效率飙升,直击性能巅峰!"
【8月更文挑战第20天】红黑树是自平衡二叉查找树,通过旋转和重着色保持平衡,确保高效执行插入、删除和查找操作,时间复杂度为O(log n)。本文介绍红黑树的基本属性、存储结构及其C语言实现。红黑树遵循五项基本规则以保持平衡状态。在C语言中,节点包含数据、颜色、父节点和子节点指针。文章提供了一个示例代码框架,用于创建节点、插入节点并执行必要的修复操作以维护红黑树的特性。
46 1
|
28天前
|
NoSQL 编译器 程序员
【C语言】揭秘GCC:从平凡到卓越的编译艺术,一场代码与效率的激情碰撞,探索那些不为人知的秘密武器,让你的程序瞬间提速百倍!
【8月更文挑战第20天】GCC,GNU Compiler Collection,是GNU项目中的开源编译器集合,支持C、C++等多种语言。作为C语言程序员的重要工具,GCC具备跨平台性、高度可配置性及丰富的优化选项等特点。通过简单示例,如编译“Hello, GCC!”程序 (`gcc -o hello hello.c`),展示了GCC的基础用法及不同优化级别(`-O0`, `-O1`, `-O3`)对性能的影响。GCC还支持生成调试信息(`-g`),便于使用GDB等工具进行调试。尽管有如Microsoft Visual C++、Clang等竞品,GCC仍因其灵活性和强大的功能被广泛采用。
59 1
|
4天前
|
C语言
C语言判断逻辑的高阶用法
在C语言中,高级的判断逻辑技巧能显著提升代码的可读性、灵活性和效率。本文介绍了六种常见方法:1) 函数指针,如回调机制;2) 逻辑运算符组合,实现复杂条件判断;3) 宏定义简化逻辑;4) 结构体与联合体组织复杂数据;5) 递归与分治法处理树形结构;6) 状态机管理状态转换。通过这些方法,可以更高效地管理和实现复杂的逻辑判断,使代码更加清晰易懂。
170 87
|
25天前
|
编译器 C语言 计算机视觉
C语言实现的图像处理程序
C语言实现的图像处理程序
48 0
|
10天前
|
存储 编译器 程序员
C语言程序的基本结构
C语言程序的基本结构包括:1)预处理指令,如 `#include` 和 `#define`;2)主函数 `main()`,程序从这里开始执行;3)函数声明与定义,执行特定任务的代码块;4)变量声明与初始化,用于存储数据;5)语句和表达式,构成程序基本执行单位;6)注释,解释代码功能。示例代码展示了这些组成部分的应用。
22 10
|
29天前
|
存储 缓存 编译器
【C语言篇】scanf和printf万字超详细介绍(基本加拓展用法)(下篇)
scanf处理⽤⼾输⼊的原理是,⽤⼾的输⼊先放⼊缓存,等到按下回⻋键后,按照占位符对缓存进⾏解读。 解读⽤⼾输⼊时,会从上⼀次解读遗留的第⼀个字符开始,直到读完缓存,或者遇到第⼀个不符合条件的字符为⽌。
|
29天前
|
存储 C语言
【C语言篇】scanf和printf万字超详细介绍(基本加拓展用法)(上篇)
printf 的作⽤是将参数⽂本输出到屏幕。它名字⾥⾯的 f 代表 format (格式化),表⽰可以定制输出⽂本的格式。
|
25天前
|
程序员 编译器 C语言
C语言中的预处理指令及其实际应用
C语言中的预处理指令及其实际应用
52 0
|
5天前
|
存储 Serverless C语言
【C语言基础考研向】11 gets函数与puts函数及str系列字符串操作函数
本文介绍了C语言中的`gets`和`puts`函数,`gets`用于从标准输入读取字符串直至换行符,并自动添加字符串结束标志`\0`。`puts`则用于向标准输出打印字符串并自动换行。此外,文章还详细讲解了`str`系列字符串操作函数,包括统计字符串长度的`strlen`、复制字符串的`strcpy`、比较字符串的`strcmp`以及拼接字符串的`strcat`。通过示例代码展示了这些函数的具体应用及注意事项。