C 语言嵌入式系统编程修炼之一:背景篇
不同于一般形式的软件编程,嵌入式系统编程建立在特定的硬件平台上,势必要求
其编程语言具备较强的硬件直接操作能力。无疑,汇编语言具备这样的特质。但是,归
因于汇编语言开发过程的复杂性,它并不是嵌入式系统开发的一般选择。而与之相比,
C 语言--一种"高级的低级"语言,则成为嵌入式系统开发的最佳选择。笔者在嵌入式系
统项目的开发过程中,一次又一次感受到 C 语言的精妙,沉醉于 C 语言给嵌入式开发带
来的便利。
图 1 给出了本文的讨论所基于的硬件平台,实际上,这也是大多数嵌入式系统的硬
件平台。它包括两部分:
(1) 以通用处理器为中心的协议处理模块,用于网络控制协议的处理;
(2) 以数字信号处理器(DSP)为中心的信号处理模块,用于调制、解调和数/
模信号转换。
本文的讨论主要围绕以通用处理器为中心的协议处理模块进行,因为它更多地牵涉
到具体的 C 语言编程技巧。而 DSP 编程则重点关注具体的数字信号处理算法,主要涉
及通信领域的知识,不是本文的讨论重点。
着眼于讨论普遍的嵌入式系统 C 编程技巧,系统的协议处理模块没有选择特别的
CPU,而是选择了众所周知的 CPU 芯片--80186,每一位学习过《微机原理》的读者都
应该对此芯片有一个基本的认识,且对其指令集比较熟悉。80186 的字长是 16 位,可
以寻址到的内存空间为 1MB,只有实地址模式。C 语言编译生成的指针为 32 位(双字),
高 16 位为段地址,低 16 位为段内编译,一段最多 64KB。
图 1 系统硬件架构
协议处理模块中的 FLASH 和 RAM 几乎是每个嵌入式系统的必备设备,前者用于存
储程序,后者则是程序运行时指令及数据的存放位置。系统所选择的 FLASH 和 RAM 的
位宽都为 16 位,与 CPU 一致。
实时钟芯片可以为系统定时,给出当前的年、月、日及具体时间(小时、分、秒及
毫秒),可以设定其经过一段时间即向 CPU 提出中断或设定报警时间到来时向 CPU 提出
中断(类似闹钟功能)。
NVRAM(非易失去性 RAM)具有掉电不丢失数据的特性,可以用于保存系统的设
置信息,譬如网络协议参数等。在系统掉电或重新启动后,仍然可以读取先前的设置信
息。其位宽为 8 位,比 CPU 字长小。文章特意选择一个与 CPU 字长不一致的存储芯片,
为后文中一节的讨论创造条件。
UART 则完成 CPU 并行数据传输与 RS-232 串行数据传输的转换,它可以在接收到
[1~MAX_BUFFER]字节后向 CPU 提出中断,MAX_BUFFER 为 UART 芯片存储接收到字
节的最大缓冲区。
键盘控制器和显示控制器则完成系统人机界面的控制。
以上提供的是一个较完备的嵌入式系统硬件架构,实际的系统可能包含更少的外
设。之所以选择一个完备的系统,是为了后文更全面的讨论嵌入式系统 C 语言编程技巧
的方方面面,所有设备都会成为后文的分析目标。
嵌入式系统需要良好的软件开发环境的支持,由于嵌入式系统的目标机资源受限,
不可能在其上建立庞大、复杂的开发环境,因而其开发环境和目标运行环境相互分离。
因此,嵌入式应用软件的开发方式一般是,在宿主机(Host)上建立开发环境,进行应用
程序编码和交叉编译,然后宿主机同目标机(Target)建立连接,将应用程序下载到目标
机上进行交叉调试,经过调试和优化,最后将应用程序固化到目标机中实际运行。
CAD-UL 是适用于 x86 处理器的嵌入式应用软件开发环境,它运行在 Windows 操作
系统之上,可生成 x86 处理器的目标代码并通过 PC 机的 COM 口(RS-232 串口)或以
太网口下载到目标机上运行,如图 2。其驻留于目标机 FLASH 存储器中的 monitor 程序
可以监控宿主机 Windows 调试平台上的用户调试指令,获取 CPU 寄存器的值及目标机
存储空间、I/O 空间的内容。
图 2 交叉开发环境
后续章节将从软件架构、内存操作、屏幕操作、键盘操作、性能优化等多方面阐述
C 语言嵌入式系统的编程技巧。软件架构是一个宏观概念,与具体硬件的联系不大;内
存操作主要涉及系统中的 FLASH、RAM 和 NVRAM 芯片;屏幕操作则涉及显示控制器和
实时钟;键盘操作主要涉及键盘控制器;性能优化则给出一些具体的减小程序时间、空
间消耗的技巧。
在我们的修炼旅途中将经过 25 个关口,这些关口主分为两类,一类是技巧型,有
很强的适用性;一类则是常识型,在理论上有些意义。
C 语言嵌入式系统编程修炼之二:软件架构篇
模块划分
模块划分的"划"是规划的意思,意指怎样合理的将一个很大的软件划分为一系列功
能独立的部分合作完成系统的需求。C 语言作为一种结构化的程序设计语言,在模块的
划分上主要依据功能(依功能进行划分在面向对象设计中成为一个错误,牛顿定律遇到
了>相对论),C 语言模块化程序设计需理解如下概念:
(1) 模块即是一个.c 文件和一个.h 文件的结合,头文件(.h)中是对于该模块接口
的声明;
(2) 某模块提供给其它模块调用的外部函数及数据需在.h 中文件中冠以 extern
关键字声明;
(3) 模块内的函数和全局变量需在.c 文件开头冠以 static 关键字声明;
(4) 永远不要在.h 文件中定义变量!定义变量和声明变量的区别在于定义会产生
内存分配的操作,是汇编阶段的概念;而声明则只是告诉包含该声明的模块在连接阶段
从其它模块寻找外部函数和变量。如:
/*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 的话,对应的是同一片内存单元。
一个嵌入式系统通常包括两类模块:
(1)硬件驱动模块,一种特定硬件对应一个模块;
(2)软件功能模块,其模块的划分应满足低偶合、高内聚的要求。
多任务还是单任务
所谓"单任务系统"是指该系统不能支持多任务并发操作,宏观串行地执行一个任
务。而多任务系统则可以宏观并行(微观上可能串行)地"同时"执行多个任务。
多任务的并发执行通常依赖于一个多任务操作系统(OS),多任务 OS 的核心是系
统调度器,它使用任务控制块(TCB)来管理任务调度功能。TCB 包括任务的当前状态、
优先级、要等待的事件或资源、任务程序码的起始地址、初始堆栈指针等信息。调度器
在任务被激活时,要用到这些信息。此外,TCB 还被用来存放任务的"上下文"(context)。
任务的上下文就是当一个执行中的任务被停止时,所要保存的所有信息。通常,上下文
就是计算机当前的状态,也即各个寄存器的内容。当发生任务切换时,当前运行的任务
的上下文被存入 TCB,并将要被执行的任务的上下文从它的 TCB 中取出,放入各个寄
存器中。
嵌入式多任务 OS 的典型例子有 Vxworks、ucLinux 等。嵌入式 OS 并非遥不可及的
神坛之物,我们可以用不到 1000 行代码实现一个针对 80186 处理器的功能最简单的
OS 内核,作者正准备进行此项工作,希望能将心得贡献给大家。
究竟选择多任务还是单任务方式,依赖于软件的体系是否庞大。例如,绝大多数手
机程序都是多任务的,但也有一些小灵通的协议栈是单任务的,没有操作系统,它们的
主程序轮流调用各个软件模块的处理程序,模拟多任务环境。
单任务程序典型架构
(1)从 CPU 复位时的指定地址开始执行;
(2)跳转至汇编代码 startup 处执行;
(3)跳转至用户主程序 main 执行,在 main 中完成:
a.初试化各硬件设备;
b.初始化各软件模块;
c.进入死循环(无限循环),调用各模块的处理函数
用户主程序和各模块的处理函数都以 C 语言完成。用户主程序最后都进入了一个死
循环,其首选方案是:
while(1)
{
}
有的程序员这样写:
for(;;)
{
}
这个语法没有确切表达代码的含义,我们从 for(;;)看不出什么,只有弄明白 for(;;)
在 C 语言中意味着无条件循环才明白其意。
下面是几个"著名"的死循环:
(1)操作系统是死循环;
(2)WIN32 程序是死循环;
(3)嵌入式系统软件是死循环;
(4)多线程程序的线程处理函数是死循环。
你可能会辩驳,大声说:"凡事都不是绝对的,2、3、4 都可以不是死循环"。Yes,
you are right,但是你得不到鲜花和掌声。实际上,这是一个没有太大意义的牛角尖,
因为这个世界从来不需要一个处理完几个消息就喊着要 OS 杀死它的 WIN32 程序,不
需要一个刚开始 RUN 就自行了断的嵌入式系统,不需要莫名其妙启动一个做一点事就
干掉自己的线程。有时候,过于严谨制造的不是便利而是麻烦。君不见,五层的 TCP/IP
协议栈超越严谨的 ISO/OSI 七层协议栈大行其道成为事实上的标准?
经常有网友讨论:
printf("%d,%d",++i,i++); /* 输出是什么?*/
c = a+++b; /* c=? */
等类似问题。面对这些问题,我们只能发出由衷的感慨:世界上还有很多有意义的
事情等着我们去消化摄入的食物。
实际上,嵌入式系统要运行到世界末日。
中断服务程序
中断是嵌入式系统中重要的组成部分,但是在标准 C 中不包含中断。许多编译开发
商在标准 C 上增加了对中断的支持,提供新的关键字用于标示中断服务程序 (ISR),类
似于__interrupt、#program interrupt 等。当一个函数被定义为 ISR 的时候,编译器会
自动为该函数增加中断服务程序所需要的中断现场入栈和出栈代码。
中断服务程序需要满足如下要求:
(1)不能返回值;
(2)不能向 ISR 传递参数;
(3) ISR 应该尽可能的短小精悍;
(4) 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.将中断服务程序入口地址写入中断向量表:
/* 设置中断向量表 */
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 语言编程时程序整体框架
结构分散、数据和函数脱节的问题。我们在后续章节会看到这样的例子。
总结
本篇介绍了嵌入式系统编程软件架构方面的知识,主要包括模块划分、多任务还是
单任务选取、单任务程序典型架构、中断服务程序、硬件驱动模块设计等,从宏观上给
出了一个嵌入式系统软件所包含的主要元素。
请记住:软件结构是软件的灵魂!结构混乱的程序面目可憎,调试、测试、维护、