volatile 用法
volatile原意是“易变的”,在嵌入式环境中用volatile关键字声明的变量,在每次对其值进行引用的时候都会从原始地址取值。由于该值“易变”的特性所以,针对其的任何赋值或者获取值操作都会被执行(而不会被优化)。由于这个特性,所以该关键字在嵌入式编译环境中经常用来消除编译器的优化,可以分为以下三种情景:
- 修饰硬件寄存器;修饰中断服务函数中的非自动变量;在有操作系统的工程中修饰会被多个应用修改的变量;
修饰硬件寄存器
以STM32F103的HAL库函数中GPIO的定义举例,如下为HAL库中GPIO寄存器定义:
/** * @brief General Purpose I/O */ typedef struct { __IO uint32_t CRL; __IO uint32_t CRH; __IO uint32_t IDR; __IO uint32_t ODR; __IO uint32_t BSRR; __IO uint32_t BRR; __IO uint32_t LCKR; } GPIO_TypeDef;
其中__IO的定义是:
#define __IO volatile /*!< Defines 'read / write' permissions */
然后定义GPIO是:
#define GPIOA ((GPIO_TypeDef *)GPIOA_BASE) #define GPIOB ((GPIO_TypeDef *)GPIOB_BASE) #define GPIOC ((GPIO_TypeDef *)GPIOC_BASE) #define GPIOD ((GPIO_TypeDef *)GPIOD_BASE) #define GPIOE ((GPIO_TypeDef *)GPIOE_BASE) #define GPIOF ((GPIO_TypeDef *)GPIOF_BASE) #define GPIOG ((GPIO_TypeDef *)GPIOG_BASE)
而GPIOx_BASE的定义是这样的:
#define GPIOA_BASE (APB2PERIPH_BASE + 0x00000800UL) #define GPIOB_BASE (APB2PERIPH_BASE + 0x00000C00UL) #define GPIOC_BASE (APB2PERIPH_BASE + 0x00001000UL) #define GPIOD_BASE (APB2PERIPH_BASE + 0x00001400UL) #define GPIOE_BASE (APB2PERIPH_BASE + 0x00001800UL) #define GPIOF_BASE (APB2PERIPH_BASE + 0x00001C00UL) #define GPIOG_BASE (APB2PERIPH_BASE + 0x00002000UL)
其中APB2外设基地址的定义:
#define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL)
最后再来看外设基地址的定义:
#define PERIPH_BASE 0x40000000UL /*!< Peripheral base address in the alias region */
综合起来,将宏定义一一展开,仅用GPIOA来看,其它的以此类推:
#define GPIOA ((GPIO_TypeDef *)(0x40000000UL + 0x00010000UL + 0x00000800UL))
如此定义之后,那么GPIOA的CRL的地址就是:
(volatile uint16_t *)(0x40000000UL + 0x00010000UL + 0x00000800UL)
CRH的地址就是:
(volatile uint16_t *)(0x40000000UL + 0x00010000UL + 0x00000800UL + 2)
后面的寄存器以此类推,因而在程序中使用:
GPIOA->CRH |= 0x01;
那么实现的功能就是对GPIOA的CRH的寄存器的最低位拉高。如果在定义GPIO的寄存器结构体里面没有使用__IO uint16_t,而是仅使用uint16_t,那么在程序中再用语句:
GPIOA->CRH |= 0x01;
就有可能会被编译器优化,不执行这一语句,从而导致拉高CRH的最低位这个功能无法实现;但是库函数中使用了volatile来修饰,那么编译器就不会对此语句优化,在每次执行这一语句的时候都会从CRH对应的内存地址里面去取值或者存值,保证了每次执行都是有效的。
在有操作系统的工程中修饰会被多个任务修改的变量
在嵌入式开发中,不仅仅有单片机裸机开发,也有带有操作系统的开发,通常两者使用C语言开发的较多。在有操作系统(比如RTOS、UCOS-II、Linux等)的设计中,如果有多个任务在对同一个变量进行赋值或取值,那么这一类变量也应使用volatile来修饰保证其可见性。所谓可见即:当前任务修改了这一变量的值,同一时刻,其它任务此变量的值也发生了变化。
struct 用法
设计程序最重要的一个步骤就是选择一个表示数据的好方法。在多数情况下,使用简单的变量甚至数组都是不够的。C使用结构变量进一步增强了表示数据的能力。C的结构的基本形式就足以灵活的表示多种数据,并且能够创建新的形式。
C的结构的声明格式如下:
struct [结构体名] { 类型标识符 成员名 1; 类型标识符 成员名 2; ... 类型标识符 成员名 n; };
此声明描述了一个由n个数据类型的成员组成的结构,它并未创建实际的数据对象,只描述了该对象由什么组成。分析一下结构体声明的细节,首先是struct关键字,它表明跟在其后的是一个结构,后面是一个可选的标记,后面的程序中可以使用该标记引用该结构,因而我们可以在后面的程序中可以这样声明:
struct [结构体名] 结构体变量;
在结构体声明中用一对花括号括起来的是结构体成员列表。每个成员都用自己的声明来描述。成员可以是任意一种C的数据类型,甚至可以是其它结构。右花括号后面的分号是声明所必需的,表示该结构布局定义结束,例如:
struct students { char name[50]; char sex[50]; int age; float score; }; int main(void) { struct students student; printf("Name: %s\t",student.name[0]); printf("Sex: %s\t", student.sex); printf("Age: %d\t", student.age); printf("Score: %f\r\n", studen.score); return 0; }
可以把结构的声明放在所有函数的外部,也可以放在一个函数的内部。如果把一个结构声明在一个函数的内部,那么它的标记就只限于函数内部使用;如果把结构声明在所有函数的外部,那么该声明之后的所有函数都能使用它的标记。
结构有两层含义,一层含义是“结构布局”,如上述例子的structstudent{…};告诉编译器如何表示数据,但是它并未让编译器为数据分配空间;另一层含义是创建一个结构体变量,如上述例子的struct students student;编译器执行这行代码便创建了一个结构体变量student,编译器使用students模板为该变量分配空间:内含50个元素的char型数组1、50个元素的char型数组2,一个int型的变量和一个float的变量,这些存储空间都与一个名称为student结合在一起,如图 5.3.3 所示。
在内存中这个结构中的成员也是连续存储的。在通常程序设计中,struct还会与typedef一起使用,具体的会在后面的《typedef用法》一节介绍。
enum 用法
enum是C语言中用来修饰枚举类型变量的关键字。在C语言中可以使用枚举类型声明符号名称来表示整型常量,使用enum关键字可以创建一个新的“类型”并指定它可具有的值(实际上,enum常量是int类型,因此只要能使用int类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性,其语法与结构的语法相同,如下:
enum [枚举类型名] { 枚举符 1, 枚举符 2 ... 枚举符 n, };
例如:
enum color { red, green, blue, yellow };
enum常量
在上面的例子中,red, greeb, blue, yellow 到底是什么?从技术层面来讲,它们是 int 类型的整型常量,例如可以这样使用:
printf("red=%d, green=%d", red, green);
可以观察到最后打印的信息是:red=0,green=1。 red成为一个有名称的常量,代表整数0。类似的,其它的枚举符都是有名称的常量,分别代表1~3。只要是能使用整型常量的地方就可以使用枚举常量,例如,在声明数组的时候可以使用枚举常量表示数组的大小,在switch语句中可以把枚举常量作为标签。
enum默认值
默认情况下,枚举列表中的常量都被赋予0,1,2等,因此下面的声明中,apple的值是2:
enum fruit{banana, grape, apple};
enum赋值
在枚举类型中,可以为枚举常量指定整数值:
enum levels{low=90, medium=80, high=100};
如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值,例如:
enum feline{cat, lynx=10, puma, tiger};
那么cat=0,lynx、puma、tiger的值分别是10、11、12。
typedef 用法
typedef工具是一个高级数据特性,利用typedef可以为某一类型自定义名称。这方面与#define类似,但是两者有三处不同:
- 与#define不同,typedef创建的符号只受限于类型,不能用于值;tyedef由编译器解释,不是预处理器;在其受限范围内,typedef比#define更灵活;
假设要用BYTE表示1字节的数组,只需要像定义个char类型变量一样定义BYTE,然后再定义前面加上关键字typedef即可:
typedef unsigned char BYTE;
随后便可使用 BYTE 来定义变量:
BYTE x, y[10];
该定义的作用域取决于typedef定义所在的位置。如果定义在函数中,就具有局部作用域,受限于定义所在的函数。如果定义在函数外面,就具有文件作用域。
为现有类型创建一个名称,看起来是多此一举,但是它有时的确很有用。在前面的示例中,用BYTE代 替unsigned char表明你打算用BYTE类型的变量表示数字而不是字符。使用typedef还能提高程序的可移植性。
用typedef来命名一个结构体类型的时候,可以省略该结构的标签(struct):
typedef struct { char name[50]; unsigned int age; float score; }student_info; student_info student={“Bob”, 15, 90.5};
这样使用typedef定义的类型名会被翻译成:
struct {char name[50]; unsigned int age; float score;} student = {“Bob”, 15, 90.5};
使用typedef的第二个原因是:tyedef常用于给复杂的类型命名,例如:
typedef void (*pFunction)(void);
把pFunction声明为一个函数,该函数返回一个指针,该指针指向一个void型。
使用typdef时要记住,typedef并没有创建任何新类型,它只是为某个已有的类型增加了一个方便使用的标签。
预处理器与预处理指令
本节将简单介绍C语言的预处理器及其预处理指令。首先是预处理指令,它们是:
#define、#include、#ifdef、#else、#endif、#ifndef、#if、#elif、#line、#error、#pragma
在这些指令中,#line、#error、#pragma在基础开发中比较少见,其它的都是在编程过程中经常遇到和经常使用的,所以我们在后面的章节将主要介绍这些常用的指令。
C语言建立在适当的的关键字、表达式、语句以及使用他们的规则上。然而C标准不仅描述C语言,还描述如何执行C预处理器。
C预处理器在执行程序之前查看程序,因而被称之为预处理器。根据程序中的预处理指令,预处理器把符号缩写替换成其表示的内容(#define)。预处理器可以包含程序所需的其它文件(#include),可以选择让编译器查看哪些代码(条件编译)。预处理器并不知道C,基本上它的工作是把一些文本转换成另外一些文本。
由于预处理表达式的长度必须是一个逻辑行(可以把逻辑行使用换行符‘\’变成多个物理行),因而为了让预处理器得到正确的逻辑行,在预处理之前还会有个编译的过程,编译器定位每个反斜杠后面跟着换行符的示例,并删除它们,比如:
printf(“Hello, Chi\ na”);
转换成一个逻辑行:
printf(“Hello, China”);
另外,编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分割的项),需要注意的是,编译器将用一个空格字符替换每一条注释,例如:
char/*这是一条注释*/str;
将变成:
char str;
这样编译处理后,程序就准备好进入预处理阶段,预处理器查找一行中以#号开始的预处理指令。然后我们就从#define指令开始讲解这些预处理指令。
文件包含#include
当预处理器发现#include预处理指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换文件中的#include指令。这相当于把被包含文件的全部内容输入到源文件#include指令所在的位置。
#include指令有两种形式:
#include <stdio.h> // 文件名在尖括号内 #include “myfile.h” // 文件名在双引号内
在UNIX中,尖括号<>告诉预处理器在标准系统目录中寻找该文件,双引号“”告诉预处理器首先在当前目录(或指定路径的目录)中寻找该文件,如果未找到再查找标准系统目录:
#include <stdio.h> // 在标准系统目录中查找 stdio.h 文件 #include “myfile.h” // 在当前目录中查找 myfile.h 文件 #include “/project/header.h” // 在 project 目录查找 #include “../myheader.h” // 在当前文件的上一级目录查找
集成开发环境(IDE,比如开发板的开发环境keil)也有标准就或系统头文件的路径。许多集成环境提供菜单选项,指定用尖括号时的查找路径。
为什么我们要包含文件?因为编译器需要这些文件中的信息,例如stdio.h中通常包含EOF、NULL、getchar()和putchar()的定义。此外,该文件还包含C的其它的I/O函数。而对于我们自定义的文件,对于嵌入式开发来说,可能这些文件就有需要使用到的某些引脚宏定义、简单的功能函数宏定义等,以及某个源文件的全局变量和函数的声明等。
C语言习惯用.h后缀表示头文件,这些文件包含需要放在程序顶部的信息。头文件经常包含一些预处理指令,有些头文件由系统提供,也可以自定义。
下面是一个子自定义一个头文件的示例:gpio.h, main.c
/*gpio.h*/ #ifndef __GPIO_H #define __GPIO_H #include <stdio.h> typedef struct { uint8_t cnt; uint16_t sum; float result; }MyStruct; typedef enum { GPIO_RESET = 0, GPIO_SET = 1, }GPIO_STATE; #define ABS(x) ((x>0) ? (x) : (-x)) #endif
/* main.c */ #include “gpio.h” int main(void) { MyStruct my_struct = {0, 25, 3.14}; GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_SET); printf(“cnt=%d, sum=%d, result=%f\n\r”, my_struct.cnt, my_struct.sum, my_struct.result); }
#include指令也不是只包含.h文件,它同样也可以包含.c文件。
以上为C语言在嵌入式开发中volatile用法、struct用法、enum用法、预处理器与预处理指令及文件包含#include的详细介绍。希望能在嵌入式开发中助大家一臂之力。