C语言的程序环境和预处理详解

简介: 我们平常写的代码都是通过编译器来运行的。我们有没有想过编译器是怎么将代码转化为各种指令最后输出结果呢?这篇文章会详细解释编译器的运行的整个过程的细节,希望会对你有所帮助。

我们平常写的代码都是通过编译器来运行的。我们有没有想过编译器是怎么将代码转化为各种指令最后输出结果呢?这篇文章会详细解释编译器的运行的整个过程的细节,希望会对你有所帮助。



一、程序的翻译环境和执行环境


我们可以简单认为编译器把代码首先进行翻译,然后再执行。所以在ANSIC的任何一种实现中,存在两个不同的环境:


第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。

第2种是执行环境,它用于实际执行代码。

 那编译器具体是怎么翻译和执行的呢?这就要看编译和链接的过程了。我们接着往下看。


二、编译和链接详解

2、1 翻译环境


 我们先来看一下整个的翻译过程,翻译环境大致可分为以下三个步骤:


组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。

每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。

链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

 具体我们也可结合下图一起理解:


c16b0ff77ef048ae8ebcbeed988b4bb6.png




我们再具体看其中的编译和执行的细节。

2、2 编译过程详解

 我们先来看一段代码:

sum.c
int g_val = 2016;
void print(const char *str) 
{
    printf("%s\n", str);
}
test.c
#include <stdio.h>
int main()
{
    extern void print(char *str);
    extern int g_val;
    printf("%d\n", g_val);
    print("hello bit.\n");
    return 0; 
}



 我们可以看到上述代码中有两个源文件,分别是:sum.c 和 test.c。在对上述代码进行编译的时候,具体又分为以下步骤:


预编译(预处理)。主要是处理预处理指令,有头文件的包含#include、定义符号的替换和删除#define、注释的删除等等。

编译。把C语言代码翻译成汇编代码。其中有语法分析、词法分析、语义分析、符号汇总。

汇编。把汇编代码翻译成二进制指令,同时形成符号表。

链接。符号表的合并和重定位、合并段表。

 在上述编译的过程中,第2点的符号汇总是指讲全局变量函数名称当作符号汇总,然后再汇编阶段将汇总的全局变量函数名称与其地址形成一个符号表。最后,由于有多个源文件会生成多个符号表,在链接阶段对符号表进行合并和重定位。链接完后会生成可执行程序。


 上述代码的编译过程中的符号表生成如下图:


7bd1da89aa334105959f3f0096ff0641.png




注意,多个源文件隔离编译,生成各自的符号表。最后会在链接时对符号表进行汇总和重定位。

2、3 执行环境


上述讲述了编译和链接后生成可执行文件,那么我们再看一下程序执行的过程:


程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。

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

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

终止程序。正常终止 main 函数,也有可能是意外终止。


三、预处理详解

3、1 预定义符号

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



上述的符号均为预处理符号。在文件的预处理阶段均会被替换成相应的数据。我们结合以下代码理解。

#include<stdio.h>
int main()
{
  printf("line:%d\n", __LINE__);
  printf("%s\n", __DATE__);
  printf("%s\n", __TIME__);
  return 0;
}


上述代码的运行结果为,如下图:

3、2 #define

3、2、1 #define定义的符号


 我们直接看#define的使用方法,代码如下:

//用法
#define name stuff
//例子
#define MAX 1000
#define reg register   //为 register这个关键字,创建一个简短的名字
#define do_forever for(;;)   //用更形象的符号来替换一种实现
#define CASE break;case      //在写case语句的时候自动把 break写上。
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
                          date:%s\ttime:%s\n" ,\
                          __FILE__,__LINE__ ,       \
                          __DATE__,__TIME__ )


 我们对上述的例子进行一一解释。第一个就是用 MAX 代替了 1000。第二个我们在使用register 关键字时会感到很麻烦,因为这个关键字太长了。于是用了 reg 代替了regisert。第三个其实是死循环。第四个效果更加明显。当我们使用switch语句时,可能经常忘记break,于是用CASE 代替了 break;case。第五个就很简单,直接代替了一个打印语句。


 注意,在define定义标识符的时候,在最后不要加上 ; 。因为define定义标识符时进行替换的,加上 ; 时可能会出现意想不到的错误。


3、2、2 #define 定义宏

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



语法:#define name( parament - list ) stuff

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

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

 我们举一个例子,代码如下:  

#define SQUARE( x ) x * x
int main()
{
  printf("%d", SQUARE(5));
  return 0;
}


 将上面的代码进行预处理后,打印的是5*5的值。我们再来看一段代码:

#define SQUARE( x ) x * x
int main()
{
  //printf("%d", SQUARE(5));
  int a = 5;
  printf("%d\n", SQUARE(a + 1));
  return 0;
}


 我们的本意是想打印出a+1的平方,但是结果并非如此。结果如下图:

 我们来分析一下替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:

printf ("%d\n",a + 1 * a + 1 )。自然而然,结果就是11。


 这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。

在宏定义上加上两个括号,这个问题便轻松的解决了:

#define DOUBLE( x)   ( ( x ) * ( x ) )

3、2、3 #define 替换规则


在程序中扩展#define定义符号和宏时,需要涉及几个步骤:


在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,它们首先被替换。

替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。

最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,就重复上述处理过程。

 注意:

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

当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。


3、3 宏和函数的对比

 宏通常被应用于执行简单的运算。 比如在两个数中找出较大的值。

#define MAX(a, b) ((a)>(b)?(a):(b))


那么为什么不用函数呢?其有如下两个原因:

用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。 所以宏比函数在程序的规模和速度方面更胜一筹 。

更为重要的是函数的参数必须声明为特定的类型。 所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以

用于 > 来比较的类型。 宏是类型无关的。


当然,宏和函数对比也是有不足的,有如下几点:


每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。

宏可能会带来运算符优先级的问题,导致程容易出现错。

宏是没法调试的。

宏由于类型无关,也就不够严谨。


 更加具体的宏和函数的对比总结如下表格:

#define定义宏

函数

1.png


3、4 条件编译


 在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。

 比如:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。代码如下:

#include <stdio.h>
#define __DEBUG__
int main()
{
 int i = 0;
 int arr[10] = {0};
 for(i=0; i<10; i++)
 {
  arr[i] = i;
  #ifdef __DEBUG__
  printf("%d\n", arr[i]);//为了观察数组是否赋值成功。 
  #endif //__DEBUG__
 }
 return 0; 
}



 条件编译指令有很多,我们来看一下常见的条件编译指令:

1.
#if 常量表达式
 //...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
 //..
#endif
2.多个分支的条件编译
#if 常量表达式
 //...
#elif 常量表达式
 //...
#else
 //...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
4.嵌套指令
#if defined(OS_UNIX)
 #ifdef OPTION1
 unix_version_option1();
 #endif
 #ifdef OPTION2
 unix_version_option2();
 #endif
#elif defined(OS_MSDOS)
 #ifdef OPTION2
 msdos_version_option2();
 #endif
#endif


3、5 头文件的包含

我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。 这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10 次,那就实际被编译 10 次。


3、5、1 头文件被包含的方式


头文件的包含方式有两种:


本地文件包含。如: #include "filename" 。

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

库文件包含。如: #include <filename.h> 。

查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。这样是不是可以说,对于库文件也可以使用 “” 的形式包含?答案是肯定的,可以 。 但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。


3、5、2 嵌套文件包


 如果出现如下场景:



comm.h 和 comm.c 是公共模块。 test1.h和 test1.c 使用了公共模块。 test2.h和 test2.c 使用了公共模块。 test.h和 test.c 使用了 test1 模块和 test2 模块。 这样最终程序中就会出现两份comm.h 的内容。这样就造成了文件内容的重复。 如何解决这个问题? 答案:条件编译。

  每个头文件的开头写如下代码即可


#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif   //__TEST_H__
或者
#pragma once


预处理指令的内容就讲解到这里,希望以上内容对你有所帮助ovo~

相关文章
|
1月前
|
C语言 索引
C语言编译环境中的 调试功能及常见错误提示
这篇文章介绍了C语言编译环境中的调试功能,包括快捷键操作、块操作、查找替换等,并详细分析了编译中常见的错误类型及其解决方法,同时提供了常见错误信息的索引供参考。
|
1月前
|
存储 算法 C语言
"揭秘C语言中的王者之树——红黑树:一场数据结构与算法的华丽舞蹈,让你的程序效率飙升,直击性能巅峰!"
【8月更文挑战第20天】红黑树是自平衡二叉查找树,通过旋转和重着色保持平衡,确保高效执行插入、删除和查找操作,时间复杂度为O(log n)。本文介绍红黑树的基本属性、存储结构及其C语言实现。红黑树遵循五项基本规则以保持平衡状态。在C语言中,节点包含数据、颜色、父节点和子节点指针。文章提供了一个示例代码框架,用于创建节点、插入节点并执行必要的修复操作以维护红黑树的特性。
49 1
|
1月前
|
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仍因其灵活性和强大的功能被广泛采用。
64 1
|
28天前
|
编译器 C语言 计算机视觉
C语言实现的图像处理程序
C语言实现的图像处理程序
52 0
|
12天前
|
存储 编译器 程序员
C语言程序的基本结构
C语言程序的基本结构包括:1)预处理指令,如 `#include` 和 `#define`;2)主函数 `main()`,程序从这里开始执行;3)函数声明与定义,执行特定任务的代码块;4)变量声明与初始化,用于存储数据;5)语句和表达式,构成程序基本执行单位;6)注释,解释代码功能。示例代码展示了这些组成部分的应用。
27 10
|
16天前
|
Shell Linux API
C语言在linux环境下执行终端命令
本文介绍了在Linux环境下使用C语言执行终端命令的方法。首先,文章描述了`system()`函数,其可以直接执行shell命令并返回结果。接着介绍了更强大的`popen()`函数,它允许程序与命令行命令交互,并详细说明了如何使用此函数及其配套的`pclose()`函数。此外,还讲解了`fork()`和`exec`系列函数,前者创建新进程,后者替换当前进程执行文件。最后,对比了`system()`与`exec`系列函数的区别,并针对不同场景推荐了合适的函数选择。
|
1月前
|
存储 自然语言处理 程序员
【C语言】文件的编译链接和预处理
【C语言】文件的编译链接和预处理
|
28天前
|
程序员 编译器 C语言
C语言中的预处理指令及其实际应用
C语言中的预处理指令及其实际应用
54 0
|
C语言 网络协议
C语言及程序设计进阶例程-8 预处理之宏定义
贺老师教学链接  C语言及程序设计进阶 本课讲解 宏定义 #include &lt;stdio.h&gt; #define PI 3.1415926 int main ( ) { float r,l,s,sq,vq; printf("please enter r:"); scanf("%f", &amp;r); l=2 * PI *r; s=r * r * PI;
999 0
|
C语言
C语言预处理之二-----宏定义那点事儿
1、关于宏的副作用,请看下面代码:   #include stdio.h> #define GOODDEF (input+3) #define POORDEF input+3   //这里是宏的副作用最经典的例子,不穿裤子!!!如果你这样用,下面你就知错!! ...
986 0