第3章嵌入式程序设计中的C语言 本章并不详细讲解C语言的语法规则,关于C语言程序设计有很多优秀的教材和参考资料。这里只针对嵌入式开发中入门者常见的一些问题进行必要的说明和讲解。读者必须有C语言程序设计的基础。 3.1整型 3.1.1整型的位宽 ANSI C中规定的基本数据类型共有6种,分别是short、int、long、char、float和double,其中,float和double为浮点型,short、int、long为整型,char为字符型。将一个字符赋值给char类型变量时,就是将字符的ASCII码,也就是将一个整型数值赋值给变量,所以char类型也是一种整型数据类型。 不同数据类型分配的字节数不同,char为1字节,short为2字节,long为4字节。int类型的长度与机器字长相同,16位系统中int占2字节,而32位系统中int占4字节。对于32位的单片机来说,int占4字节。如果不确定,则可以用“sizeof(int)”语句测试int数据类型的字节数。 整型变量进一步分为无符号整型和有符号整型。定义变量时在数据类型前面添加关键字unsigned说明定义无符号变量,signed关键字说明定义有符号变量。定义short、int和long类型变量时,在不加说明的情况下,默认定义的是有符号变量。而char类型比较复杂,与具体的编译器有关。例如,Windows环境下的Keil 5开发软件中提供了相关的配置项,如图3.1所示。若选中该配置项,那么默认情况下所定义的char变量为signed,即有符号整型变量; 如果没有选中该配置项,那么在不加说明的情况下所定义的char变量为unsigned整型变量。 图3.1Keil 5中char类型的配置项 定义变量时,数据类型的字节数以及signed/unsigned属性一起,决定了变量的数据范围。例如,unsigned char为无符号8位整型,它的数据范围为0~255,而signed char为有符号8位整型,它的数据范围为-128~+127。对于有符号整型变量来说,变量中存放的是数值的补码。赋值时如果数值超出了变量的数据范围,编译器会给出警告或错误提示信息。 例3.1: 赋值超过变量的数据范围时,编译会产生警告信息。 #include"stm32f10x.h" signed charvar; intmain(void) {var=128; while(1) {var++; }; } 上面的代码定义了全局变量var,赋值128时编译时给出了如图3.2所示的警告信息。 图3.2赋值超过数据范围导致的警告信息 数值128对应的8位二进制数为10000000,而var变量为signed char类型,保存的是数值的补码。即最高位为符号位,符号位为1表明是负数,为0是正数。按照补码规则,二进制编码10000000表示-128,而不是+128,所以编译器发出警告信息,提醒“导致符号改变”。单步调试程序时在Watch窗口中可以观察到var变量的值,如图3.3所示。 图3.3超界时变量中真实的数值 当赋值超过变量存储范围时,编译器会给出警告信息,编程人员应该仔细阅读警告提示信息,这样在软件开发阶段就能及时发现并改正问题。 如果经过算术运算,运算结果超出变量的数据范围,会发生什么情况呢?首先,在编译阶段不会有任何警告或错误提示。其次在程序运行阶段,也不一定会出错。只有当运算结果超出变量的数据范围时才会产生问题,此时变量中保存的结果是错误的,程序继续以错误的数据运行,极有可能导致严重后果,这时才会发现程序有问题。这种只有在运行时才有一定概率发生的错误,危害很大,想要调试定位这样的错误,难度是非常大的。因此定义变量时一定要注意变量的数据范围,根据变量的最大取值范围,定义合适的数据类型。 例3.2: 运算结果超出变量的数据范围,会得到错误的运算结果,如图3.4所示。 图3.4运算结果超出范围 例题中变量x、y、var都是signed char类型,取值范围为-128~+127,然而运算结果为200,超过了var变量可以保存的数据范围,因此var变量保存的运算结果是错误的。 为什么结果是-56呢?signed char类型为1字节的有符号整型,x、y赋值100,意味着x、y变量的8位二进制编码为01100100,两者相加,结果为11001000,所以var变量中保存的二进制编码为11001000,按照补码规则,对应的数值就是-56。 只要将var变量定义为signed short类型,就能解决这个问题。 定义变量时一定要明确说明unsigned或signed属性,不要依赖于默认设置,这样可以增加代码的可移植性和可读性。应注意变量的数据范围,根据可能存储的最大数值来决定变量的数据类型,避免程序执行时发生运算结果超界的情况。 整型数据类型以及对应的数据范围见表3.1。 表3.1数据类型及其数据范围 数 据 类 型 字节数 数 据 范 围 说明 unsigned char 1 0~255,即0~28-1 signed char 1 -128~+127,即-27~+(27-1) 续表 数 据 类 型 字节数 数 据 范 围 说明 unsigned short 2 0~65535,即0~216-1 signed short 2 -32768~+32767,即-215~+(215-1) unsigned int 4 0~4294967295,即0~232-1 32位系统 signed int 4 -2147483648~+2147483647,即-231~+(231-1) 32位系统 unsigned long 4 0~4294967295,即0~232-1 signed long 4 -2147483648~+2147483647,即-231~+(231-1) 例3.3: signed类型变量保存的是数值的补码,如图3.5所示。 图3.5例3.3程序截图 单步调试程序时,在Watch窗口中可以修改变量的进制,右击,在弹出的快捷菜单中选择Hexadecimal Display命令,即“十六进制显示”,就能看到变量实际存储的十六进制数值。取消这个设置,看到的就是对应的十进制数值。 C语言用0x前缀说明十六进制。每4位二进制对应转换1位十六进制,二进制与十六进制之间的换算非常方便,而二进制数据位数过多,查看和录入时容易出错,所以在调试程序时通常使用十六进制,而不直接用二进制。 例题中,x变量赋值为-100,而在Watch窗口中观察到,所存储的十六进制数值为0x9C,即二进制编码为10011100,这就是-100的补码。 例3.4: 运算结果超出变量的数据范围时,进位丢失了,如图3.6所示。 图3.6例3.4程序截图 程序执行后,在Watch窗口中观察到变量x的数值为44。 x是无符号8位整型,加法运算后,运算结果大于255,运算向前产生了进位,但变量x是8位的整型变量,只能保存8位的运算结果,因此加法指令执行后,x变量保存的值为44。 例3.5: 两个正数相加,运算结果却变成负数,如图3.7所示。 图3.7例3.5程序截图 signed char类型的x变量初始化为数值100,即变量x的二进制数值为01100100。加80,数值80对应的8位二进制数值为01010000。两个8位二进制数相加,结果存回变量x。相加后,变量x的二进制数值变为10110100。作为有符号数变量,它的最高位为符号位,符号位为1,说明是负数,按照补码的运算法则,可以得到变量x的十进制数值为-76。 3.1.2访问硬件模块的寄存器 单片机芯片中集成有多个硬件模块,每个片上硬件模块都提供有多个寄存器,通过对这些硬件模块内的寄存器进行读写操作,就可以设定硬件模块的工作方式,控制它们实现指定的功能,因此在嵌入式系统开发中,必须要访问片上硬件内部的寄存器。 嵌入式系统开发中会定义整型变量来访问硬件模块内部的寄存器,整型变量必须声明为unsigned,并且变量的位宽必须与寄存器的位宽一致。例如,STM32F103单片机中GPIO模块的配置寄存器CRL,这个寄存器是32位的,所以必须定义32位的无符号整型变量,才能正确地访问这个寄存器。 图3.8重命名的整型数据类型 由于定义整型变量访问寄存器时整型变量的位宽必须与寄存器的位宽一致,为此,在Keil 5软件提供的系统头文件stdint.h中,用typedef关键字重新命名了整型数据类型,如图3.8所示。重新命名后的数据类型可以直接看出数据类型的位宽,以及是否为有符号数。 库函数中定义整型变量时不再直接使用ANSI C中的关键字来定义整型,而是用重命名后的uint8_t、uint16_t、uint32_t和uint64_t来定义不同位宽的无符号整型变量,用int8_t、int16_t、int32_t和int64_t定义有符号整型变量。 作为嵌入式系统的开发人员,定义整型变量时也应该采用重新命名后的数据类型,与库函数保持一致,又能直接看出整型的位宽。由于必须在当前C语言源文件中包含typedef相关语句后,才能使用重命名后的数据类型,因此C语言源程序中必须先用#include语句包含stdint.h头文件后,才能使用重命名后的类型来定义整型变量。 例3.6: 定义int8_t类型的变量,如图3.9所示。 图3.9例3.6截图 由于没有包含stdint.h头文件,编译器给出了错误信息,提示“int8_t标识符未定义”。必须先用typedef语句完成对整型数据类型的重命名后,才能使用重命名后的数据类型。上述程序只需要在开始处添加“#include "stdint.h"”语句即可。 3.2volatile关键字 3.2.1C语言编译器的优化功能 C语言的编译器会对代码进行优化,删除冗余代码。如果编译器认为对某些变量的读写操作是无用的,那么编译时就会删除相关的读写语句。如果编译器认为某些变量在程序中没有使用,那么编译时甚至可能不定义这些变量。 例3.7: 编译器对代码的优化作用,如图3.10所示。 图3.10例3.7截图 编译main.c源文件时,编译器给出了警告信息“变量x设置了值,但从未使用过”。虽然有警告信息,但程序编译连接成功,可以执行。但是单步调试程序时发现在Watch窗口中无法观察到变量x、y、z的值,如图3.11所示。 图3.11编译器优化的结果 这个现象就是编译器对程序进行优化而导致的。由于变量x赋值后从未使用,而变量y和z只用于给变量x赋值,所以编译器优化后,x、y和z变量都不存在了,而读写这些变量的代码,作为冗余代码,也被编译器删除了。虽然看上去执行了x=y算赋值指令,但是打开Disassembly窗口,观察实际执行的机器码指令,就会发现定义变量,以及对x变量的3条赋值语句,实际上都是NOP,也就是空操作指令,如图3.12所示。 图3.12例3.7程序的机器码截图 CPU执行的是机器码指令,通过反汇编窗口可以查看CPU真正执行的指令。所谓的“反汇编”,就是将机器码指令“反过来”翻译成汇编指令。 图3.12中箭头指向的是待执行的机器码指令,该机器码指令是C语言指令“x=y; ”的编译结果。0x080003B0是机器码指令存放的地址,BF00是十六进制的机器码,该条机器码指令占2字节,而NOP则是机器码指令反汇编后的汇编指令。 从图3.12显示的反汇编代码可以看出,无论是定义变量,还是赋值语句,对应的汇编指令都是NOP空指令,这就是编译器对代码进行优化的结果,编译器认为对变量的读写操作是无用的,将冗余代码全部都删除了。只有最后一条“while(1);”指令是有效的指令,编译成汇编指令 B 0x080003B8。这是一条跳转指令,跳转到地址0x080003B8,即跳转到自身,实现了“死循环”。 3.2.2用volatile关键字避免优化 定义变量访问硬件模块内的寄存器时,需要严格执行每次对变量的读操作或写操作,而编译器的优化功能可能导致对寄存器的读写操作失败。如果对变量dr的赋值操作意味着通过串口发送相应的数据,那么对变量dr的每一次赋值,都意味着一次数据传输,编译器不应该对变量dr的赋值语句进行优化。 定义变量时添加volatile关键字,说明该变量的值会被某些编译器未知的因素改变,如操作系统、硬件模块等,编译器对访问该变量的代码就不再进行优化了。 例3.8: volatile关键字的作用。 如图3.13所示,例3.7中的程序只需要在定义变量时添加volatile关键字加以声明,编译时就不再有警告信息,而且反汇编程序也不再是NOP,变量定义以及变量的赋值都真实执行了。 图3.13例3.8截图 用volatile关键字定义的变量,编译时不再对该变量的访问进行优化,会严格按照指令执行每一次对该变量的读写操作。因此定义变量访问硬件模块的寄存器时,一定要用volatile关键字加以修饰。Keil 5软件在core_cm3.h头文件中定义了相关的宏,定义变量访问片上硬件模块的寄存器时都使用了volatile关键字,如图3.14所示。 图3.14Keil 5中访问片上模块寄存器的相关定义 CMSIS固件库头文件stm32f10x.h中为所有片上硬件模块定义了结构体数据类型,每一个结构体成员对应硬件模块中的一个寄存器。例如,GPIO模块内有CRL、CRH等多个32位的寄存器,为访问这些寄存器,在GPIO_TypeDef结构体中为每个寄存器定义了一个uint32_t类型的变量,从图3.14(a)可知,__IO实质就是volatile关键字。从图3.14可以看出,系统定义变量访问片上硬件模块寄存器时全部添加了volatile关键字。 除了定义变量访问片上硬件模块寄存器时需要用volatile关键字以外,有时定义普通变量时也会添加volatile关键字,以避免编译器进行优化。例如,软件延时函数通过一个循环程序消耗CPU的执行时间,达到延时的目的。此时就希望严格按照指令顺序,执行一个没有实际作用的程序段。在软件延时函数中定义的变量,也会用volatile关键字加以说明,以达到软件延时的目的。软件延时程序如图3.15所示。 图3.15软件延时程序 图3.15中的delay()函数中只有一条for循环语句,这个函数不实现任何具体的功能,仅仅是通过执行循环语句,消耗CPU的执行时间,达到延时的目的。 3.3结构体数据类型 3.3.1struct关键字 通过关键字struct,可以将多个不同类型的变量组合起来,作为一个整体,这就是结构体数据类型。例如“学生”这个对象至少应该有“学号”“姓名”“性别”等属性,其中“学号”可以是32位整型,“姓名”应该是字符型数组,而“性别”可以用单个字符说明,定义为字符型即可。 例3.9: 定义结构体数据类型Stu。 程序见图3.16。代码首先包含了两个头文件stdint.h和string.h,其中string.h是C语言标准库提供的头文件,其中包含常用的字符串处理函数的函数声明,代码中调用的字符串复制函数strcpy()就是其中之一。 图3.16例3.9截图 Keil 5支持一些C语言标准库函数,在Keil 5安装文件夹的“..\ARM\ARMCC\include”中,可以看到Keil 5提供的标准库函数的头文件,包括stdint.h、string.h、stdio.h等。 main()函数中定义了3个Stu结构体类型的变量Zhang、Wang和Li,然后对变量的数据成员进行了赋值,name数据成员是char类型的数组,调用strcpy()函数,完成字符串复制。 在Debug调试环境下可以看到,Zhang的类型是struct Stu,作为一个复合型的变量,Value栏目下显示的十六进制数值0x20000628实际上是结构体变量Zhang的地址。单击复合型变量左边的“+”号,可以观察其中的数据成员。 name的十六进制数值0x2000062C是数组首地址,“Zhang San”是具体的字符串,单击左边的“+”号,打开数组,可以看到每个数组成员的具体情况。 3.3.2访问单片机片上外设寄存器 Keil 5为所支持的单片机提供了相关的头文件,在头文件中为访问单片机片上外设的寄存器定义了相关的结构体数据类型。例如,为STM32F10X系列单片机提供了头文件stm32f10x.h,该头文件中为每一种片上外设定义了一个结构体数据类型,每个数据成员对应片上外设中的一个寄存器。 单片机的参考手册中详细说明了片上外设的工作情况,从中截取了GPIO模块寄存器的缩略说明,如图3.17所示。 图3.17GPIO模块寄存器列表 图3.17显示了GPIO模块寄存器的相关信息,“偏移”指寄存器相对于模块首地址的偏移地址,汇编语言中用字符h说明为十六进制。后面给出了每个寄存器d0~d31位的说明。从图3.17中可以看出,IDR寄存器只有低16位有效,而高16位都是保留位,目前没有任何作用。 stm32f10x.h头文件中为访问GPIO寄存器定义了结构体数据类型GPIO_TypeDef,如图3.18所示。 图3.18GPIO结构 体定义 定义CRL变量用于访问CRL寄存器,以此类推。结构体中所定义的每一个数据成员对应访问片上外设的一个寄存器,寄存器的位宽就决定了结构体成员的数据类型。 ANSI C按变量定义的先后顺序为变量分配存储空间,这意味着结构体中定义数据成员的先后顺序就决定了数据成员之间的相对偏移地址,所以为访问片上外设寄存器而定义的结构体,其数据成员的类型以及定义的先后顺序都必须与参考手册中的规定一致。 单片机芯片中集成有多个GPIO模块,每个模块的起始地址不同。STM32F103单片机中最多集成7个GPIO模块,起始地址见表3.2。 表3.2片上GPIO模块地址表 片上GPIO 起 始 地 址 GPIO端口A 0x4001 0800 GPIO端口B 0x4001 0C00 GPIO端口C 0x4001 1000 GPIO端口D 0x4001 1400 GPIO端口E 0x4001 1800 GPIO端口F 0x4001 2000 GPIO端口G 0x4001 2400 stm32f10x.h中定义了GPIO_Typedef结构体数据类型后,用宏定义将指定地址强制转换为GPIO_Typedef结构体指针,如图3.19所示,通过结构体指针就能够访问片上GPIO的寄存器了。 图3.19片上GPIO模块的结构体指针定义 stm32f10x.h头文件为所有的片上外设都定义了结构体数据类型,声明了基地址,并通过宏定义,将片上外设基地址强制转换为相应的结构体指针,所以C语言源程序中只需要包含stm32f10x.h头文件,就能直接通过结构体指针读写片上外设的寄存器了(见图3.20)。Keil 5开发环境中会自动提示结构体的数据成员,大大方便了开发人员的工作。 图3.20读写片上外设寄存器 3.4枚举数据类型 生活中常常会遇到只有有限个选项的情况,例如,一天是星期几的问题,只可能是星期一到星期日中的一个,而性别只有两个选项: 男或女。 当定义变量时,若希望限定对变量的有效赋值只能是指定选项中的一个,此时就需要定义枚举数据类型。 对于C语言来说,枚举数据类型与结构体数据类型一样,都是“构造类型”。枚举类型将变量的取值用一组常数逐一列出来。枚举类型中的每一个取值只能是整数,默认情况下取值从0开始。 例3.9中定义的结构体Stu中,结构体成员gender说明学生性别,定义为char类型。性别只有男或女,其有效值只有'M'(男)或'F'(女)两种取值。但是作为char类型,其取值范围就远远不止这两种了。 如果将gender改为枚举数据类型,并指定其取值为'M'或'F',那么代码中对gender变量进行赋值时,就需要用枚举数据类型中定义的选项,否则编译时会产生警告信息。 例3.10: 性别只能是男或女。 如图3.21所示,代码中定义了枚举数据类型Gender,并且将结构体Stu中的数据成员gender定义为枚举类型。对gender进行赋值时,就只有Male或Female两种情况。若使用其他赋值,则编译器会给出警告信息。 图3.21例3.10截图 CMSIS固件库提供的头文件中定义了很多枚举数据类型,例如,标志位只有置位或复位两种状态,而片上外设可以使能或禁止,为此stm32f10x.h头文件中分别定义了FlagStatus和FunctionalState枚举类型,CMSIS库函数中使用这些枚举类型作为参数,而不是直接用整型,相关代码片段如图3.22所示。 图3.22stm32f10x.h头文件中定义的枚举数据类型 调用库函数时需要注意参数的类型,枚举类型的参数应该用规定的选项作为实参,而不应该直接用整数数值,以避免编译时产生大量的警告信息。此外,由于枚举类型定义中选项的名称非常直观,作为参数传递时,可以增加程序的可读性。 3.5static关键字 static语义为“静态”。定义变量时添加static关键字,就定义了“静态”变量; 定义函数时添加它,就定义了“静态”函数。static关键字的作用各有不同,是非常有用的一个关键字。 3.5.1静态全局变量 1. 全局变量 全局变量的作用域是整个项目。在一个C语言源文件中定义了全局变量,定义之后就可以访问这个变量了。其他C语言源文件只需要用extern关键字声明一下,就可以访问这个全局变量了。 全局变量为静态分配,编译时就为全局变量分配了存储空间,全局变量的生命周期是整个程序运行期间。只要程序还在运行,全局变量就一直占据着存储空间,直到程序结束运行,才释放存储空间。 由于全局变量的作用域为整个项目,因此不能在多个C语言源文件中定义同名的全局变量,否则编译时会提示“重复定义”的错误。 2. 静态全局变量 定义全局变量时前面添加static关键字,就定义了静态全局变量。这里static关键字限制了全局变量的作用域,将其作用范围局限在当前的C语言源文件中。也就是说,静态全局变量只能在定义它的C语言源文件中访问,其他C语言源文件不能访问它。静态全局变量的生命周期与全局变量一样,都是在整个程序运行期间有效。 由于静态全局变量作用域局限在定义它的文件中,所以其他C语言源文件中可以定义同名的静态全局变量或全局变量,但是这是不同的变量。 虽然语法上允许在不同C语言源文件中定义同名的静态全局变量,但是这很容易引起逻辑错误,编程过程中很容易弄不清楚当前访问的是哪个静态全局变量。编程中应该避免定义同名的变量,不管是局部变量、全局变量,还是静态全局变量。 例3.11: 全局变量的定义与访问。 如图3.23所示,例题项目中有两个源文件: main.c和myMath.c。 图3.23例3.11程序截图 全局变量gfPI,在myMath.c中定义,在main.c中作了extern声明,并且在main()函数中又定义了同名的局部变量。那么main()函数的while(1)循环中计算面积area时访问的gfPI变量是全局变量,还是局部变量呢? 当同名的局部变量和全局变量都有效时,使用局部变量。所以while(1)循环中计算area时访问的是局部变量gfPI,这导致计算结果不够精确,然而无论怎么修改全局变量gfPI都无法解决问题。 在main.c和myMath.c中都定义了静态全局变量gcVal,初始化数值分别为20和100。那么main()函数的while(1)循环中调用Inc_gcVal()函数时访问的是哪个gcVal变量呢? 由于Inc_gcVal()函数是在myMath.c中定义的,当调用该函数时,程序会跳转到myMath.c中的函数体执行,因此这里访问的是在myMath.c中定义的静态局部变量gcVal,而不是在main.c中定义的gcVal。 虽然由于作用域不同,使得语法上允许定义同名的静态全局变量,或与全局变量同名的局部变量,但是这只会造成编程人员的混乱,程序中如果隐藏着这样的问题(bug),是非常难以调试和定位的,因此不要在程序中定义同名的静态全局变量。 3.5.2静态局部变量 在函数内部或程序块内定义的变量为局部变量,其作用域局限在函数或程序块内。在程序运行期间,调用函数或执行到程序块时才会为局部变量分配存储空间,创建变量,而函数结束或离开程序块时就会释放存储空间,销毁局部变量。局部变量的生命周期以及作用域都是有限的。 定义局部变量时添加static关键字,就定义了静态局部变量。这里static关键字改变了局部变量的生命周期,静态局部变量创建后,不再被销毁,直到整个程序结束运行,退出时才会释放存储空间。 静态局部变量的作用域依然有限,但是它的生命周期延长了——从创建开始,持续到程序运行结束。 例3.12: 记录函数被调用的次数。 修改例3.11的Inc_gcVal()函数,定义静态局部变量记录下函数被调用的次数。程序如图3.24所示。 图3.24例3.12程序截图 只在第一次调用函数时创建并初始化静态局部变量,此后静态局部变量就一直有效,因此main()函数每调用一次Inc_gcVal()函数,u16Times变量的数值就加1,从而记录了函数被调用的次数。 3.5.3静态函数 函数的作用域为整个项目,在一个C语言源文件中定义了函数,其他C语言源文件中只需要完成函数声明,就可以调用这个函数了。因此,整个项目中不允许定义同名的函数,否则连接时就会产生“重复定义”的错误。 定义函数时添加static关键字,就定义了静态函数,此时函数的作用域局限在当前文件中,其他C语言源文件不能调用。 由于静态函数的作用域局限在当前文件中,因此其他C语言源文件中可以定义同名的函数,但是通常在程序设计中应该尽量避免定义同名的函数,一不小心就容易在程序中埋下隐患。 当程序员定义了一个静态函数时,其实就等同于在宣布“该函数仅供本文件中其他函数调用,其他文件别调用它!”。这样可以为代码提供一定的“保护”,若其他文件试图调用它,编译时就会报错。还增强了代码的可读性,后续其他人员接手这个程序,继续开发时,看到了静态函数,就明白不应该在其他文件中调用它。 STM32F10x系列单片机的库函数文件system_stm32f10x.c中定义了静态函数SetSysClock(),该函数完成了系统总线时钟初始化,只在SystemInit()函数中调用它。其他C源文件应该调用SystemInit()函数,而不是SetSysClock(),因此程序中将SetSysClock()函数定义为静态函数。 总的来说,只有当确定该函数只在当前文件中被调用,不期望其他源文件调用它时,才将函数定义为静态函数。 3.6宏定义 #define宏定义指令实质上是为常数或表达式取了一个别名。宏定义没有数据类型,也不会分配存储单元,编译时直接将代码中的宏用宏定义中的常数或表达式进行替换。 例3.13: 计算圆面积的宏。 如图3.25所示,main.c源文件中首先定义了两个宏,圆周率PI为一个常数,而计算圆面积的带参宏area(x)是一个表达式。在main()函数中,两次调用了area宏。调试程序时,在Watch窗口中观察变量x和y的数值,可以发现第二次调用时,也就是area(4+3)处,结果出错。 图3.25例3.13截图 main()函数中调用宏area(x),编译时直接进行替换。程序中的area(4+3),4+3作为x进行替换,替换后的表达式是3.14159×4+3×4+3。这是一个常数运算式,编译时会直接计算出结果,也就是27.56636,所以编译后,这条赋值语句实际上就是“y=27.56636;”。 由于宏定义的这种特性,定义宏时一定要注意加上括号,设定表达式中各个运算的优先级。上面的代码只需要在定义area(x)宏时注意加上括号,就能正常计算圆面积了。 #definearea(x)(PI*(x)*(x)) 如果留意一下CMSIS固件库提供的头文件中的宏定义,就会发现头文件中定义宏时都添加了括号,以避免调用宏时产生错误。 结构体指针GPIOA宏定义的相关代码如下。 #define PERIPH_BASE((uint32_t)0x40000000) #define APB2PERIPH_BASE(PERIPH_BASE + 0x10000) #define GPIOA_BASE(APB2PERIPH_BASE + 0x0800) #define GPIOA((GPIO_TypeDef *) GPIOA_BASE) 片上外设GPIOA的起始地址为0x40010800,GPIOA外设在APB2总线上,而APB2总线上有多个片上外设。在stm32f10x.h头文件中,首先从外设基地址PERIPH_BASE开始定义宏,每个宏定义中都添加了括号,确保多个宏互相调用时不会由于运算优先级而导致出错。 用#define定义的宏,用#undef可以撤销,撤销后,就不能再调用这个宏了。 宏定义只在当前的C语言源文件中有效,一个项目中若包含多个C语言源文件,那么每个C源文件中都需要用#define语句实现宏定义。但是这样编写代码,一旦宏定义的表达式写错了,解决问题时会非常麻烦,需要逐一修改所有C源文件中相关的宏定义。因此,在项目开发中,通常在头文件中实现宏定义,C源文件中只需要包含头文件,就能够调用头文件中定义的宏了。 3.7条件编译与头文件 3.7.1条件编译指令 Keil 5提供了很多头文件,对于开发人员来说,只需要包含头文件,就能调用系统提供的各种接口函数,非常方便。 随意打开几个系统提供的头文件,对比一下就会发现,前几行代码很相似,都是下面这样的格式,其中“XXX”直接用文件名来替换即可。 #ifndef__XXX_H #define__XXX_H … #endif 代码中的#ifndef和#endif就是条件编译指令。条件编译指令属于预处理指令,这些指令不可执行,没有对应的机器码,而是在编译时起作用。编译器根据条件满足与否,决定是否编译相关的代码段。ANSI C中的条件编译指令见表3.3。 表3.3条件编译指令 指令 功能 #if表达式 #else #endif #if后面有一个常量表达式。编译时若表达式的值为true,则编译#if分支的代码,跳过#else分支的代码。如果表达式的值为false,则编译时跳过#if分支的代码,改为编译#else与#endif之间的代码 #if命令的功能有些类似于C语言中的ifelseend if指令,可以根据实际情况决定,是否要包含#else分支 #if 表达式1 #elif表达式2 #else #endif 编译时若表达式1的值为true,则编译#if分支的代码,否则需要判断表达式2,若表达式2的值为true,则编译#elif分支的代码; 若表达式1和表达式2的值都为false,则编译#else分支的代码 与if指令相似,#elif可以多重嵌套,形成多种不同的编译情况 #ifdef 符号 #endif 编译时,如果定义了符号,则编译#ifdef与#endif之间的代码,否则就跳过这段代码,不编译 #ifndef符号 #endif 如果没有定义符号,才编译#ifndef与#endif之间的代码; 如果符号已经定义了,则跳过这段代码,不编译 stm32f10x.h头文件中的条件编译指令如下。 #ifndef __STM32F10x_H #define __STM32F10x_H … #endif 代码首先判断是否定义了符号“__STM32F10X_H”,如果没有定义才编译后面的代码段。如果已经定义了这个符号,编译时就跳过后续的代码段,也就是整个stm32f10x.h头文件的所有代码。 如果C语言源程序中用#include指令包含了两次stm32f10x.h头文件,那么编译时,第一次包含头文件时,#ifndef指令条件满足,此时符号“__STM32F10X_H”未定义,编译后续的代码,立刻就用#define语句定义了这个符号; 而第二次包含头文件时,#ifndef指令的条件不满足,后面的代码不会再次被编译。 头文件一定会用条件编译语句将文件中真正的内容包括起来,只有这样才能避免重复包含头文件时产生重复定义或重复声明错误。 程序设计中,在头文件中包含头文件是非常常见的现象,这就可能导致多次包含某个头文件的情况。例如,调用片上外设库函数完成项目开发时,需要用到RCC模块和GPIO模块,而对应库函数的头文件stm32f10x_rcc.h和stm32f10x_gpio.h中都包含了stm32f10x.h头文件,而main.c源文件中需要包含RCC模块和GPIO模块的头文件,导致stm32f10x.h头文件被包含了多次。 用#include指令包含头文件,实质就是将头文件的所有内容直接插入#include指令所在位置。多次包含意味着某个头文件的内容会被插入多次,这就是编写头文件时,必须用条件编译指令将头文件真正的内容囊括在内的原因。 #ifndef和#endif是一对条件编译指令,#ifndef __xxx_H 判断是否定义了符号__xxx_H,如果没有定义,那么会编译其中的内容; 如果已经定义了这个符号,那么编译器会忽略#ifndef和#endif之间的代码。 3.7.2头文件 对于在xxx.c源文件中编写的函数来说,其他源文件要调用这个函数,就必须先完成函数声明。为了方便其他源文件调用,通常会为xxx.c源文件编写一个对应的头文件xxx.h,将函数声明、全局变量的extern声明、宏定义、结构体定义和枚举类型定义等都放在头文件中。这样其他源文件只需要包含这个头文件,就可以调用其中声明的函数、宏等。通常习惯上会将C源文件与对应头文件命名为相同的文件名,当然这不是硬性规定。 一般情况下,头文件中只会做函数声明,而不会定义函数,这是因为项目中可能会有多个源文件包含这个头文件,这就意味着在多个源文件中定义了函数,编译单个源文件时不会有问题,但是编译连接整个项目时就会出现函数重复定义的错误。 由于函数和全局变量的作用范围是全局的,在整个项目中都有效,因此通常情况下,头文件中不会有函数定义和全局变量定义。 如果要在头文件中定义函数或全局变量,那么只能在一个源文件中包含这个头文件,或者定义函数或全局变量时用static关键字,将函数和全局变量的作用范围限制在当前文件中,但这时在多个源文件中包含这个头文件,就会多次定义这些函数和全局变量,每个源文件中访问的是在自己文件中定义的全局变量。项目开发中后者容易导致错误,并且很难调试定位这样的错误。 例3.14: 系统头文件core_cm3.h中的函数定义。 在CMSIS固件库中,绝大多数头文件都只进行了函数声明,但是内核相关头文件core_cm3.h以及core_cmFunc.h有所不同,定义了操作单片机内核的接口函数。函数定义的前面全部添加了“__STATIC_INLINE”,这是在core_cm3.h中定义的宏,相关宏定义见图3.26。 图3.26core_cm3.h中的宏定义 core_cm3.h中通过条件编译指令,针对Keil 5所用的C编译器,完成了相应的宏定义。默认情况下,Keil 5使用ARM的C编译器,也就是图3.26中矩形框部分。 core_cm3.h和core_cmFunc.h中都定义了接口函数,所有函数定义都添加了“__STATIC_INLINE”,也就是说,在头文件中定义的所有函数都是静态函数,如图3.27所示。 图3.27core_cm3.h中的函数定义 编写头文件时,一定要用条件编译指令,以避免多次包含头文件导致重复定义错误。 总的来说,如果没有特殊要求,不要在头文件中定义函数或全局变量。 通常头文件中只包含有typedef定义、宏定义等这些作用范围只局限在当前文件的定义,以及函数声明和全局变量的extern说明。 3.8变量在哪里 3.8.1堆、栈和静态区 C语言中将内存分为3部分: 堆(heap)、栈(stack)和静态区(static area),栈也常被称为“堆栈”。 堆栈区为按照“先进后出”原则进行操作的连续存储空间。将数据存入堆栈称为“压栈”操作,而将数据弹出堆栈称为“弹栈”操作。对于堆栈的操作,始终都在堆栈栈顶进行,堆栈栈顶位置随着压弹栈操作而上下浮动。可以将堆栈区想象为一个弹夹,从一个口将子弹压进或弹出,所以最后压入弹夹的子弹,最先被弹出。 堆栈区用于保存局部变量,进入函数时在堆栈区为函数内的局部变量分配存储空间,函数执行结束时弹栈恢复堆栈空间。堆栈的执行效率高,但是堆栈区的大小是有限的。 堆区用于动态分配内存,即调用malloc()或new()函数分配的内存。对于动态分配的内存,必须调用free()函数或delete()函数来释放,在主动释放之前内存始终被占用。动态分配内存比较灵活,但如果忘记释放内存,则会造成内存“泄漏”。 静态区用于保存全局变量和静态变量,包括静态局部变量和静态全局变量。在静态区分配存储空间的变量,其生命周期为整个程序运行期间。 静态区的存储空间分配由编译器在编译时分配,而堆区和栈区的存储空间分配是在程序运行过程中进行的。栈区空间是有限的,如果分配的存储空间超过栈区剩余空间大小,则会导致堆栈“溢出”,程序崩溃。 3.8.2单片机中变量的存储空间分配 单片机芯片内集成有Flash存储器和SRAM存储器,两者的容量都有限。STM32F10x系列单片机采用哈佛架构,存储器以及片上外设都在一个4GB的地址空间中。其中Flash地址从0x0800000开始,SRAM地址从0x20000000开始,片上外设地址从0x40000000开始。 堆区、栈区以及静态区都在SRAM存储器中。在系统提供的启动文件中可以设定堆区、栈区的大小。 例3.15: 大容量设备启动文件startup_stm32f10x_hd.s中定义堆区和栈区。 启动文件为汇编语言源程序。根据单片机芯片中集成的存储器容量大小,芯片分为小容量、中容量和大容量,启动文件也有所不同。stm32f103vet6芯片内部集成有512KB的Flash和64KB的SRAM,属于大容量芯片,启动文件为startup_stm32f10x_hd.s。 从图3.28可以看出,启动文件中规定了栈区大小为0x00000400,而堆区大小为0x00000200,两者都在SRAM中。根据项目需求,可以自行调整栈区大小。 图3.28例3.15截图 例3.16: 调试环境下确定栈区在SRAM存储器中的位置。 STM32F10x系列单片机SRAM存储区地址从0x20000000开始,根据芯片具体型号可以确定片上集成的SRAM存储器大小。 启动文件中定义了栈区和堆区的大小,若不修改启动文件,则默认情况下栈区大小为0x00000400,而堆区大小为0x00000200。启动后,CPU内的SP寄存器指向堆栈栈顶,压栈时SP指针减小,而弹栈时SP指针增大,随着压栈弹栈操作,SP始终指向堆栈栈顶。只要观察SP寄存器,就能了解当前堆栈的使用情况,如图3.29所示。 主程序中没有定义任何全局变量或局部变量,单步调试程序,进入main()函数时,观察CPU内部寄存器的情况,可以看到R13(SP)寄存器的值为0x20000660,目前堆栈区中没有压入任何数据,SP指向堆栈区的尾部,而堆栈区大小为0x00000400,也就是说,地址0x20000260~0x2000065F这0x00000400个字节单元为堆栈区。 当修改启动文件,将堆栈区大小改为0x00001000时,编译连接后,再次单步调试,可以观察到SP寄存器的情况如图3.30所示。 将堆栈区大小改为0x00001000后,从图3.30可以看出,堆栈尾部地址变为0x20001260,此时堆栈区地址为0x20000260~0x200125F。 图3.29堆栈区示例1 图3.30堆栈区示例2 (1) 局部变量的存储空间分配。 函数内部定义的局部变量,其生存周期和作用域都局限在函数内。调用函数时,在堆栈中为函数内的局部变量分配存储空间。当函数执行完毕时才会弹栈释放堆栈空间。 函数中可以调用其他函数或者调用自身。函数调用自身,也就是所谓的“递归”函数。理论上函数调用嵌套的层数是没有限制的,但实际上每一次调用函数,都会消耗堆栈空间。然而堆栈空间是有限的,如果堆栈溢出,那么整个程序就崩溃了。这是非常严重的错误,所以规划堆栈区大小时一定要考虑函数调用嵌套的情况,而设计递归函数时更要注意最大的嵌套次数,避免发生堆栈溢出的错误。 (2) 静态局部变量的存储空间分配。 静态局部变量指在函数内部定义局部变量时用static关键字加以说明的变量。静态局部变量的作用域依然局限在函数体内,但是其生命周期会发生改变,函数结束时不会销毁静态局部变量,它在整个程序运行期间都有效。对于单片机程序来说,只要上电程序始终处于运行状态,就不会退出运行。 静态局部变量的存储空间是在编译时就在SRAM的空闲区域为其分配了存储空间,不会释放。但是作为局部变量,对它的访问依然局限在函数内部。 (3) 全局变量的存储空间分配。 全局变量作用域是整个项目,生命周期则是整个程序运行期间。静态全局变量仅仅是改变了作用域,其生命周期是不变的。 整个程序运行期间都有效的变量,都是在编译时分配存储空间,并且在程序运行期间存储空间都不释放,不能另做它用。 上述类型的变量都是在SRAM存储器中分配,而不同型号单片机内集成的SRAM存储容量差异极大,例如,STM32F103T4内部只有6KB的SRAM,而STM32F103VE内部有64KB的SRAM。所以在嵌入式软件开发中,需要关注程序对存储空间的使用。 (4) 只读变量的存储空间分配。 定义变量时可以添加const关键字加以修饰,将变量定义为只读变量。只读变量与宏定义不同,只读变量有数据类型,并且会分配存储空间。 定义为只读变量,只是限定了对变量的写操作,只能在定义时初始化变量,在代码中只能读变量,而不能改写变量的数值。const关键字仅仅限定了对变量的写操作,并没有改变变量的生命周期和作用域。 定义局部变量时,若添加了const关键字,则该只读的局部变量依然在栈区分配存储空间。但是当定义全局变量时,若添加了const关键字,则由于全局变量的存储空间为静态分配,又限定为只读,从其特性来看,完全可以在只读的Flash存储器中为其分配存储空间。 单片机芯片中集成的SRAM存储容量较小,而Flash存储容量较大,所以编译时会在Flash存储器中为只读的全局变量分配存储空间。 STM32F10x系列单片机芯片片上集成的Flash存储器地址从0x08000000开始,而SRAM存储器地址从0x20000000开始。除了const全局变量在Flash存储器中分配存储空间以外,堆区、栈区以及静态分配的变量都在SRAM中分配存储空间。 例3.17: 调试程序,观察变量的地址。 (1) 堆栈区在哪里? 如图3.31所示,编译连接成功后,Keil 5会给出提示,其中RWdata指在SRAM中静态分配并且初始化了的变量字节数,ZIdata指SRAM中分配的,没有指定初始化数值,初始化为零的字节数,这里包括堆区和栈区。RWdata字节数加上ZIdata字节数,就是SRAM存储区中已经使用了的字节数。 图3.31例3.17程序截图 栈区位于SRAM已使用区域的末端,所以当堆栈没有压入任何数据时,SP指针的值为堆栈区最后一个字节地址+1,即SRAM首地址0x20000000+RWdata+ZIdata。调试环境下可以看到,进入main()函数时SP寄存器的值为0x20000670。 例程中没有修改启动文件,栈区大小为0x00000400,因此堆栈区域地址为0x20000270~0x2000066F。 (2) main()函数中定义的局部变量在哪里? 局部变量无论是否初始化,都是在程序执行过程中调用函数时从栈区分配存储空间。进入函数时,在栈区中为函数内的局部变量分配存储空间,离开函数时释放堆栈空间。 在main()函数开始位置设置断点调试程序。进入main()函数后,打开Register和Watch窗口,观察CPU寄存器和变量的值。在Watch窗口中输入变量名时前方添加了C语言中的取地址符号&,Value栏显示的是变量的地址,如图3.32所示。 图3.32例3.17程序调试截图1 此时,程序执行暂停在float fAry[50]这行代码上。main()函数中定义了fAry数组和u32Val变量,两者一共占204字节。先从栈区中为fAry分配存储空间,即压栈fAry[50]数组,执行后SP指针数值减200,变为0x200005A8。然后压栈u32Val变量,SP指针数值减4,变为0x200005A4。所以两条定义变量的指令执行后,SP指针数值为0x200005A4,栈顶元素即为变量u32Val,数组fAry的首地址为0x200005A8,如图3.33所示。 图3.33例3.17程序调试截图2 (3) Ary_Init()函数中定义的静态局部变量在哪里? main()函数中在while(1)循环体中调用了函数Ary_Init(),而在Ary_Init()函数中定义了静态局部变量u8Cnt,在图3.33的Watch窗口里显示为“<cannot evaluate>”,这是因为当前程序还在main()函数中执行,没有调用过Ary_Init()函数,还无法访问u8Cnt变量。当程序执行到Ary_Init()函数时,情况如图3.34所示。 图3.34例3.17程序调试截图3 u8Cnt变量的地址为0x20000008,从地址可以看出,该变量不在堆区或栈区范围,而是在静态分配的区域。 当程序执行离开Ary_Init()函数时,u8Cnt变量依然存在,也就是说,分配给它的存储空间没有释放。在整个程序运行期间,存储空间都不会释放。 (4) 函数嵌套调用对栈区的消耗。 由于需要在栈区压栈函数调用的返回地址、压栈局部变量等,所以进入Ary_Init()函数后,堆栈指针SP寄存器的值变为0x20000598。 函数的每一次嵌套调用都需要消耗堆栈空间,一定要在启动文件中定义足够大的堆栈区! 递归函数会嵌套调用自身,根据函数调用时传递的参数数值决定嵌套调用的层数。嵌入式系统开发中需要小心谨慎地使用递归函数,调试递归函数程序时,应注意观察SP寄存器的数值,注意堆栈“溢出”错误。 (5) 全局变量在哪里? 调试程序进入main()函数时,在Watch窗口注意观察全局变量的情况,如图3.35所示。 图3.35例3.17程序调试截图4 全局变量是静态分配的,也就是说,编译时已经为变量分配了存储空间。只要程序加载,开始执行,任何时候都能访问全局变量。 程序中定义了全局变量gVal、静态全局变量gfVal和const全局变量CON,从图3.35可以看到,gVal的地址为0x20000000,gfVal地址为0x20000004,也就是说,编译器首先在SRAM中完成静态分配,然后才是堆区,最后是栈区。 片上 Flash存储器地址从0x08000000开始,主要用于存放程序代码,const类型的全局变量也在Flash存储器中分配存储空间。const全局数组CON首地址为0x08000410,从地址可以看出CON数组是在片上Flash中分配的存储空间。 在嵌入式系统开发中,由于片上集成的存储器大小有限,所以开发人员需要注意对存储空间的使用,根据项目实际情况定义栈区大小,注意避免在函数内定义较大的数组,以免造成堆栈“溢出”。