C 语言嵌入式系统编程修炼之一:背景篇
不同于一般形式的软件编程,嵌入式系统编程建立在特定的硬件平台上,势必要求其编程语言具备较强的硬件直接操作能力。无疑,汇编语言具备这样的特质。但是,归因于汇编语言开发过程的复杂性,它并不是嵌入式系统开发的一般选择。而与之相比,C 语言:一种"高级的低级"语言,则成为嵌入式系统开发的最佳选择。
实际上,大多数嵌入式系统的硬件平台包括两部分:
- 以通用处理器为中心的协议处理模块,用于网络控制协议的处理;
- 以数字信号处理器(DSP)为中心的信号处理模块,用于调制、解调和数/模信号转换。
嵌入式系统硬件架构
- 协议处理模块中的 FLASH 和 RAM 几乎是每个嵌入式系统的必备设备,前者用于存储程序,后者则是程序运行时指令及数据的存放位置。系统所选择的FLASH和 RAM 的位宽都为16 位,与 CPU 一致。
- 实时钟芯片可以为系统定时,给出当前的年、月、日及具体时间(小时、分、秒及毫秒),可以设定其经过一段时间即向 CPU 提出中断或设定报警时间到来时向 CPU 提出中断(类似闹钟功能)。
- NVRAM(非易失去性RAM)具有掉电不丢失数据的特性,可以用于保存系统的设置信息,譬如网络协议参数等。在系统掉电或重新启动后,仍然可以读取先前的设置信息。其位宽为 8 位,比 CPU 字长小。文章特意选择一个与 CPU 字长不一致的存储芯片,为后文中一节的讨论创造条件。
- UART 则完成 CPU 并行数据传输与 RS-232 串行数据传输的转换,它可以在接收到[1~MAX_BUFFER]字节后向 CPU 提出中断,MAX_BUFFER 为UART芯片存储接收到字节的最大缓冲区。
- 键盘控制器和显示控制器则完成系统人机界面的控制。
嵌入式系统需要良好的软件开发环境的支持,由于嵌入式系统的目标机资源受限,不可能在其上建立庞大、复杂的开发环境,因而其开发环境和目标运行环境相互分离。因此,嵌入式应用软件的开发方式一般是,在宿主机(Host)上建立开发环境,进行应用程序编码和交叉编译,然后宿主机同目标机(Target)建立连接,将应用程序下载到目标机上进行交叉调试,经过调试和优化,最后将应用程序固化到目标机中实际运行。
C 语言嵌入式系统编程修炼之二:软件架构篇
模块划分
模块划分的"划"是规划的意思,意指怎样合理的将一个很大的软件划分为一系列功能独立的部分合作完成系统的需求。
C 语言作为一种结构化的程序设计语言,在模块的划分上主要依据功能。
C 语言模块化程序设计需理解如下概念:
- 模块即是一个.c 文件和一个.h 文件的结合,头文件(.h)中是对于该模块接口的声明;
- 某模块提供给其它模块调用的外部函数及数据需在.h 中文件中冠以 extern 关键字声明;
- 模块内的函数和全局变量需在.c 文件开头冠以 static 关键字声明;
- 永远不要在.h 文件中定义变量!定义变量和声明变量的区别在于定义会产生内存分配的操作,是汇编阶段的概念;声明则只是告诉包含该声明的模块在连接阶段从其它模块寻找外部函数和变量。
View Code/*module1.h*/
int a = 5; /* 在模块1的.h 文件中定义 int a */
/*module1 .c*/
#include "module1.h" /* 在模块1中包含模块 1 的.h 文件 */
/*module2 .c*/
#include "module1.h" /* 在模块2中包含模块 1 的.h 文件 */
/*module3 .c*/
#include "module1.h" /* 在模块3中包含模块 1 的.h 文件 */
以上程序的结果是在模块 1、2、3 中都定义了整型变量a,a 在不同的模块中对应不同的地址单元,这个世界上从来不要这样的程序。正确的做法是:
/*module1.h*/
extern int a; /* 在模块 1 的.h 文件中声明 int a */
/*module1 .c*/
#include "module1.h" /* 在模块1中包含模块 1 的.h 文件 */
int a = 5; /* 在模块1的.c 文件中定义 int a */
/*module2 .c*/
include "module1.h" /* 在模块2中包含模块 1 的.h 文件 */
/*module3 .c*/
#include "module1.h" /* 在模块3中包含模块 1 的.h 文件 */
这样如果模块1、2、3操作a 的话,对应的是同一片内存单元。
一个嵌入式系统通常包括两类模块:
- 硬件驱动模块,一种特定硬件对应一个模块;
- 软件功能模块,其模块的划分应满足低偶合、高内聚的要求。
多任务还是单任务
所谓"单任务系统"是指该系统不能支持多任务并发操作,宏观串行地执行一个任务。而多任务系统则可以宏观并行(微观上可能串行)地"同时"执行多个任务。
多任务的并发执行通常依赖于一个多任务操作系统(OS),多任务OS 的核心是系统调度器,它使用任务控制块(TCB)来管理任务调度功能。TCB包括任务的当前状态、优先级、要等待的事件或资源、任务程序码的起始地址、初始堆栈指针等信息。调度器在任务被激活时,要用到这些信息。此外,TCB 还被用来存放任务的"上下文"(context)。
任务的上下文就是当一个执行中的任务被停止时,所要保存的所有信息。通常,上下文就是计算机当前的状态,也即各个寄存器的内容。当发生任务切换时,当前运行的任务的上下文被存入 TCB,并将要被执行的任务的上下文从它的TCB 中取出,放入各个寄存器中。
究竟选择多任务还是单任务方式,依赖于软件的体系是否庞大。例如,绝大多数手机程序都是多任务的,但也有一些小灵通的协议栈是单任务的,没有操作系统,它们的主程序轮流调用各个软件模块的处理程序,模拟多任务环境。
单任务程序典型架构
- 从CPU复位时的指定地址开始执行;
- 跳转至汇编代码 startup 处执行;
- 跳转至用户主程序 main 执行,在main 中完成:
-
- a.初试化各硬件设备;
- b.初始化各软件模块;
- c.进入死循环(无限循环),调用各模块的处理函数
用户主程序和各模块的处理函数都以C 语言完成。用户主程序最后都进入了一个死循环,其首选方案是:
while(1)
{
}
有的程序员这样写:
for(;;)
{
}
这个语法没有确切表达代码的含义,我们从for(;;)看不出什么,只有弄明白for(;;)在C语言中意味着无条件循环才明白其意。
中断服务程序
中断是嵌入式系统中重要的组成部分,但是在标准C 中不包含中断。许多编译开发商在标准C 上增加了对中断的支持,提供新的关键字用于标示中断服务程序 (ISR),类似于__interrupt、#program interrupt 等。当一个函数被定义为 ISR的时候,编译器会自动为该函数增加中断服务程序所需要的中断现场入栈和出栈代码。
中断服务程序需要满足如下要求:
- 不能返回值;
- 不能向 ISR 传递参数;
- ISR应该尽可能的短小精悍;
- printf(char * lpFormatString,…)函数会带来重入和性能问题,不能在 ISR 中采用。
在某项目的开发中,设计了一个队列,在中断服务程序中,只是将中断类型添加入该队列中,在主程序的死循环中不断扫描中断队列是否有中断,有则取出队列中的第一个中断类型,进行相应处理。
/* 存放中断的队列 */
typedef struct tagIntQueue
{
int intType; /* 中断类型 */
struct tagIntQueue *next;
}IntQueue;
IntQueue lpIntQueueHead;
__interrupt ISRexample ()
{
int intType;
intType = GetSystemType();
QueueAddTail (lpIntQueueHead, intType);/* 在队列尾加入新的中断 */
}
在主程序循环中判断是否有中断:
While(1)
{
If( !IsIntQueueEmpty() )
{
intType = GetFirstInt();
switch(intType) /* 是不是很象WIN32 程序的消息解析函数? */
{
/* 对,我们的中断类型解析很类似于消息驱动 */
case xxx: /* 我们称其为"中断驱动"吧? */
…
break;
case xxx:
…
break;
…
}
}
}
按上述方法设计的中断服务程序很小,实际的工作都交由主程序执行了。
硬件驱动模块
一个硬件驱动模块通常应包括如下函数:
(1)中断服务程序 ISR
(2)硬件初始化
a.修改寄存器,设置硬件参数(如UART应设置其波特率,AD/DA 设备应设置其采样速率等);
b.将中断服务程序入口地址写入中断向量表:View Code/* 设置中断向量表 */
m_myPtr = make_far_pointer(0l); /* 返回void far 型指针void far * */
m_myPtr += ITYPE_UART; /* ITYPE_UART: uart中断服务程序 */
/* 相对于中断向量表首地址的偏移 */
*m_myPtr = &UART _Isr; /* UART _Isr:UART 的中断服务程序 */
(3)设置 CPU 针对该硬件的控制线
a.如果控制线可作 PIO(可编程 I/O)和控制信号用,则设置CPU 内部对应寄存器使其作为控制信号;
b.设置CPU 内部的针对该设备的中断屏蔽位,设置中断方式(电平触发还是边缘触发)。
(4)提供一系列针对该设备的操作接口函数。例如,对于LCD,其驱动模块应提供绘制像素、画线、绘制矩阵、显示字符点阵等函数;而对于实时钟,其驱动模块则需提供获取时间、设置时间等函数。
C 的面向对象化
在面向对象的语言里面,出现了类的概念。类是对特定数据的特定操作的集合体。类包含了两个范畴:数据和操作。而 C 语言中的 struct 仅仅是数据的集合,我们可以利用函数指针将 struct 模拟为一个包含数据和操作的"类"。下面的 C 程序模拟了一个最简单的"类":
#ifndef C_Class
#define C_Class struct
#endif
C_Class A
{
C_Class A *A_this; /* this 指针 */
void (*Foo)(C_Class A *A_this); /* 行为:函数指针 */
int a; /* 数据 */
int b;
};
我们可以利用 C 语言模拟出面向对象的三个特性:封装、继承和多态,但是更多的时候,我们只是需要将数据与行为封装以解决软件结构混乱的问题。C 模拟面向对象思想的目的不在于模拟行为本身,而在于解决某些情况下使用 C 语言编程时程序整体框架结构分散、数据和函数脱节的问题。
软件结构是软件的灵魂!结构混乱的程序面目可憎,调试、测试、维护、升级都极度困难。
C 语言嵌入式系统编程修炼之三:内存操作篇
数据指针
在嵌入式系统的编程中,常常要求在特定的内存单元读写内容,汇编有对应的 MOV 指令,而除 C/C++以外的其它编程语言基本没有直接访问绝对地址的能力。在嵌入式系统的实际调试中,多借助 C 语言指针所具有的对绝对地址单元内容的读写能力。以指针直接操作内存多发生在如下几种情况:
- 某I/O 芯片被定位在CPU的存储空间而非I/O 空间,而且寄存器对应于某特定地址;
- 两个 CPU 之间以双端口 RAM 通信,CPU 需要在双端口 RAM 的特定单元(称为 mail box)书写内容以在对方 CPU 产生中断;
- 读取在 ROM或 FLASH 的特定单元所烧录的汉字和英文字模。
譬如:
unsigned char *p = (unsigned char *)0xF000FF00;
*p=11;
以上程序的意义为在绝对地址0xF0000+0xFF00(80186使用16位段地址和16 位偏移地址)写入 11。
在使用绝对地址指针时,要注意指针自增自减操作的结果取决于指针指向的数据类别。上例中 p++后的结果是 p= 0xF000FF01,若p 指向 int,即:
int *p = (int *)0xF000FF00;
p++(或++p)的结果等同于:p = p+sizeof(int),而 p-(或-p)的结果是 p = p-sizeof(int)。
同理,若执行:
long int *p = (long int *)0xF000FF00;
则p++(或++p)的结果等同于:p = p+sizeof(long int) ,而p-(或-p)的结果是p = p-sizeof(long int)。
记住:CPU 以字节为单位编址,而 C 语言指针以指向的数据类型长度作自增和自减。理解这一点对于以指针直接操作内存是相当重要的。
函数指针
首先要理解以下三个问题:
- C语言中函数名直接对应于函数生成的指令代码在内存中的地址,因此函数名可以直接赋给指向函数的指针;
- 调用函数实际上等同于"调转指令+参数传递处理+回归位置入栈",本质上最核心的操作是将函数生成的目标代码的首地址赋给 CPU的 PC寄存器;
- 因为函数调用的本质是跳转到某一个地址单元的 code 去执行,所以可以"调用"一个根本就不存在的函数实体。
《微型计算机原理》教材中讲到,186 CPU启动后跳转至绝对地址 0xFFFF0(对应C 语言指针是 0xF000FFF0,0xF000为段地址,0xFFF0 为段内偏移)执行,请看下面的代码:
typedef void (*lpFunction) ( ); /* 定义一个无参数、无返回类型的函数指针类型*/
/* 定义一个函数指针,指向 CPU 启动后所执行第一条指令的位置*/
lpFunction lpReset = (lpFunction)0xF000FFF0;
lpReset(); /* 调用函数 */
在以上的程序中,我们根本没有看到任何一个函数实体,但是我们却执行了这样的函数调用:lpReset(),它实际上起到了"软重启"的作用,跳转到 CPU 启动后第一条要执行的指令的位置。
记住: 函数无它,唯指令集合耳;你可以调用一个没有函数体的函数,本质上只是换一个地址开始执行指令!
数组 vs.动态申请
在嵌入式系统中动态内存申请存在比一般系统编程时更严格的要求,这是因为嵌入式系统的内存空间往往是十分有限的,不经意的内存泄露会很快导致系统的崩溃。所以一定要保证你的malloc 和free 成对出现,如果你写出这样的一段程序:
char * function(void)
{
char *p;
p = (char *)malloc(…);
if(p NULL)
…;
… /* 一系列针对 p 的操作 */
return p;
}
在某处调用 function(),用完function 中动态申请的内存后将其free,如下:
char *q = function();
…
free(q);
上述代码明显是不合理的,因为违反了 malloc 和 free 成对出现的原则,即"谁申请,就由谁释放"原则。不满足这个原则,会导致代码的耦合度增大,因为用户在调用 function 函数时需要知道其内部细节!
正确的做法是在调用处申请内存,并传入 function 函数,如下:
char *p=malloc(…);
if(p NULL)
…;
function(p);
…
free(p);
p=NULL;
而函数function 则接收参数p,如下:
void function(char *p)
{
… /* 一系列针对 p的操作 */
}
基本上,动态申请内存方式可以用较大的数组替换。对于编程新手,推荐尽量采用数组!嵌入式系统可以以博大的胸襟接收瑕疵,而无法"海纳"错误。
给出原则:
- 尽可能的选用数组,数组不能越界访问(真理越过一步就是谬误,数组越过界限就光荣地成全了一个混乱的嵌入式系统);
- 如果使用动态申请,则申请后一定要判断是否申请成功了,并且 malloc 和 free 应成对出现!
关键字 const
const 意味着"只读"。区别如下代码的功能非常重要,也是老生长叹,如果你还不知道它们的区别,而且已经在程序界摸爬滚打多年,那只能说这是一个悲哀。
1 const int a;
2 int const a;
3 const int *a;
4 int * const a;
5 int const * a const;
- 关键字const 的作用是为给读你代码的人传达非常有用的信息。例如,在函数的形参前添加const关键字意味着这个参数在函数体内不会被修改,属于"输入参数"。在有多个形参的时候,函数的调用者可以凭借参数前是否有 const 关键字,清晰的辨别哪些是输入参数,哪些是可能的输出参数。
- 合理地使用关键字 const可以使编译器很自然地保护那些不希望被改变的参数,防止其被无意的代码修改,这样可以减少 bug的出现。
const 在 C++语言中则包含了更丰富的含义,而在 C 语言中仅意味着:"只能读的普通变量",可以称其为"不能改变的变量"(这个说法似乎很拗口,但却最准确的表达了 C 语言中 const 的本质),在编译阶段需要的常数仍然只能以#define 定义!故在C 语言中如下程序是非法的:
const int SIZE = 10;
char a[SIZE]; /* 非法:编译阶段不能用到变量 */
关键字 volatile
C 语言编译器会对用户书写的代码进行优化,譬如如下代码:
1 int a,b,c;
2 a = inWord(0x100); /*读取 I/O 空间 0x100 端口的内容存入 a 变量*/
3 b = a;
4 a = inWord (0x100); /*再次读取 I/O 空间0x100端口的内容存入 a 变量*/
5 c = a;
很可能被编译器优化为:
1 int a,b,c;
2 a = inWord(0x100); /*读取 I/O 空间 0x100 端口的内容存入 a 变量*/
3 b = a;
4 c = a;
但是这样的优化结果可能导致错误,如果 I/O 空间 0x100 端口的内容在执行第一次读操作后被其它程序写入新值,则其实第2次读操作读出的内容与第一次不同,b和c 的值应该不同。在变量a的定义前加上 volatile 关键字可以防止编译器的类似优化,正确的做法是:
volatile int a;
volatile 变量可能用于如下几种情况:
- 并行设备的硬件寄存器(如:状态寄存器,例中的代码属于此类);
- 一个中断服务子程序中会访问到的非自动变量(也就是全局变量);
- 多线程应用中被几个任务共享的变量。
CPU 字长与存储器位宽不一致处理
解决 CPU 字长与存储器位宽不一致的情况。80186 的字长为 16,而 NVRAM 的位宽为 8,在这种情况下,我们需要为 NVRAM 提供读写字节、字的接口,如下:
typedef unsigned char BYTE;
typedef unsigned int WORD;
/* 函数功能:读 NVRAM中字节
* 参数:wOffset,读取位置相对 NVRAM 基地址的偏移
* 返回:读取到的字节值
*/
extern BYTE ReadByteNVRAM(WORD wOffset)
{
LPBYTE lpAddr = (BYTE*)(NVRAM + wOffset * 2); /* 为什么偏移要×2? */
return *lpAddr;
}
/* 函数功能:读 NVRAM中字
* 参数:wOffset,读取位置相对 NVRAM 基地址的偏移
* 返回:读取到的字
*/
extern WORD ReadWordNVRAM(WORD wOffset)
{
WORD wTmp = 0;
LPBYTE lpAddr;
/* 读取高位字节 */
lpAddr = (BYTE*)(NVRAM + wOffset * 2); /* 为什么偏移要×2? */
wTmp += (*lpAddr)*256;
/* 读取低位字节 */
lpAddr = (BYTE*)(NVRAM + (wOffset +1) * 2); /* 为什么偏移要×2? */
wTmp += *lpAddr;
return wTmp;
}
/* 函数功能:向 NVRAM中写一个字节
*参数:wOffset,写入位置相对 NVRAM 基地址的偏移
* byData,欲写入的字节
*/
extern void WriteByteNVRAM(WORD wOffset, BYTE byData)
{
…
}
/* 函数功能:向 NVRAM中写一个字 */
*参数:wOffset,写入位置相对 NVRAM 基地址的偏移
* wData,欲写入的字
*/
extern void WriteWordNVRAM(WORD wOffset, WORD wData)
{
…
}
子贡问曰:Why 偏移要乘以2?
子曰:16位 80186与8位 NVRAM之间互连只能以地址线A1 对其A0,CPU本身的 A0与NVRAM 不连接。因此,NVRAM 的地址只能是偶数地址,故每次以 0x10 为单位前进!