• C语言中的程序环境和预处理


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

    在ANSI C的任何一种实现中,存在两个不同的环境

    第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令,也就是从,c文件到.exe文件;

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

    翻译环境是由编译器提供的,而执行环境是由操作系统提供的。

    如MSVC,DEV C++,Codeblocks这些编译软件都是集成开发环境,也就是集成了编辑,编译,链接和调试等功能。

    2.详解编译和链接

    2.1程序翻译环境下的编译和链接

    从源文件到可执行程序可以分为编译和链接两步,在编译阶段源文件变成了目标文件,在链接阶段目标文件变成了可执行程序。

    组成程序的每个源文件通过编译过程分别转化成目标文件;

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

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

    图解:

    2.2深入编译和链接过程

    编译本身可以分为预编译(预处理),编译和汇编。

    预编译:在预编译阶段会将#include引用的头文件给输入到文件里面,进行#define定义的标识符的替换,以及将注释给删除,因为注释是给程序员看的,不是给电脑看的;

    编译:在这个过程中会将C语言代码翻译成汇编代码,编译器会对代码进行词法分析,语法分析,语义分析,符号汇总;

    汇编:会把在编译阶段形成的汇编代码翻译成二进制的指令,并将汇总的符号形成一个符号表;

    在编译完成之后,就会开始链接,链接过程会合成段表,也就是将目标文件捆绑在一起,以及将符号表合并并进行重定位,最后生成可执行程序。

    2.3运行环境

    程序执行的过程:

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

    2.程序开始执行,并调用main函数。

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

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

    这里推荐一本书:《程序员的自我修养》,可以更加深层次的了解代码的编译和链接过程。

    3.预处理详解

    3.1预定义符号

    预定义符号都是语言内置的

    __FILE__       //进行编译的源文件

    __LINE__      //当前代码的行号

    __DATE__    //文件被编译时的日期

    __TIME__     //文件被编译时的时间

    __STDC__    //如果编译器遵循ANSI C,其值为1,否则未定义

    预定义符号的使用:

    1. int main()
    2. {
    3. printf("file:%s\nline:%d\ndata:%s\ntime:%s\n", __FILE__, __LINE__, __DATE__, __TIME__);
    4. return 0;
    5. }

     

    3.2#define

    3.2.1#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__ )

    在define定义标识符的时候,不要在最后加上;

    如下面这种情况,会出现语法错误

    1. #define NUM 100;
    2. int main()
    3. {
    4. int a = 0;
    5. if (1)
    6. a = NUM;
    7. else
    8. a = 0;
    9. return 0;
    10. }

    3.2.2#define定义宏

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

    宏的声明方式如下:

    #define name(parament-list) stuff

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

    把name(parament-list)这个整体称为宏

    注意:

    参数列表的左括号必须与name紧贴,如果两者之间存在空格,参数列表就会被解释为stuff的一部分,语法就是这么规定的。

    接下来是宏的使用:

    比如用宏实现一个数的平方:

    1. #define SQUARE(n) n * n
    2. int main()
    3. {
    4. SQUARE(6);
    5. return 0;
    6. }

    语句SQUARE(6)就会替换成6 * 6;

    解释:宏先是接受一个参数,SQRARE(n)中的n就变成了6,其后宏的内容也就由n * n变成了6 * 6,再将6 * 6替换到程序中使用宏的位置。

    但是,这个宏这么写存在一个问题,如下代码:

    1. #define SQUARE(n) n * n
    2. int main()
    3. {
    4. printf("%d\n", SQUARE(1 + 3));
    5. return 0;
    6. }

    看上去似乎最后的结果是16,然而实际上参数n会被替换成1 + 3,这样最终替换的内容是1 + 3 * 1 + 3,这条表达式最终的结果是7.

    所以需要在n的左右两边加上一对括号,如下:

    #define SQUARE(n) (n) * (n)

    再看另外一个宏定义:

    #define DOUBLE(n) (n) + (n)

    代码:

    1. #define DOUBLE(n) (n) + (n)
    2. int main()
    3. {
    4. printf("%d\n", 5 * DOUBLE(3));
    5. return 0;
    6. }

     看上去最终结果似乎是30,然而替换后语句实际上是

    printf("%d\n", 5 * (3) + (3));

    所以最终结果是18

    所以为了保证获得想要的结果,宏定义表达式两边还需要加上一对括号

    #define DOUBLE ((n) + (n))

    所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中
    的操作符或邻近操作符之间产生不可预料的相互作用。 

    3.2.3#define替换规则

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

    1.在调用宏时,首先对宏括号中的参数进行检查,看看是否包含由#define定义的符号,如果有,这些符号首先被替换。

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

    3.最后,再次对结果文件进行扫描,查看替换过后的内容是否还有#define定义的符号,如果有,则重复上述处理过程。

    注意:

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

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

    比如:

    1. #define a 123
    2. int main()
    3. {
    4. printf("%s", "a");
    5. return 0;
    6. }

    语句printf("%s", "a");中的a并不会被替换成123

    3.3.4#和##

    如何把参数插入到字符串中?

    如下代码:

    1. int main()
    2. {
    3. printf("abc""def");
    4. return 0;
    5. }

    输出的结果是abcdef

    发现字符串是有自动相连的特点的

    看下面这个代码:

    1. #define PRINT(FORMAT, VALUE) printf("the value of "#VALUE " is "FORMAT"\n", VALUE)
    2. int main()
    3. {
    4. int a = 6;
    5. PRINT("%d", a);
    6. return 0;
    7. }

    最终输出的结果是

    the value of a is 6

    所以#VALUE会被预处理器在预处理阶段预处理为"VALUE"

    接下来看看##的作用:

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

    如下代码:

    1. #define MAXMIN 6
    2. #define MIDDLE MAX##MIN
    3. int main()
    4. {
    5. printf("%d\n", MIDDLE);
    6. return 0;
    7. }

    注意:

    连接之后产生的符号必须是已经定义的,否则结果就是非法的。

    3.2.5带副作用的宏参数

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

    比如:

    x + 1;//不带有副作用

    x++;  //带有副作用

    如下代码可以证明副作用的宏参数带来的问题

    1. #define MAX(a, b) ( (a) > (b) ? (a) : (b) )
    2. int main()
    3. {
    4. int x = 5;
    5. int y = 8;
    6. int z = MAX(x++, y++);
    7. printf("x=%d y=%d z=%d\n", x, y, z);
    8. return 0;
    9. }

     在代码预处理之后

    z = ( (x++) > (y++) ? (x++) : (y++));

    所以最终结果是x=6 y=10 z=9

    3.2.6宏和函数对比

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

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

    对于为什么不用函数来完成这个任务,有两个原因:

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

    2.函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用,而宏可以适用于整型,浮点型,长整型,宏是类型无关的

    当然宏也是有缺点的:

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

    2.宏是没法进行调试的,因为在预处理阶段,宏定义的符号已经发生了替换,此时调试看到的代码和实际上运行时的代码是有所差异的;

    3.宏由于类型无关,也就不够严谨了;

    4.宏可能会带来运算级优先的问题,导致程序容易出错; 

    宏有时候也可以做到函数做不到的事情,比如宏的参数可以出现类型,但是函数不行

    如下代码:

    1. #define MALLOC(num, type)\
    2. (type *)malloc(num * sizeof(type))
    3. ...
    4. //使用
    5. MALLOC(10, int);//类型作为参数

     预处理替换后:

    (int *)malloc(10 * sizeof(int));

    会节省部分代码。

    总的来对比一下宏和函数的区别:

    属性#define定义宏                     函数
    代码长度每次使用时,宏代码都会被插入到程序中,除了非常小的宏之外,程序的长度会大幅度增长函数代码只出现于一个地方;每
    次使用这个函数时,都调用那个
    地方的同一份代码
    执行速度更快存在函数的调用和返回的额外开
    销,所以相对慢一些
    操作符优先级宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号函数参数只在函数调用的时候求
    值一次,它的结果值传递给函
    数,表达式的求值结果更容易预测
    带有副作用的参数参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果函数参数只在传参的时候求值一
    次,结果更容易控制
    参数类型宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型函数的参数是与类型有关的,如
    果参数的类型不同,就需要不同
    的函数,即使他们执行的任务是
    不同的
    调试宏是不方便调试的函数是可以逐语句调试的
    递归宏是不能递归的函数是可以递归的

    3.2.7命名约定

    函数和宏的使用语法很相似,所以语言本身没法帮助区分二者,所以平时的命名习惯是:

    宏名全部大写

    函数名不要全部大写

    3.3#undef 

    这条指令用于移除一个宏定义

    语法:

    #undef name

    使用: 

    1. #define MAX 5
    2. int main()
    3. {
    4. printf("%d\n", MAX);
    5. #undef MAX
    6. return 0;
    7. }

    3.4命令行定义

    许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
    例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)

    1. #include <stdio.h>
    2. int main()
    3. {
    4.   int array [ARRAY_SIZE];
    5.   int i = 0;
    6.   for(i = 0; i< ARRAY_SIZE; i ++)
    7.  {
    8.     array[i] = i;
    9.  }
    10.   for(i = 0; i< ARRAY_SIZE; i ++)
    11.  {
    12.     printf("%d " ,array[i]);
    13.  }
    14.   printf("\n" );
    15.   return 0;
    16. }

    3.5条件编译

    在编译一个程序的时候如果要将一条语句或者一组语句编译或者放弃掉是很方便的,因为有一个叫条件编译的东西。

    对于调试性的代码,删除比较可惜,保留又会碍事,所以可以选择性的去编译。

    如下代码:

    1. #define __DEBUG__
    2. int main()
    3. {
    4. int arr[10] = { 0 };
    5. int i = 0;
    6. for (i = 0; i < 10; i++)
    7. {
    8. arr[i] = i;
    9. #ifdef __DEBUG__
    10. printf("%d ", arr[i]);//为了观察数组是否被赋值成功
    11. #endif
    12. }
    13. return 0;
    14. }

    1.
    #if 常量表达式
            //...
    #endif
    //常量表达式由预处理器求值。
    如:
    #define __DEBUG__ 1
    #if __DEBUG__
            //..
    #endif

    2.多个分支的条件编译
    #if 常量表达式
            //...
    #elif 常量表达式
            //...
    #else
            //...
    #endif

    3.判断是否被定义
    #if defined(symbol)
    #ifdef symbol


    #if !defined(symbol)
    #ifndef symbo

    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.6文件包含

    #include指令可以使另一个文件被编译,会让被包含的头文件出现在#include指令的地方

    这种替换的方式很简单,预处理器会先删除这条指令,并用包含文件里的内容进行替换,如果这个文件被包含了10次,那实际上就会被编译10次

    3.6.1头文件被包含的方式

    本地文件包含:

    #include "fliename.h"

    查找方法:先在源文件的目录下去查找,如果该头文件未被找到,编译器就会像去查找库函数的头文件一样在标准位置去查找头文件,如果还找不到就提示编译错误。

    库文件包含:

    #include <filename.h>

    查找方法:直接在标准路径下去查找,如果找不到就提示编译错误。

    虽然可以对库文件也采用""的包含方式,但是当目录下的文件非常多的时候,这样查找起来的效率就会低一些了,而且也不容易去区分是库文件还是头文件了。

    3.6.2嵌套文件包含

    如图:

    common.h和common.c是公共模块

    test1.h和test1.c使用了公共模块

    test2.h和test2.c使用了公共模块

    test.h和test.c使用了test1模块和test2模块

    这样最终的程序中就会包含两次common.h了,等于有2份common.h的内容,会造成代码的重复。

    对此可以采用条件编译的方式来解决这个问题

    在引用每个头文件时在开头写上这么一个内容:

    1. #ifndef __STDIO_H__
    2. #define __STDIO_H__
    3. #include <stdio.h>
    4. #endif

    如果没有定义标识符__STDIO_H__就定义__STDIO_H__并且去包含头文件

    如果下次还遇到包含头文件的代码,由于__STDUI_H__已经被定义过,所以也就不会进行第二次包含了

    或者对于在头文件的开头也可以这么写:

    1. #pragma once
    2. #include <stdio.h>

    也可以避免头文件的重复引入

    4.其他预处理指令

    比如:

    #error

    #pragma

    #line

    这里推荐《C语言深度解剖》,里面会有更加详细的内容。

    关于程序环境和预处理的内容就讲到这里了,今后也会不定期更新

  • 相关阅读:
    python提效小工具-统计xmind用例数量
    JMeter使用记录
    heic文件怎么转换成jpg?实用图片格式转换方法分享
    pandas索引函数loc和iloc的区别
    ArduinoIDE快速搭建ESP32开发环境
    SQL知识大全(二):SQL的基础知识你都掌握了吗?
    无人机快递(物流)技术方案,无人机快递(物流)基础知识
    鸿蒙系统(HarmonyOS)-- 第2章:鸿蒙Ul框架
    2023年-10月-第1周周报
    谷粒学院16万字笔记+1600张配图(十三)——搭建前台环境、首页数据显示
  • 原文地址:https://blog.csdn.net/l_shadow_m/article/details/125406604