发帖数

43

原创数

43

关注者

16

阅读数

9334

点赞数

2

张角

  • STM32 C语言变量的定义和初始化

    我们今天探讨C语言变量的定义和初始化。那么我们首先要明确三个问题。第一,我们要明白什么是变量,或者为什么C语言一定要有变量;第二个在C语言中如何去表达这些变量,或者说C语言都有什么类型的变量如何定义这些变量;第三,变量为什么要初始化,以及如何初始化。

    第一个问题,关于变量,一个最通俗的理解就是变化的量。本来外在的物质世界就是在不断变化的,不是有句话么:“唯一不变的就是变化”。C语言作为描述客观世界变化的一种语言,首先就是要有能够对外界事物变化状态量化的工具,那么这个工具就是变量。数字世界,首先就是量化,这个是一切后续工作的基础。

    我们下面拿几个常用的变量类型进行说明,比如char型变量,它主要是用来应对0  255之间变化的事物的,比如字符什么的。比如float,浮点型的变量,它主要是量化客观世界中模拟量的事物,比如汽车的速度、太阳光的强度等等;再比如int型的变量,它的描述范围就比char型大得多了,它主要是应对整数变化的客观事物的,比如学生的个数、苹果的个数等等。

    那么实际上,我们说C语言的变量远不止这些简单的数据类型,是吧。我们还有数组,结构体,还有指针、栈、链表等等。每种数据类型的出现都是为了解决一个量化的问题,比如指针,它主要是定位量化计算机中内存寻址问题;比如结构体,它的定位主要是用来描述复杂事物的,就比如汽车,它不仅有行驶的速度,还有轮子的个数,椅子的个数等等;再比如栈,它的主要作用是解决任务切换以及函数调用时,程序现场的保护问题。

    那么也就是说每一个变量类型或者量化工具类型的出现,都是有原因的,都是为了解决实际问题的。当我们从这个视角去看这些变量类型和必要性的时候,我们的理解就会深刻很多。举个例子,比如面向对象数据类型的产生,就是把方法或者函数集成到了一个类型中,这样就可以更为准确的去描述客观事物,比如一个狗狗,它不仅有一条大尾巴,还可以跑得非常快,大尾巴是数据,会跑且跑得快是方法。面向对象的语言比如C++或者Java,就把这些变量和方法封装起来,形成一个新的更为综合的量化工具,那就是对象。

    image.png

    image.png

    站在C语言的基础上,往上看是C++语言等面向对象的,但是如果往下看,比如到了汇编级别,就是另外一番场景。我们知道汇编语言是最接近机器的语言,对于某一类型的单片机,它一般有几十条特定中汇编指令。但是我们说汇编语言是没有数据类型的,它操作的只有二进制类型的数据,并没有对这些数据进行按照其属性进行分类。没有根据属性对数据进行分类,其实也就是说没有对量化工具进行分类,那么人类的大脑就要耗费更多的能量去理解汇编程序,人的大脑本身都是很懒的,能省能量肯定是想办法节省能量。从这个角度上的看,汇编语言更像是机器语言。

    image.png

    但是我们说电脑本身就是机器,不同汇编语言的指令集才能真正反映各个芯片架构的不同,指令集不同可能对应的电路也是不同的。任何高级语言最后还是要在特定的机器上运行的,那也就是说这些高级语言最后还是要翻译成特定的汇编语言。这个翻译的工作就是编译器要做的事情。另外,软件的开发还要有量好的代码编辑环境以及调试环境(比如支持单步调试,实时查看寄存器及存储单元的数据),所以一款新的单片机是不是好用会有多方面的因素影响的,也不能只看指令集的执行效率。

    第二个问题,C语言都有什么类型的变量呢?我们可以用一张表来大概描述一下,下面这张表对C语言的数据类型进行了相对完整的总结。大家可以看一下,整体的数据类型被划分为四类:基本类型,构造类型(组合类型),指针类型还有空类型。基本的数据类型肯定是根本,C语言在级别数据类型的基础上构造出更为复杂的数据类型,用于描述相对复杂的事物,比如结构体等等。那么C语言就是使用这些相对抽象的基本类型,去量化和描述纷繁复杂的外部世界的。我们在前面已经提到了绝大多数数据类型产生的原因,这里就不再赘述了。如果有还不理解的同学,可以自己上网去查一查资料。

    image.png

    从下面这幅图可以看出,不同的基础数据类型器长度是不一样的,而且同一种数据类型在不同的机器和编译器编译下其数据长度也是不一样的。不同的基础数据类型,所占据是二进制数据位数发生不同,这个是可以理解的。对于相对简单的事物,比如字符,本来就不需要使用那么长的位数来表达,这个是基本诉求。再一个,比如对于char类型这种需要较少位数就可以表达的可以量化的事物,如果使用int这种长度的数据去表达,本身也是对计算机存储资源的浪费。基于上述两个原因,才出现了不同的数据类型有不同的长度的现象。我们在实际编程的时候,从设计的角度上来看,肯定是选择使用最少的存储位数来量化自己要描述的事物,这样占用的资源才能是最少。当程序代码行数非常多的时候,这种差异就会相对非常明显了。

    image.png

        下面,我们来探讨第三个问题,变量为什么要初始化以及如何初始化。我们首先解释一下为什么单片机数据最好要初始化。众所周知,变量是存储在RAM中,掉电后即丢失,上电后默认全为0。那么这样的话没赋初值的变量值全为0,这也应该是大家认为理所当然的。但是实际上并不是这样,有些类型的单片机,当单片机复位的时候(包括硬件复位即按下复位按钮,看门狗复位,以及其它软件程序复位),单片机只是重新跳回到main函数开始执行,而并没有清空RAM!所以,那些只是定义而没有赋初值的变量(尤其是全局变量)依然会使用复位前留下来的值!那么这样程序运行可能就会出现异常的结果尤其是指针变量。数据有一个初始的值,整个程序也就有了一个初始状态,初始状态确定了,如果程序设计得没有问题,那么就可以按照既定的规则跑下去。如果程序错误发生在初始位置上,那就太可惜了。大家在编程的时候,一定要注意这个现象。

    image.png

    那么下面我们看一下,如何对变量进行初始化。不同的变量类型,初始化的方式肯定是不一样的。首先对于基础的数据类型,可以直接初始化成想要的值即可。那么对于数组、结构体等类型,初始化的方法就具体问题具体分析,各具特色了。我们下面举例子进行说明。

    一维数组:

    int a[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

    int a[10] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0};

    int a[10] = {0};

    int a[] = {1, 2, 3, 4, 5};

    二维数组:

    int a[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};

    int a[3][4] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};

    int a[3][4] = {{1}, {5}, {9}};

    /*

        1 0 0 0

        5 0 0 0

        9 0 0 0

    */

    int a[3][4] = {{1}, {0, 6}, {0, 0, 11}};

    /*

        1 0 0 0

        0 6 0 0

        0 0 11 0

    */

    int a[3][4] = {{1}, {5, 6}};

    /*

        1 0 0 0

        5 6 0 0

        0 0 0 0

    */

    int a[][4] = {{0, 0, 3}, {}, {0, 10}};

    /*

        0 0 3 0

        0 0 0 0

        0 10 0 0

    */

    字符数组:

    char c[10] = {'I', ' ', 'a', 'm', ' ', 'h', 'a', 'p', 'p', 'y'};

    char c[2][3] = {{'y', 'o', 'u'}, {'a', 'r', 'e'}};

     

    char c[] = {"I am happy"};

    char c[] = "I am happy";    // 可以省略花括号

    char c[] = {'I', ' ', 'a', 'm', ' ', 'h', 'a', 'p', 'p', 'y', ''}; // 与上面等价


    char *p;

    p = "I love China"; // 正确

     

    char c[14];

    c = "I love China"; // 错误

    c[14] = "I love China"; // 错误

     

    结构体:

    struct {

    char name[20];

    int age;

    }student1, student2;

    //匿名结构体

    struct Student {

    char name[20];

    int age;

    }student1, student2;

    //声明结构体

     

     

    struct Student {

    char name[20];

    int age;

    }student1 = {"xiaoming", 20};

    struct Student student1={.age=12}; // C99可以只对age进行初始化,其他变量初始化成零

     

    联合体:

    union Data {

    int i;

    char ch;

    float f;

    }a = {1, 'a', 1.5}; // 错误,不能同时初a.ch = 'A'; // 正确

    对共用体赋值要指明赋值对象,如

     

    a.f = 1.5;  // 正确

    a.i = 40;   // 正确

    a = 1;      // 错误,没有指明赋值对象始化3

    union Data a = {16}; // 正确

    union Data a = {.ch='j'}; // 正确 C99新增

     

    枚举:

    第一个枚举成员的默认值为整型的 0,后续枚举成员的值在前一个成员上加 1

    声明

    enum Weekday {sun, mon, tue, wed, thu, fri, sat};

    定义

    enum Weekday workday, weedkend;

    赋值

    enum Weekday {sun=7, mon=1, tue, wed, thu, fri, sat};

    // sun=7, mon=1, tue=2, wed=3, thu=4, fri=5, sat=6

     

    把上述三个问题到搞清楚后,我们的这篇文章到此就结束了。

     

    参考资料

    C语言数据类型总结 https://blog.csdn.net/xingjiarong/article/details/46942649

    C++继承 https://www.runoob.com/cplusplus/cpp-inheritance.html

    ③单片机C语言探究--为什么变量最好要赋初值 https://blog.csdn.net/weixin_34342207/article/details/92999746

    C语言-定义与初始化总结 https://blog.csdn.net/syzdev/article/details/103532435


    收藏 0 回复 0 浏览 547
  • RISC-V鸿蒙开发板单片机简介(项目连载8)

    我们使用的单片机是深圳睿思芯科公司出品的Pygmy-E系列的单片机,它是面向IOT超低功耗32SOC,内含睿思独立设计的ORV32 RISC-V内核,实现了RV32IMC标准指令扩展。主要用于IOT终端设备的感知、控制、连接等领域,如智能家居、智能控制、智慧工业、智慧园区等。

    image.png

    这款单片机主频可以高达100Mhz。在存储方面,一级缓存8KB,二级缓冲256KB,存储容量还是比较大的。同时它支持丰富的外设接口,比如GPIOUARTSPIIIC等,可以实现复杂的通信及控制功能。

    在低功耗方面,Pygmy-E拥有极低的功耗表现,通过对CPU极致的低功耗微架构设计和对SoC系统层级进行深度优化设计,动态功耗远优于同等计算性能的ARM芯片,并且实现的uW级别的SOC待机功耗。

    整个处理器的框架图,是如下这个样子,供大家参考。

    image.png

    我们后面会分几个模块来介绍这款单片机,比如GPIO接口、I2C接口、SPI接口以及中断系统等等。这篇文章里面,主要介绍系统时钟以及GPIO接口的一个关键点,介绍这个的时候,我会尽可能的和ARM架构的单片机进行比较,在对比中把这款单片机架掌握得更加深刻。

    1. 时钟

    整个系统主频和外设的频率(SysTick定时器,UARTI2CSPI等)是可以自己设置的,不过从目前我这边拿到的资料来看,在时钟树这块,它的架构不像ARM内核那样可以通过对RCC寄存器的配置来统一对时钟进行管理。

    不过这并不影响单片机的使用,只是在配置时钟这块需要对各个外设寄存器有更加深入的了解,因为外设时钟信号的配置更多是在这里进行的。

    我们这里外部使用的是20Mhz的有源晶振,实际系统中的主频也就配置成了20Mhz

    image.png

    1. GPIO

    这款芯片的外设使用的是新思科技的模块,对GPIO口复用的配置主要是通过各个接口的SELDIR两个寄存器来实现的。SELDIR寄存器的值都为0的时候,使用的是复用模块的功能;SELDIR寄存器中的值均为1的时候,单片机使用的是GPIO口模块的功能。但是每个接口模块,只能有一个复用功能,这个地方不同于我们经常使用的STM32单片机。在STM32单片机中,大部分接口都会支持好几个功能模块的复用。

    image.png

    从上图中,大家可以看出来每一个端口目前只有一个复用功能,我们在实际进行端口配置的时候,灵活度相对来说要小不少。

    大家可以看一下GPIO模块的通用框图。

    image.png

    从这个通用框图,可以看出来,GPIO输入和输出值的改变,是通过D触发器完成的。用硬件原理上来说,如果GPIO接口的模块没有时钟信号,那么这个模块就不能够工作。我们可以直接通过控制时钟模块就可以控制响应接口是不是处于工作,这样就可以达到控制整个芯片功耗的目的。但是我们在一般的ARM单片机提供的参考手册中看不到这样的框图,这个框图中对D触发器的解释相应地来说,会更加清晰。


    收藏 0 回复 0 浏览 472
  • STM32 UART、I2C、SPI串口通信对比分析

          大家好,我是张飞实战电子张角老师!

    所有通信协议,应该都是一个速度、成本的折中,这里的成本包括完成通信所占据的接口资源(或者说需要几根线)以及硬件电路模块设计的复杂度。

        我们前面分析过,如果想要实现高速的数据通信,通信双方的时钟是必须同步的,否则可能会出现数据错位的情况,尤其是通信双方本身的时钟频率相差较大的时候。那么自然的,uart作为一种异步通信协议,它的传输速率肯定不会太高,或者说肯定是小于同步通信协议的。反过来说,如果异步通信协议采用采用发送方或者接受方中的一个时钟频率,作为通信的波特率,那么在两者的时钟频率相差较大的情况下,一定会出现数据错配的情况。甚至有可能,每次传递8个数据都不一定传输得了。信号传输的波特率降低了以后,可以降低对通信双方频率相差的要求,反正最后都要把自身的频率和波特率之间进行一次转换,归一到统一的波特率上来。但是归一化之后依然会有误差的,这个我们前面讨论过,所以依然不能每次传递较多的数据。再加上,uart通信协议中,各种辅助位占据了一定的资源(大约25%),数据传递的速度肯定就更慢了。

        虽然uart传输的速度相对较慢,但是如果只是单向通信的话,着实相对来说,非常简单。只需要两根线就可以了,一根Tx就行(大多数情况下,通信的双方都是共地的)。

    那么现在我们达成了共识,如果要进行高速的串口通信,必须在通信的双方之间进行时钟同步。那么SPII2C这两种协议,应运而生了。那么SPII2C有什么区别呢?我感觉这两个有点像TCPUDP的区别一样,TCP有校验和握手机制;UDP则没有,只是不管不顾发数据。具体表现就是I2C是有应答机制的,但是SPI没有,那么自然I2C这种通信协议的传输速率是没有SPI快的。再一个,在I2C通信中,不管是读指令还是写指令,首先进行的是不是寻址呀,找到相应的芯片以后,才能进行下一步的数据传输,是吧。但是SPI就不用搞这个操作,它是通过硬件的片选信号之间指定从机的,从地址寻址上看,它的速度要远比I2C高。还有一个,不管是读数据还是写数据,一般都是还还要再指定寄存器的地址,然后主机才能通过SDA总线去读取从机中的数据,但是SPI一般是直接通过指令读取相应的寄存器,这中间又少了一次寻址的过程,那么有效数据传递的速度相比I2C,肯定是高了许多。

    从通信双工的角度来讲,SPI还支持双工通信,那数据传输的速度就更快了,在这个方面作为半双工通信的I2C自然是更比不了。

    虽然传输速度,I2C比不上SPI,但是I2C也是有自己的优点呀,那就是占用的端口资源更少。只要两根线就行了,一根数据线,一根时钟线,就足够了,就可以实现双向通信了。但是SPI可不行了,SPI要实现这种双向通信,至少需要四根线,CLKMOSIMISOCSS。俗话说,一分价钱一分货,此言不虚。

    image.png

            I2CSPI都是可以实现点对多点进行通信的,既然要实现点对多点进行通信,那么就必须解决寻址的问题。SPI,我们说过了,仗着自己数据传输速度快的优点,财大气粗,对每一个从机都安排了一个片选信号线。这种操作,着实占用了不少单片机的接口资源。但是I2C就不行了,因为数据传递的速度相对较慢,自然得节省开支,包括在点对多点的寻址上也是如此。I2C通信协议是通过地址码的方式来解决多机寻址问题的,这个就有点像SPI的片选线。

    系统中的所有外围器件都具有一个7位的"从器件专用地址码",其中高4位为器件类型,由生产厂家制定,低3位为器件引脚定义地址,由使用者定义。主控器件通过地址码建立多机通信的机制,因此I2C总线省去了外围器件的片选线,这样无论总线上挂接多少个器件(当然终端在要在有效个数范围内),其系统仍然为简约的二线结构。终端挂载在总线上,有主端和从端之分,主端必须是带有CPU的逻辑模块,在同一总线上同一时刻只能有一个主端,可以有多个从端,从端的数量受地址空间和总线的最大电容 400pF的限制。

    image.png

    基于IIC总线的物理结构,总线上的STARTSTOP信号必定是唯一的。如果同时又两个设备同时发起数据传送怎么办?这就涉及到时钟同步和SDA仲裁的问题。我们这里注意到SDASCL都是开漏输出设计,那么自然SDASCL都具有线与功能。在SCL总线上只有所有的设备的SCL都为高的时候,SCL才为高,只要有一个为低,那么SCL就为低。不管怎样,所有从设备设备的SCL总是同步的。要么是高,要么是低。

    那么SDA的仲裁问题是怎么回事呢?SDA线的仲裁也是建立在总线具有线“与”逻辑功能的原理上的。节点在发送1位数据后,比较总线上所呈现的数据与自己发送的是否一致。是,继续发送;否则,退出竞争。这里的比较功能是I2C设计的亮点,有了这个功能为基础,SDA的总线仲裁才能是自动执行的。SDA线的仲裁可以保证I2C总线系统在多个主节点同时企图控制总线时通信正常进行并且数据不丢失。总线系统通过仲裁只允许一个主节点可以继续占据总线。

    image.png


    上图是以两个节点为例的仲裁过程。DATA1DATA2分别是主节点向总线所发送的数据信号,SDA为总线上所呈现的数据信号,SCL是总线上所呈现的时钟信号。当主节点12同时发送起始信号时,两个主节点都发送了高电平信号。这时总线上呈现的信号为高电平,两个主节点都检测到总线上的信号与自己发送的信号相同,继续发送数据。第2个时钟周期,2个主节点都发送低电平信号,在总线上呈现的信号为低电平,仍继续发送数据。在第3个时钟周期,主节点1发送高电平信号,而主节点2发送低电平信号。根据总线的线“与”的逻辑功能,总线上的信号为低电平,这时主节点1检测到总线上的数据和自己所发送的数据不一样,就断开数据的输出级,转为从机接收状态。这样主节点2就赢得了总线,而且数据没有丢失,即总线的数据与主节点2所发送的数据一样,而主节点1在转为从节点后继续接收数据,同样也没有丢掉SDA线上的数据。因此在仲裁过程中数据没有丢失。

    这里大家注意一下,SCL同步与SDA仲裁是同时发生的,不存在先后问题。这些实现都是由于I2C设备的特殊设计实现的,堪称非常优雅。


    收藏 1 回复 0 浏览 447
  • RISC-V鸿蒙开发板单片机简介5-中断系统(项目连载12)

           大家好,我是张飞实战电子张角老师

    讲完了基本的外设接口,我们今天看一下这个开发板的中断系统。中断系统可以说是单片机能够响应任务需求的核心机制,对于一个固定的单片机而言,计算资源只有那么多,如何高效地利用这些计算资源,是单片机设计者必须回答的问题。目前主流的方案,就是我们今天要讲的中断系统。我们可以人为给不同的中断请求设定不同的级别,这样单片机系统就知道执行任务的轻重缓急了,系统的实时性提高了,计算资源就相当于被高效地利用了。

    那么,在嵌入式实时操作系统中,任务又是一个什么概念呢?我们知道,任务也是有优先级的,不同的任务优先级是不同的,那任务的优先级和中断的优先级是什么关系?我觉得,可以把任务理解成一种不靠硬件抢占执行的中断,应该归入大中断的概念中去,本质上是进一步提升了单片机响应任务的实时性。我们知道,一般中断程序的执行时间都不宜太长,只是负责变量打标,然后快速退出就可以了。一个高中断优先级的中断程序,执行得太长的话,那么所有相应的低优先级的中断程序是不是就完全无法执行了。所以程序开发的时候,才尽可能地让中断程序只是负责对变量打标而已。中断程序对相应的变量打标以后,实际工作的执行,还主要是任务的调度来实现的。这样的设计,相比而言能够让计算资源的实时性得到最大程序的发挥。这个也是嵌入式实时操作系统被设计出来的一个关键的原因。

    鸿蒙系统外部中断到底是怎么样被初始化的呢

    image.png

    上图中,第一个红框里面代码的意思就是开中断,这样单片机的各种硬件中断就可以进行响应了。这段代码执行以后,使用鸿蒙系统的中断创建函数创建外部中断。

    我们先不看外部中断内部是怎么实现的,先看一下这个中断创建函数是怎么工作的。在STM32中,中断函数能够被执行,是因为中断函数的地址或者叫做向量被放在中断向量表这个地方。当有外部中断请求发生的时候,单片机的硬件电路就会自动寻址到相应的中断函数,进而进入到函数里面去执行相应的代码。当然这中间肯定伴随着各种环境参数的保存,环境参数的保存,主要是为了解决从中断中恢复的时候,程序找到回去的路。

    鸿蒙系统的中断创建函数,所做的主要事情,如下图所示,就是把一个数组g_hwiForm里面的数值初始化了。


    image.png

    这个数组里面的每一个数值存放了相应中断函数的地址以及相关的参数值。我们可以暂且认为这个数组存放的就是中断向量表,那这些个函数是怎么执行起来的,或者说是如何被调用的呢?

    image.png

    我们看一下,我们这款单片机的中断执行机制。RISC-V单片机中断向量表的起始地址,是由CSR寄存器mtvt来指定的,具体说明,如下图。那也就是说当有外部中断触发的时候,单片机硬件查询的首先是这个寄存器的地址,然后执行这个寄存器中的地址所指向的代码。那么我们的鸿蒙系统中,有没有对这个寄存器进行初始化呢?

    image.png

    大家可以看一下,实际上在操作系统启动的汇编代码部分,这个向量是被初始化了的。如下图,操作系统把TrapVector函数地址放入了t0,然后把t0放入了mtvec这个寄存器中。那TrapVector到底是什么呢?

    image.png

    大家看一看,这个TrapVector函数里面,在经过一系列的寄存器操作以后,调用了OsHwiInterruptDoneTaskSwitch这两个函数。这也就是说每次中断请求发生以后,都会执行这两个函数。

    image.png

    这个OsHwiInterruptDone函数地址里面,是不是就调用了我们初始化的中断函数呀。它根据不同的中断号码执行不同的中断函数,如下图所示。

    image.png

    在本篇文章刚开始的时候,我们是不是提到的外部中断响应函数OsMachineExternalInterrupt,如果有外部中断发出请求的话,就会进入到这个函数中去。


    收藏 0 回复 0 浏览 415
  • STM32 C语言预处理深入解析

    大家好,我是张飞实战电子张角老师! 我们今天对C语言的预处理指令做一个总结其实C语言的预处理指令主要有三大类文件包含宏定义和条件编译我们首先要明白什么是预处理第二个C语言编译之前为什么需要预处理或者说这些预处理的添加能够给C语言的编程带来什么好处?再一个,我们就展开讲解一下C语言常用的三种预处理指令,看看他们如何使用以及有各自有什么优缺点。

    所谓预编译或者说预处理,就是在编译之前进行的一些操作,这其中比较主要的可能就是展开操作。那么C语言为什么要添加这些预编译指令呢?从设计者的角度上来看,这些指令肯定会对C语言的编程带来效率的提升,C语言才会保留这些关键字。因为整体上各个预处理指令之间的关联性比较小,我们这里没有办法给他们的优点和缺点做一个统一的定论,这里只能是展开来讲一个一个仔细讲解。

    第一个我们我来看一下,头文件指令#include”。那么在回答这个文件之前,我们要先讨论一下头文件存在的必要性,或者说为什么C语言要分为头文件和源文件。为什么C语言不能像C#Java那样,不需要头文件么?

    第一个我们从C语言发展的历史来看或者说从共享代码对存储空间的使用量上来看,C语言头文件的必要性。之所以C语言一定需要头文件,一个最本质的原因,是因为C编译器编译出来的二进制码(.o, .obj, .lib, .dll)不包括自我描述的符号信息,那么要复用这种可执行代码的话,需要另外的文件。C#Java的可执行代码可以自带元数据信息,但是这也意味着运行时的内存需求的增加,毕竟这种自我描述的数据对最终用户来说是无用的。当然在C#Java被发明的时候,存储空间以及很便宜了。但是C被发明的时候,64K的内存是400多美金。一个程序经常几十个模块,运行的程序为某给模块去浪费几十K是不可能的,价格太高了。那么头文件也就应运而生了,可执行代码的元数据信息是在头文件中保存的,头文件可以充当这些模块复用的桥梁,那么进而就可以降低可执行模块复用时对存储空间的需求。

    第二,我们从编程器的角度来看一下。C时代的时候编译器比较简单,是固定的编译和链接两个过程,编译一次只处理一个文件,进行预处理之后,头文件会插入到这一个文件里,不同源代码文件的处理时独立的,这样如果头文件里面定义了一个函数的实现,编译的时候所有引用这个头文件的源码文件,生成的obj里都会有这个符号。而链接是通用的链接程序,从汇编时代就用的工具,没有什么高级功能,同一个符号链接时出现两次是会报错的。但是,我们又说了,每个文件的编译是独立的,所以如果实现不在当前源文件里面,调用的时候编译器就不知道这个函数的类型和签名,没法生成调用代码,所以必须在调用之前先声明一遍。如果不把声明写在头文件里面,就必须在每个用到这个函数的源文件里都声明一遍,很不方便,所以综合之后的解决方案就是实现写源码文件里面,声明写头文件里面。

    最后,我们从知识产权保护的角度上来分析一下。接口和实现分立之后,使用者只需要关注接口就行了,按照接口的说明使用就好了,不用关注具体是如何实现的。那么作为开发者呢,则可以对实现部分进行加密,那样是不是就可以保护一定的商业秘密了。具体到头文件来说,我们在头文件中进行的函数声明,其实就有这个作用。

    也就是说基于以上各种原因,头文件对于C语言来说,是一个确定的存在。那么就肯定要有一个关键字来处理头文件,那这个关键字就是“#include”。在程序正式编译之前,预处理程序会首先展开头文件中的函数以及变量声明。然后程序在编译的时候,对于没有在本文件中实现的变量或者函数,就会做好标记。然后在链接阶段,就会根据类型、名称信息,去其他可执行文件中去找在该文件中没有实现的同时声明过的变量或者函数,如果找到了,而且不重复,那么程序的编译就能够通过。如果没找到,或者找到两个,那么编译就不会成功。

    具体到#include的用法,这里还有区别。#include <xx.h>,这种尖括号写法,预编译程序只会到系统指定的目录中去找这些头文件,看看他们是否存在。#include xx.h这种写法则不是,这种双引号类型的写法,预编译程序会首先到工程目录去找相应的头文件,如果找不到,预处理程序会再到系统指定的目录里面去找。那也就是说,如果是自己写的头文件,我们一定需要使用#include xx.h的写法;如果是系统头文件,两种写法都行,但是#include <xx.h>这种写法,效率应该会更高一些。

    讲完了文件包含,我们看一下宏定义的预处理指令#define。这个指令大家应该都比较常见。使用宏定义,可以带来不少优点,比如可以避免意义相对模糊的数字出现,#define PI 3.1415926,那么我们就可以在程序中直接使用PI来代替那么一长串的数字。再一个,这个PI会在预编译的时候,直接进行值替代,并不需要为这些常量提供存储空间,那么就进一步提高了程序的执行效率。但是这个也会有隐患的,编译器这里只是进行展开,并不进行类型检查,与此对比的就是const的类型的变量,使用这个变量就有可能避免潜在的错误。再一个,像下面这个,#define FILE_PATH E:folder11.txt如果目录较长的话使用起来是不是非常不方便呀我们就可以直接使用FILE_PATH这个字符串去去代替较长的目录,这样是不是就方便了许多。

    虽然#define,给我们带来了许多便利,但是使用它的时候,一定要小心再小心,否则编程的时候,就比较容易出错。

    #define PCHAR char*

    PCHAR p1,p2;

    char** a = &p1;

    char** b = &p2

    像这段代码,其实就有问题。这里p2其实是char类型,并不是char*类型。对于这种类型的错误,可以使用typedef的方式来解决。

    我们再举一个例子,#define MUL(A,B) A*B而在使用的时候,这样的调用:int a=1,b=2,c=3,d=0;d=MUL(a+b,c)经过编译时候展开,就变成了d=a+b*c而不是我们所希望的d=(a+b)*c其解决办法也很简单,就是给每个分量,都加上括号,就可以避免此类问题,在宏定义的时候,如此定义:#define MUL(A,B) ((A)*(B))不过有些时候加了括号也没有办法避免这些问题

    但是使用#define来定义函数,确实可以减少系统开销,提高运行效率。为什么会这样呢?因为在C语言中,发生函数调用的时候,需要保留调用函数的现场,子函数执行完毕以后还有回复函数调用的现场,这都需要一定的时间。如果子函数执行的任务比较多,这点时间是可以忽略的,但是如果子函数的功能比较少,比如只是一个加法的操作,那这部分转换操作的开销就太大了。使用带参数的宏定义就不会出现这样的问题,因为他是在预处理阶段就进行了宏展开,在执行的时候不需要进行转换,即在当地执行。宏定义可以完成简单的操作,但是复杂的操作还是要借助函数调用来实现。另外,宏定义的代码如果比较长,预编译的时候,所有引用的部分的代码都需要展开,那么目标代码的空间就会相对较大。所以,我们还是要根据实际情况来决定是否使用宏定义。

    实际上,针对宏定义函数的一些缺点,C++引入了inline函数这个关键字。inline函数,可以像宏定义的函数一样,直接展开,而不会发生函数调用的开销。但是呢,inline函数,本质上就是函数,会有参数以及返回执行类型的检查,使用的时候,相对要安全很多。

    讲完了宏定义,我们就到了条件编译的部分。所谓条件编译,就是满足条件的时候,就编译,不满足条件的时候,就不编译。条件编译一个很重要的作用,就是可以提高程序的可移植性,我们可以根据不同的平台选择编译不同的程序。条件编译一般有三种格式,这里总结如下。

     

    第一种:

    #ifdef 标示符 (注意:标示符一般用#define命令定义)
    程序段1
    #else
    程序段2
    #endif

        第二种:

    #ifndef 标示符 (注意:标示符一般用#define命令定义)
    程序段1
    #else
    程序段2
    #endif

        第三种:

    #if 表达式 (注意:标示符一般用#define命令定义,如果表达式为真则编译程序1
    程序段1
    #else
    程序段2
    #endif

    大家在实际使用的时候,参考这三种类型进行使用,就可以了。

    我们今天对预编译指令的总结,就先到这里。下一篇文章,我们就谈谈其他的预编译指令。


    参考资料:

    1. 为什么C/C++要分为头文件和源文件 https://www.zhihu.com/question/280665935

    2. C语言预编译的缺点 https://blog.csdn.net/weixin_39632379/article/details/117079935

    3. C语言预处理详解 https://blog.csdn.net/czc1997/article/details/81079498

    4. #define宏定义的优点和缺点 https://blog.csdn.net/u013910522/article/details/22672057

    5. C语言提供的三种预处理命令 https://blog.csdn.net/xiongzebao/article/details/45586743

     


    收藏 1 回复 0 浏览 323
×
张角