[C语言嵌入式系统编程修炼] 软件架构与内存操作篇

在大学一直搞的是单片机,写的是嵌入式C语言程序,走过了不少弯路,现在感觉仍然在走弯路。有幸偶尔看到了这篇文章,深感自己以前写程序的时候存在很多误区。现写篇博客做下总结。

作者:宋宝华出处:天极网责任编辑: 方舟 [ 2005-06-14 14:45 ]


第一篇 软件架构篇


1.1 模块划分

模块划分的 “划” 是规划的意思,意指怎样合理的将一个很大的软件划分为一系列功能独立的部分合作完成系统的需求。C 语言作为一种结构化的程序设计语言,在程序的划分上主要依据功能,C语言模块化程序设计需理解一下概念:

(1)模块即是一个 .c 文件和一个 .h 文件的结合,头文件 (.h) 中是对于该模块借口的声明;

(2)某模块提供给其它模块调用的外部函数以及数据需要在 .h 文件中冠以 extern 关键字声明;

(3)只在模块内调用的函数和全局变量,尽量使用 static 进行修饰,避免可能发生的命名冲突;

(4)永远不要在 .h 文件中 定义 变量!定义变量和声明变量的区别在于,定义会给变量分配内存,是汇编阶段的概念;而声明则只是告诉包含该声明的模块,在本模块或者其它模块定义了该函数或变量,需要在连接阶段从其它模块寻找外部函数和变量。


例如:

// module1.h
int a = 5;    // 在模块1的.h文件中定义了int a  (最好不要在.h文件中定义变量)

// 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 = 5;    // 在模块1的.h文件中声明整型变量a

// module1.c
#include "module1.h"    // 在模块1源文件中包含模块1的.h文件  
int a = 5;    // 在模块1源文件中定义整型变量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 中取出,放入各个寄存器中。

究竟选择多任务还是单任务方式,依赖于软件的体系是否庞大。例如,绝大多数手机程序都是多任务的,但也有一些小灵通的协议栈是单任务的,没有操作系统,它们的主程序轮流调用各个软件模块的处理程序,模拟多任务环境。

1.3 单任务程序典型架构

(1)从CPU复位时的指定地址开始执行;

(2)跳转至汇编代码startup处执行;

(3)跳转至用户主程序main执行,在main中完成:a.初试化各硬件设备; b.初始化各软件模块;c.进入死循环(无限循环),调用各模块的处理函数。

用户主程序和各模块的处理函数都以 C 语言完成。用户主程序最后都进入了一个死循环,其首选方案是:

while(1)
{
    /* 各模块的处理程序 */
}

下面是几个 "著名" 的死循环:

(1)操作系统是死循环;

(2)WIN32 程序是死循环;

(3)嵌入式系统软件是死循环;

(4)多线程程序的线程处理函数是死循环。

1.4 中断服务程序

中断是嵌入式系统中重要的组成部分,但是在标准 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.5 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 语言编程时程序整体框架结构分散、数据和函数脱节的问题。

1.6 总结

本篇介绍了嵌入式系统编程软件架构方面的知识,主要包括模块划分、多任务还是单任务选取、单任务程序典型架构、中断服务程序、C的面向对象化等,从宏观上给出了一个嵌入式系统软件所包含的主要元素。

第二篇 内存操作

2.1 数据指针

在嵌入式系统的编程中,常常要求在特定的内存单元读写内容,汇编有对应的 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;

记住:CPU 以字节为单位编址,而 C 语言指针以指向的数据类型长度作自增和自减。理解这一点对于以指针直接操作内存是相当重要的。

2.2 函数指针

首先要理解以下三个问题:

(1)C语言中函数名直接对应于函数生成的指令代码在内存中的地址,因此函数名可以直接赋给指向函数的指针;

(2)调用函数实际上等同于 "调转指令+参数传递处理+回归位置入栈",本质上最核心的操作是将函数生成的目标代码的首地址赋给CPU的PC寄存器;

(3)因为函数调用的本质是跳转到某一个地址单元的code去执行,所以可以 "调用" 一个根本就不存在的函数实体,晕?请往下看:

请拿出你可以获得的任何一本大学《微型计算机原理》教材,书中讲到,X86 CPU 启动后跳转至绝对地址 0xFFFF0(对应 C 语言指针是 0xF000FFF0,0xF000 为段地址,0xFFF0 为段内偏移)执行,请看下面的代码:

typedef void (*lp) ( ); /* 定义一个无参数、无返回类型的函数指针类型 */

lp lpReset = (lp)0xF000FFF0; /* 定义一个函数指针,指向*/
/* CPU启动后所执行第一条指令的位置 */
lpReset(); /* 调用函数 */

在以上的程序中,我们根本没有看到任何一个函数实体,但是我们却执行了这样的函数调用:lpReset(),它实际上起到了 "软重启" 的作用,跳转到 CPU 启动后第一条要执行的指令的位置。

2.3 关键字volatile

C 语言编译器会对用户书写的代码进行优化,譬如如下代码:

int a,b,c;
a = inWord(0x100); /*读取I/O空间0x100端口的内容存入a变量*/
b = a;
a = inWord (0x100); /*再次读取I/O空间0x100端口的内容存入a变量*/
c = a;

很可能被编译器优化为:

int a,b,c;
a = inWord(0x100); /*读取I/O空间0x100端口的内容存入a变量*/
b = a;
c = a;

但是这样的优化结果可能导致错误,如果 I/O 空间 0x100 端口的内容在执行第一次读操作后被其它程序写入新值,则其实第 2 次读操作读出的内容与第一次不同,b 和 c 的值应该不同。在变量 a 的定义前加上 volatile 关键字可以防止编译器的类似优化,正确的做法是:

volatile int a;

volatile 变量可能用于如下几种情况:

(1)并行设备的硬件寄存器(如:状态寄存器,例中的代码属于此类);

(2)一个中断服务子程序中会访问到的非自动变量(也就是全局变量);

(3)多线程应用中被几个任务共享的变量。

2.4 总结

本篇主要讲述了嵌入式系统C编程中内存操作的相关技巧。掌握并深入理解关于数据指针、函数指针、volatile关键字等的相关知识,是一个优秀的C语言程序设计师的基本要求。当我们已经牢固掌握了上述技巧后,我们就已经学会了C语言的 99%,因为C语言最精华的内涵皆在内存操作中体现。

我们之所以在嵌入式系统中使用 C 语言进行程序设计,99% 是因为其强大的内存操作能力!

posted @ 2018-11-23 21:00  fengMisaka  阅读(1179)  评论(0编辑  收藏  举报