嵌入式开发中,由于C 语言所处的环境不同,其用法也存在一定的特殊性,本文详解了C语言在嵌入式开发中volatile用法、struct用法、enum用法、预处理器与预处理指令及文件包含#include。希望能在嵌入式开发中助大家一臂之力。

volatile 用法

volatile原意是“易变的”,在嵌入式环境中用volatile关键字声明的变量,在每次对其值进行引用的时候都会从原始地址取值。由于该值“易变”的特性所以,针对其的任何赋值或者获取值操作都会被执行(而不会被优化)。由于这个特性,所以该关键字在嵌入式编译环境中经常用来消除编译器的优化,可以分为以下三种情景:

  1. 修饰硬件寄存器;修饰中断服务函数中的非自动变量;在有操作系统的工程中修饰会被多个应用修改的变量;

修饰硬件寄存器
以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类似,但是两者有三处不同:

  1. 与#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的详细介绍。希望能在嵌入式开发中助大家一臂之力。