第4章 C语言的嵌入式编程
本章首先通过编程语言的选择问题介绍C语言编程的优点,然后讨论C语言进行程序设计时涉及的一些问题,并简要介绍了Freescale公司的单片机开发工具——CodeWarrior的使用方法。
4.1 编程语言的选择
为了确定嵌入式系统合适的编程语言,需要了解以下问题:
① 计算机(如微控制器、微处理器或DSP芯片等)只接收“机器码”(即目标代码)指令。如果严格定义,机器码才是计算机的语言,而不是程序员使用的其他语言。但如果由程序员去解释机器码,则工作量是非常巨大的,而且也容易出错,是不可行的。
② 所有的软件,例如汇编语言、C语言、C++语言、Java语言等,为了能够被计算机执行,最终都必须翻译成机器码。
③ 嵌入式处理器的功能有限且内存有限,所以编程语言必须具有高效率。
④ 为嵌入式系统编程,经常需要对硬件进行底层访问操作,这意味着至少要能够读写特定的存储器地址。
当然,语言的选择问题还有一些并非技术方面的考虑:
① 如果每个项目开发都从头编写代码,显然软件程序员是不乐意的。编程语言必须能够支持创建灵活方便的库,这样同类的项目可以重用那些经过充分测试的代码模块。当使用新的处理器或升级处理器时,整个代码系统移植到新系统应该是可行的,并且工作量尽可能少。
② 语言的选择应该具有通用性。这样才能保证比较容易产生更多的有经验的开发人员,而且开发人员也容易获得相关设计实例以及编程实践信息。
③ 随着系统和处理器的不断升级,程序代码往往需要经常进行维护。好的程序代码应该是容易被理解的,而且并不仅仅容易被开发者理解,同时程序代码的维护、升级也应该非常便利。
基于上述原因,我们需要的编程语言应该是:效率高的高级语言,能够访问底层硬件,并且是良性定义的。同时,该语言也支持我们想要开发使用的平台。综合考虑这些因素,C语言是非常合适的。
可以总结C语言的特性如下:
① 它属于“中级语言”,不仅具有“高级语言”的特征(如支持函数和模块),还有“低级语言”的特性(可以通过指针访问硬件);
② 编程效率很高;
③ 十分流行且容易理解;
④ 即使是PC程序员,以前只使用过Java或C++语言,也能够很快理解C语言的语法和编程方法;
⑤ 每一个嵌入式处理器(从8位到32位或以上)都有良好且得到充分验证的C编译器;
⑥ 容易找到C语言编程经验的开发人员;
⑦ 容易找到有关资料、培训课程及相关网站等技术支持。
但是有很多程序员还是对汇编语言情有独钟。是不是因为C语言的存在,就不需要再使用汇编语言编写程序了呢?答案是否定的,因为还是有一部分程序必须使用汇编语言来编写的,以下是需要使用汇编语言编写程序的一部分情况说明。
系统的初始化,包括所有应用程序寄存器的初始化,各端口、各寄存器位在系统中的定义,栈指针的设置等,都需要使用汇编语言编写,以建立C语言程序运行的环境。
中断向量的初始化、中断服务的入口和出口、开关中断等,也需要使用汇编语言编写,而中断服务本身可以用C语言编写,而用汇编语言调用C语言程序运行的环境。
用汇编语言编写输入/输出口的输入/输出函数,在C语言程序中再调用这些函数。
通常可以用汇编语言编写与硬件有关部分的程序,用C语言编写与硬件无关部分的程序。如果同时使用了汇编语言和C语言编写程序,处理好两部分程序之间的参数传递是非常关键的。
对于单片机系统,与硬件相关部分的程序量不会很大,一般不会超过2 KB的机器码。如果整个应用程序大于4 KB,则使用C语言编写应用程序更合适。应用程序越大,使用高级语言的好处就越明显,不必担心C语言的效率或者运行速度问题。
4.2 C语言编程元素
4.2.1 全局变量和局部变量
变量是程序运行时在内存中存放数据的一个存储空间。对嵌入式系统来说,它是RAM或ROM(甚至是处理器的寄存器)上的存储单元。全局变量是为整个程序定义的,在程序运行中始终有效。用全局变量传递参数,是参数传递的常用方法。局部变量是在某个函数内部声明的变量,它只能被该函数访问。在嵌入式系统中,局部变量通常位于堆栈中。全局变量和局部变量的区别取决于在程序中的什么位置声明它。全局变量必须在函数外部声明,而局部变量则必须在一个函数内部声明。
由于程序是固化在ROM中的,而不是下载到RAM中的。除非在应用程序运行开始后向RAM中下载什么,RAM中的内容在开机时是随机的。这就要求在用C语言开发嵌入式应用程序时不要使用初始化变量。
当希望在多个源文件中共享变量时,需要确保定义和声明的一致性。最好的安排是在某个相关的.c文件中定义,然后在.h头文件中进行外部声明,在需要使用的时候,只要包含对应的头文件即可。定义变量的.c文件也应该包含该头文件,以便编译器检查定义和声明的一致性。
4.2.2 头文件
通常在一个程序的开始部分进行头文件包含操作。头文件通常包括常量定义、变量定义、宏定义和函数声明等,程序员可以在自己的程序中嵌入它们。内嵌库中最常见的头文件是标准输入/输出文件(stdio.h),该头文件包含用于输出信息和接收用户键盘输入的函数声明。在很多情况下,出于特定系统要求,程序员通常需要创建自己的头文件,并将它们包含在程序中。
要包含一个头文件,必须在程序的开始部分使用编译预处理指令#include,这会在4.2.3节中详细论述。
另外一个有关头文件的问题:可以在一个头文件中包含另一个头文件吗?这是一个风格问题,引发了不少的争论。它让相关定义更难找到,更主要的是,如果一个文件被包含的两次,它会导致重复定义错误。关于这个问题的解决,也会在4.2.3节中给出答案。
4.2.3 编译预处理
1.用于包含文件的#include指令
任何C程序首先都要包含那些准备使用的头文件和源文件,include是一个用于包含某个文件内容的预处理指令。以下给出可以被包含的文件:
● 包含代码文件:这些文件是已经存在的代码文件。
● 包含常量数据文件:这些文件是代码文件,可以有扩展名.const。
● 包含字符串数据文件:这些文件是包含字符串的文件,可以带扩展名.string、.str或者.txt。
● 包含初始数据文件:这些文件用于嵌入式系统掩模只读存储器的初始或默认数据,启动程序运行后会被复制到RAM当中,可以具有扩展名.init。
● 包含基本变量文件:这些文件是存储在RAM中的全局或者局部静态变量文件,因为它们不具有初始(默认的)值,所谓静态的意思是变量只有一个普通的变量地址实例,这些基本变量都被存储在以.bss为扩展名的文件中。
● 包含头文件:这是一个预处理指令,目的是要包含一组源文件的内容(代码或者数据)。它们都是某个特定模块的文件。头文件的扩展名为.h。
对于我们而言,include指令是最常用的包含头文件,其格式为
#include <stdio.h> #include <math.h> #include "myheaderfile.h"
第1行和第2行语句告诉编译器包含标准输入/输出库和数学函数的头文件。<>符号表示编译器在指定位置头文件。第3行的语句包含了自己创建的头文件myheaderfile.h," "表示在当前源文件所在目录下查找这个头文件。
2.宏定义#define指令
C语言中允许用一个标识符来表示一个字符串,称为宏。被定义为宏的标识符称为宏名。在编译预处理时,对程序中所有出现的宏名,都用宏定义中的字符串去代换,这称为宏代换或宏展开。宏定义是由源程序中的宏定义命令完成的,宏代换是由预处理程序自动完成的。宏分为有参数和无参数两种。
(1)无参宏定义
无参宏的宏名后不带参数。其定义的一般形式为
#define 标识符 字符串
其中,标识符为所定义的宏名;字符串可以是常数、表达式、格式串等。例如
#define HIGH 100 #define M (a+b)
第一句定义符号HIGH为常数100。第二句定义标识符M来代替表达式(a+b)。在编写源程序时,所有的(a+b)都可由M代替,而对源程序作编译时,将先由预处理程序进行宏代换,即用(a+b)表达式去置换所有的宏名M,然后再进行编译。
(2)带参宏定义
C语言允许宏带有参数。在宏定义中的参数称为形式参数,在宏调用中的参数称为实际参数。对带参数的宏,在调用中,不仅要宏展开,而且要用实参去代换形参。
带参宏定义的一般形式为
#define 宏名(形参表) 字符串
带参宏调用的一般形式为
宏名(形参表)
例如,程序1
#define M(y) y*y+3*y //宏定义 .... k=M(5); //宏调用
在宏调用时,用实参5去代替形参y,经预处理,宏展开后的语句为
k=5*5+3*5
程序2
#define MAX(a,b) (a>b)?a:b //宏定义
符号MAX是宏名称,而第二部分(a>b)?a:b定义了这个宏,可以在a和b中选择一个较大值作为输出结果。
对于宏定义再做以下几点说明:
① 宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单的代换,字符串中可以含任何字符,可以是常数,也可以是表达式,预处理程序对它不作任何检查。如有错误,只能在编译已被宏展开后的源程序时发现。
② 宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也会一起置换的。
③ 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束,如果要终止其作用域可使用#undef命令。
最后再提一点,关于typedef和#define的区别,有时用户对于这两者的使用会有些迷茫。typedef关键字尽管在语法上是一种存储类型,但正如其名称所示,它用来定义新的类型名称,而不是定义新的变量或函数。对于用户定义类型,这两者都可以使用,但一般来说,最好使用typedef,部分原因是它能正确处理指针类型。例如,考虑以下声明
typedef char *String_t; #define String_d char* String_t s1, s2 ,s3; String_d s4;
对于这个例子,s1、s2和s3都被定义成了char *,但s4却被定义成了char型。
3.条件编译指令
条件编译指令包括#if、#ifdef、#ifndef、#else、#elif和#endif。这些指令用于根据某个表达式有条件的编译一部分代码。可以仅在程序开发过程中利用这些指令来编译部分调试代码。
指令#if和#endif用于选择性地编译某段代码。#if后的表达式值为TURE或FALSE。如果是TRUE,#if和#endif之间的所有代码将被编译;否则,这些代码将被忽略。
#else和#elif指令可以用于更灵活的选择编译功能,它们也必须同#if和#endif一起使用。
例如编写的程序希望在多个嵌入式处理器平台上执行,就可以通过上述指令选择处理器中的某一个。
#define MC9S08 1 #define MC9S12 2 #define AT89C51 3 #define Processor 2 void main(void) { #if Processor == MC9S08 Instructions A #elif Processor = = MC9S12 Instructions B #elif Processor==AT89C51 Instructions C #else Instructions D #endif }
上述代码通过#if、#elif、#else和#endif定义了三种不同的嵌入式处理器,并选择了其中MC9S12。当判断出Processor的值是MC9S12,则“elif Processor == MC9S12”后面的指令被编译,器与的代码将被跳过。
还有两条指令#ifdef和#ifndef。#ifdef表示如果宏已经定义,则编译下面代码;#ifndef表示如果宏没有定义,则编译下面代码。前面提到的放置头文件被多次包含的问题,这里可以得到解决了。
#ifndef __X_H__ #define __X_H__ // … 头文件x.h的其余部分 #endif
这样每一个头文件都使用了一个独一无二的宏名,这令头文件可以自我识别,以便可以被安全地多次包含。
4.2.4 数据类型
数据命名后,就会在存储器中分配地址,地址分配取决于数据类型。以Freescale公司的CodeWarrior开发工具为例,其中定义的数据类型如表4.1所示。
表4.1 CodeWarrior中规定的基本数据类型
对于表4.1中的float和double两种类型的操作需要很多条指令,所以建议尽量避免在S12单片机中使用这两种类型。
除了上述几种基本数据类型外,C语言还有以下5种数据元素:
● array(数组):一组类型相同的数据元素;
● pointer(指针):存储某种数据类型的地址的变量;
● structure(结构):一组类型不同的数据元素;
● union(联合):由两种不同数据类型共享的存储元素;
● function(函数):函数本身也可以作为一种数据类型。
在声明变量的时候,可以规定变量的访问/存储类型,C语言有6个访问/存储关键字:extern、auto、static、register、const和volatile。
① extern说明该变量在另一个目标代码文件中声明和定义过。这些变量可以被所有函数访问。
② auto是默认的存储类型,在一个代码块内(或在一个函数头部作为参量)声明的变量,无论有没有访问/存储关键字auto,都属于自动存储类。该类具有自动存储时期、代码块的作用域和空链接,如果未初始化,它的值是不确定的。在S12单片机中,这种类型的变量存放在栈中。一旦某个函数(一段程序)结束任务,这些用auto声明的变量将从栈中清除,不再有效。另外,只有声明这种变量的函数才有权访问该变量。
③ static存储类型与auto类型类似,但它存储在RAM中而不是栈中,因此它在程序运行的整个过程中都有效。在C语言中,关键字static有三个明显的作用:在函数体,一个被声明为静态的变量在这一函数被调用过程中维持其值不变;在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外的函数访问,它是一个本地的全局变量;在模块内,一个被声明为静态的函数只可被这一模块内的其他函数调用,也就是说,这个函数被限制在声明它的模块的本地范围内使用。
④ register声明的变量表明要求编译器使用(如果可能)微处理器中的一个寄存器来存放该变量。使用微处理器的寄存器存储一个变量可以减少总线访问存储单元的时间、加速程序的运行。因此,若某个变量在程序中需要经常访问,可以考虑这种存储类型。
如果某个变量的值在程序运行中保持不变,则可以用const类型来声明它,该变量通常存放在ROM中。一个const变量必须由程序员初始化。有的程序员认为“const意味着常数”,这种说法其实有一些问题,有一种理解认为const意味着“只读”,这种理解更准确。下面说明以下几个声明的含义。
const int a; int const a; const int *a; int * const a; int const * a const;
前两个声明的作用是一样的,a是一个常整型数;第3个声明意味着a是一个指向常整型数的指针(也就是,整型数是不可修改的,但指针可以);第4个声明意味着a是一个指向整型数的常指针(也就是说指针指向的整型数是可以修改的,但指针是不可修改的);最后一个声明意味着a是一个指向常整型数的常指针(也就是说,指针指向的整型数是不可修改的,同时指针也是不可修改的)。
⑤ const有以下作用:关键字const的作用是为给读代码的人员传达非常有用的信息,实际上,声明一个参数为常量是为了说明这个参数的应用目的;为优化器提供一些附加的信息,使用关键字const也许能产生更紧凑的代码;合理地使用关键字const可以使编译器很自然地保护那些不希望被改变的参数,防止其被无意的代码修改,简而言之,这样可以减少bug的出现。有关const的所有用法,建议读者参考Dan Saks的文章“const T vs.T const”。
⑥ volatile访问类型表示它所声明的变量值在程序运行中可能不经过相关指令就发生变化。在S12单片机中,当某个变量的值被硬件输入端口改变时,这些变量应该用volatile声明。S12中端口的声明也用到了volatile,比如对PORTB的地址定义为
#define PORTB (*((volatile unsigned char *)(0x0001)))
一般来说,volatile用在如下几个地方:中断服务程序中可能会修改的供其他程序检测的变量需要加volatile、多任务环境下各任务间共享的标志应该加volatile、存储器映射的硬件寄存器通常也要加volatile,因为每次对它的读写都可能由不同含义。
下面结合volatile的讨论对之前提出的const是一个“只读”量给一个例子说明,比如S12单片机中某个只读的状态寄存器,它需要volatile限定词是有可能被意想不到地改变;而使用const是因为程序不应该试图去修改它。
4.2.5 运算符
C语言有很多种运算符,如表4.2所示,C语言的运算符可以分为5大类:通用运算符、算术运算符、逻辑运算符、位操作运算符和一元运算符。
表4.2 C语言运算符
毕竟本书不是关于C语言的教材,所以对于一些常用运算符,这里就不作解释了。本书主要对嵌入式程序设计中比较常用而普通C语言程序设计中又较少使用的位运算符作一简要说明。
嵌入式系统总是要用户对变量或寄存器进行位操作,而C语言的一个优势就是它可以对某个存储单元的内容进行位操作,如按位与、按位或和按位异或。移位运算符常用来将一个数乘以或除以2的幂运算。注意此处的除法是严格的整除而不是浮点除法。
如果希望将一个字节中某些位清0,而其他位保持不变,可以使用按位与运算。例如给定一个无符号字符型变量a,要求清除a的bit 3,可以使用下列语句。
a &= 0xF7;
如果希望将一个字节中的某些位置1,而其他位保持不变,可以使用按位或运算。例如给定一个无符号字符型变量a,要求置位a的第3位,可以使用下列语句。
a |= 0x08;
如果希望对某些位取反,而其他位保持不变,最好的方式是使用按位异或运算。例如给定一个无符号字符型变量a,要求将a的第7位取反,可以使用下列语句。
a ^= 0x80;
4.2.6 指针
指针是存放其他变量地址的变量。例如,一个字符型变量指针存放的是该字符变量的地址。声明一个指针变量的格式与声明一个变量的格式相同,只是在变量名前加一个*运算符。例如
unsigned char *a;
变量a被声明为一个存放无符号字符型变量的起始地址。指针可以存放各种类型变量的地址,如字符、字符数组、整型、整型数组、单精度浮点或双精度浮点等。由于指针存放的是一个变量的地址,所以必须确保这个指针存放的确实是一个内存地址。
前面已经举例说明过,在S12单片机中,如果需要处理输入或输出端口时,使用指针很方便,例如前面举过的例子:
#define PORTB (*((volatile unsigned char *)(0x0001)))
指针在C语言中的另一个重要应用是动态内存分配。动态内存分配与我们见到的其他内存分配方式不同,区别在于动态内存分配的存储单元在程序的运行过程中才确定。这些分配的内存通常来自RAM中未被使用的部分,我们称这一部分为堆。动态内存分配常常用在不知道RAM的大小又想充分利用RAM的资源的情况下。动态内存分配的两个主要函数是malloc()和free()。malloc()函数用于分配内存空间,而free()用于释放被分配的内存空间。两个函数的格式是
void *malloc(sizeof(variable)); void free(void *ptr);
malloc()函数返回一个可分配给任何指针变量的指针。如果申请的内存空间是可用的,返回申请空间的第一个存储单元的地址;如果申请失败,则返回空(NULL),所以一定要记住在使用任何指针前必须判断它是否为空。
在嵌入式系统中程序设计中,程序员经常面临者要求去访问某特定的内存位置的情况。此时可以利用指针方便的实现这个要求。例如,要求设置一绝对地址为0x67A9的整型变量的值为0xAA66。可以用下面的代码完成这一任务。
int *ptr; ptr = (int *)0x67A9; *ptr = 0xAA66;
4.2.7 条件语句、循环语句及无限循环语句
1.条件语句
条件语句在程序中会经常多次使用。如果某个定义的条件能够被满足,那么执行紧跟在条件语句之后的大括号内的语句(或者不带大括号的语句),否则程序会转到下一条语句或者转到另一组语句中执行。条件语句可以分为if语句和switch语句两大类。
if语句的一般形式为
if (<表达式>) { 语句体; }
执行过程为首先计算<表达式>的值,如果该表达式的值为非零(TRUE),则执行其后的if子句,然后去执行if语句后的下一个语句;如果该表达式的值为零(FLASE),则跳过if子句,直接执行if语句后的下一个语句。
if-else语句的格式为
if (<表达式>) { 语句体1; } else { 语句体2; }
执行过程为首先计算<表达式>的值,如果该表达式的值为非零(TRUE),则执行<语句体1>;如果该表达式的值为零(FLASE),则跳过<语句体1>,直接执行<语句体2>,两者执行其一后再去执行if语句后的下一个语句。
if-else if-else语句的格式为
if (<表达式1>) { 语句体1; } else if (<表达式2>) { 语句体2; } else { 语句体3; }
执行过程为首先计算<表达式1>的值,如果该表达式的值为非零(TRUE),则执行<语句体1>;如果该表达式的值为零(FLASE),则跳过<语句体1>,计算<表达式2>的值;如果该表达式的值为非零(TRUE),则执行<语句体2>;如果该表达式的值为零(FLASE),则跳过<语句体2>,直接执行<语句体3>,三者执行其一后再去执行if语句后的下一个语句。
条件语句还有一种语句是switch,其格式为
swtich (<表达式>) { case <常量表达式1>: <语句序列1>;break; case <常量表达式2>: <语句序列2>;break; case <常量表达式3>: <语句序列3>;break; …… case <常量表达式n>: <语句序列n>;break; [default:<语句序列n+1>] }
当根据某个条件判断有多个可执行的分支时,应使用switch结构。switch-case的执行效率相对if-else较高,因为switch语句会生成一个跳转表来指示实际的case分支的地址,而这个跳转表的索引号与switch变量的值是相等的。switch不用像if-else那样遍历条件分支直到命中条件,而只需访问对应索引号的表项从而达到定位分支的目的。总体上说,switch语句执行效率要高于同样条件下的if-else,特别是当条件分支较多时。但switch语句占用较多的代码空间,因为它要生成跳转表,特别是当case常量分布范围很大但实际有效值又比较少的情况,使得switch的空间利用率变得很低,还有就是switch语句只能处理case为常量的情况,对非常量的情况是无能为力的。例如,“if (a > 1 && a< 100)”是无法使用switch语句来处理的。
2.循环语句
C语言有三种不同的循环结构:for循环、while循环和do-while循环。
for循环的开头包含一条初始化语句、一个循环条件判断和一条更新语句。在更新语句后是一组指令组成的循环体,这组指令在循环条件满足之前重复执行。
for循环语句格式为
for (<初始化表达式>;<条件表达式>;<更新表达式>) { <循环体语句> }
while循环与for循环类似,都是重复执行循环体内的指令,但它在while后只有一个循环终止条件。
while循环语句格式为
while (<表达式>) { <循环体语句> }
do-while循环基本上和while循环完全一样,唯一的区别是do-while循环先执行循环体语句,再判断终止条件。所以即使<表达式>的值为零(FLASE),循环体语句还是会被执行一次。
do { <循环体语句> } while (<表达式>)
3.无限循环语句
使用C语言总是会被提醒无限循环是不希望发生的,因为这意味着程序永远都不会结束,但是无限循环是嵌入式系统编程的一个特征。无限循环的硬件等价于一个系统时钟(实时时钟)或者一个正在运行的空闲计时器。程序中的main()函数就是一个大循环,其间有对函数进行的调用以及对中断的处理等程序,而最终它必须回到起始处,系统主程序永远都不能出现停止状态。
实现无限循环可以使用以下几种方案。
方案1
while(1) { …… }
方案2
for ( ; ; ) { …… }
方案3
Loop: …… goto Loop;
既然这里提到了goto语句,就要再多说几句。有的人谈到goto语句总是觉得永远都不该使用它。对于goto语句,很早以前人们注意到随意使用goto语句会很容易导致难以维护的混乱代码。然而,不经思考就简单禁止goto的使用并不能立即得到优美的程序。一个无规则的程序员也许使用奇怪的嵌套循环和布尔变量来取代goto,一样能构造出复杂难懂的代码。所以说程序设计风格就像写作风格一样,不能被僵化的教条所束缚,虽然并不提倡使用像goto这样破坏程序可维护性的语句,但是有规划的编写程序代码更为重要。
4.2.8 函数
本节给出函数的定义,并介绍它们在C语言中的作用,并将讨论函数间参数的传递。
1.函数定义
函数是完成某个特定任务的一段独立代码,它必须具备三个特征:独立性、灵活性、可移植性。一个函数必须独立于程序的其他代码,因为函数可以被不同的用户调用。例如要求编写一个函数滤除输入信号中的噪声(过滤)。你的程序将会被许多不同的程序调用,需要从不同的信号中去除某种噪声。该函数必须独立于使用该函数的程序,并且必须足够灵活。如果编写的去除噪声的函数只能去除某个预先设定的频率段内的噪声,并且这个频率段范围无法改变,那么这个函数的价值就非常有限,尤其是与同频率范围用户指定的函数相比。最后,函数还必须是可移植的。C语言给工业界带来的革命就是提供可以在不同的硬件平台上使用同一段代码的能力,一旦某段代码编写完毕,只要有软件(编译器/汇编器)将其转换为相应的机器代码,它就应该能在不同的硬件上运行。要保证一个函数是可移植的,必须使这段代码与程序的其他部分无关,也就是第一条独立性的要求。
2.主程序
主程序也是一种函数,区别在于当程序名被调用时,这个函数首先被执行。主程序是程序执行的管理者,它包含程序的总体结构,通过调用其他不同的函数来处理、完成具体的任务,并因此避免亲自处理这些任务。可以把主函数想象成一个在其他函数的帮助下控制各种命令执行的管理者。在主程序中定义了多程序调用,调用它们循环顺序执行的模型如图4.1所示。
图4.1 主程序中多程序调用的程序模型
3.函数原型
函数在被调用前必须在程序的头部声明,这些声明称为函数原型。
函数声明的格式为
返回类型 函数名 ([变量类型1 变量名1], [变量类型2 变量名2], …… [变量类型n 变量名n]);
其中,变量名是可选的,需要注意的是函数声明最后必须有分号。以下给出几个函数声明的例子。
int compute(int,int); float change(char name,float number,int a); double find(unsigned int,float,double);
有时还会遇到以关键字extern作为函数声明的情况,例如
extern not_here(int a,int b,int c);
extern关键字通知编译器not_here函数的声明不再这个源文件中,而是在另一个外部的程序中。
一旦函数在源文件的头部声明过,就必须在同一个源文件或其他将要与该源文件链接的源文件(库)中定义它们,下面就讨论一下函数的定义。
4.函数定义
当某个函数在一个源文件的开始部分声明后,程序员必须在该源文件或其他伴随的源文件中定义这个函数。函数还可以在伴随的库文件中定义。函数定义可以在程序的任何地方进行,但通常都位于主程序后。除了末尾没有分号外,函数定义与函数声明的格式差不多。例如,假设前面声明的compute函数接受两个输入参数作为一个向量,计算该向量的长度并返回该长度值。该函数可以作如下定义:
int compute(int a,int b) { int sum,result; sum = a*a + b*b; result = (int)(sqrt(sum)); return(result); }
每个函数必须用正确的格式来定义:定义的开头是一条包含返回类型和参数的语句,中间是完成该函数功能的语句,最后是一条返回语句。如果返回类型是void,最后的返回语句可以没有。当然,严谨的程序员坚持认为在这种情况下也应该加一句“return( )”。
5.函数调用
在程序地任何地方,都可以用函数名和位于一对圆括号中的参数来调用某个函数。仍然以计算向量的长度为例,可以使用如下方式调用该函数。
magnitude = compute (12, 24);
6.函数间参数的传递
在函数的调用过程中,调用者,即函数的触发者可以向该函数传递多个参数。以上面一个例子作为参考,调用compute函数时,需要传递两个整型参数,因为该函数在定义和声明时就已经确定了两个参数。
如果单纯使用C语言编写程序,可以不必关心函数间参数是如何传递的。但是如果希望汇编语言编写的子程序和C语言编写的函数实现相互调用,则汇编语言编写的子程序与C语言编写的函数应该具有相同的格式,这样汇编语言程序就可以在C程序中被调用,同样汇编语言程序也可以调用C的函数。必须彻底弄清楚函数的结构和参数传递方法,才能使汇编语言编写的子程序符合C语言的函数格式,这里提供一种分析方法。
在C程序中,参数都是通过堆栈传递的,使用的C编译器不同,参数进入堆栈的顺序以及最后一个参数或第一个参数保存在什么地方也会有所不同,故与汇编语言程序的接口方式也会不同。在开发嵌入式应用程序中,因不可避免地会使用到汇编语言,故使用一个新的C交叉编译工具软件时,首先要搞清楚汇编语言程序和C程序之间是如何传递参数的。下面的例子是分析说明不同编译器是如何处理参数传递的,这里以Freescale公司的CodeWarrior编译器为例分析说明。
在C中定义以下函数
int Add(int a, int b, int c) { return(a+b+c); }
编译器生成如下汇编代码
PSHD LDD 6,SP ADDD 4,SP ADDD 0,SP PULX RTS
在C中调用上面定义的函数
d = Add(1, 3, 5);
编译器生成如下汇编代码
LDAB #1 CLRA PSHD LDAB #3 PSHD LDAB #5 BSR Add LEAS 4,SP
现在,如果用汇编语言写一个子程序,要求可以在C语言程序中调用,使用CodeWarrior交叉C编译器,归纳起来可以得到参数传递的以下规则。
(1)返回参数
对于函数返回的参数,相当于return(n)中的n值,如果n是一个单字节数据(char),则在B寄存器中,即D寄存器的低字节;如果n是一个双字节数据(int),在D寄存器中,低字节在B寄存器中,高字节在A寄存器中,对于返回值n是其他数据类型,则返回一个指向n的指针,也在D寄存器中。
(2)定义函数
定义函数时,如果只有一个形式参数,C程序会默认该参数已经放在D寄存器中,参数类型定义同上述返回参数;如果有两个以上的形式参数,最后一个参数(最右端的)在D中,可以以堆栈指针为基地址,加上偏移量寻址其他参数,计算偏移量时要多加2,目的是避开调用该函数时堆栈中保存的程序返回地址。左边第一个参数偏移量值最大,上例中程序调用时堆栈数据结构如图4.2所示。
图4.2 程序调用时堆栈数据结构
(3)调用函数(形式参数数目是固定的)
如果函数有固定数目的两个以上的形式参数,调用前,从第一个参数(最左端的)开始,从左至右逐个压入堆栈,留最后一个参数在D中,然后调用该函数。故在用汇编语言编写该函数时,存取这些参数,应该到堆栈区以SP为指针,以偏移量+2,+4,…,进行存取。
(4)调用函数(形式参数数目是不固定的)
如果函数的形式参数数目是不固定的,如printf()函数,括号内为形式参数表,此时调用函数参数的入栈顺序和(3)相反,编译器会从右向左将参数全部推入堆栈。故定义函数时,到堆栈中访问这些形式参数时,偏移量的顺序也和固定数目的参数情况相反。
注意:计算偏移量时要多加3,目的是避开调用过程中本身压入堆栈的子程序返回地址。可以模仿C交叉编译器,用SP寄存器间址;也可以用指令TSX将堆栈指针传给IX,用IX寄存器间址替代SP寄存器间址。
4.3 C程序编译器与交叉编译器
C语言程序设计需要两个编译器。一个编译器在主机上运行,编译器生成目标文件,编译器可以是Turbo C、Borland C 、C++、VC等高级语言,用于开发、设计、测试以及调试目标系统;另一个编译器是交叉编译器,交叉编译器也是在主机上运行的,但是它为目标系统生成机器代码,对于大多数的嵌入式系统微处理器和微控制器来说,目标系统多选择指定的或者商用的交叉编译器使用。主机往往同时运行一个提供完整开发环境的编译器和交叉编译器,这意味着可以在主机上仿真、调试、模拟目标系统。
图4.3给出了将汇编程序转换为机器码,最后得到一个ROM映像文件的过程。
图4.3 汇编程序转换ROM映像文件的过程
① 汇编器(Assembler)经过一种称为汇编(Assembling)的步骤,将汇编语言程序翻译为机器码。
② 下一个步骤称为链接(Linking),链接器(Linker)将这些代码与其他必要的汇编语言代码链接在一起。由于有多组代码需要链接在一起,形成最后的二进制文件,因此链接是很有必要的。例如,如果汇编语言程序中有一个对延迟任务的引用,就可以有标准代码来完成这个任务。延迟代码必须与汇编语言代码相链接,延迟代码从某个地址开始是连续的,汇编语言软件代码从某个地址开始也是连续的。两段代码都必须处于不同的地址上,这些地址还必须是系统中的可用地址,链接器将这两者链接在一起,链接后要在机器上运行的二进制文件通常称为可执行文件或者简称为“.exe”文件。链接就是将代码实际放入存储器之前,必须重新分配代码序列的排放过程。
③在下一个步骤中,当发现给定的立即数是一个物理RAM地址时,加载器(Loader)执行重新分配(Reallocating)代码的任务。加载器是操作系统的一部分,读取.exe文件之后将代码放置到存储器中。这个步骤也是很有必要的,因为可用的存储器地址可能不是从0x0000开始的。在运行过程中,二进制代码必须装载到不同的地址上,加载器能够找到适当的起始地址,也可以使用加载器将准备运行的程序装载到RAM中。
④ 系统设计过程的最后一步是将代码定位(Locating)为RAM映像,并将其永久存放到ROM可用的地址中。嵌入式系统不像计算机,有一个单独的程序可以跟踪运行过程中的可用地址。嵌入式系统开发者必须定义用于加载的可用地址,并创建用于永久定位代码的文件。定位器(Locator)程序将重新分配链接过的文件,并且以静态格式创建用于永久定位代码的文件。这种格式可以是Intel Hex文件格式或者Freescale的S-record格式。
⑤ 最后利用一个称为设备编程器的装置,将ROM映像文件作为输入,并最终将这个映像烧写(Burn)到PROM或者EPROM中,或者由半导体厂商在工厂里,将映像文件做成一个嵌入式系统ROM的掩模,根据映像创建的掩模使ROM成为IC芯片的形式。
图4.4给出了将一个C程序转换为ROM映像文件的过程。编译器(Compiler)产生目标代码。编译器根据处理器指令和其他说明对代码进行汇编。作为编译的最后一个步骤,嵌入式系统的C编译器必须使用代码优化器(Code-Optimizer)。优化器在链接之前,对代码进行优化。在编译之后,链接器(Linker)将目标代码与其他必要的代码链接在一起。例如,链接器将某些函数代码包含进来,如printf和sqrt代码。设备管理和驱动程序代码(设备控制代码)也是在这个阶段链接的,如打印机设备管理和驱动程序代码。链接之后,创建ROM映像文件的其他步骤与图4.3中所示的步骤相同。
图4.4 C程序转换ROM映像文件的过程
4.4 CodeWarrior软件简介
CodeWarrior集成开发环境(IDE)可以用于MC9S12单片机的程序开发,有效地提高软件开发效率。本节以CodeWarrior 4.6 for S12(X)版本为例简要介绍CodeWarrior的安装及使用方法,使读者初步掌握如何在CodeWarrior下用C语言进行程序设计。
4.4.1 CodeWarrior的安装
CodeWarrior 4.6 for S12(X)软件可以在Freescale公司网站下载,运行安装文件CW12_V4_6.exe后,开始解压安装文件,解压完毕后,可以根据图4.5至图4.12的界面,完成软件的安装。
图4.5 安装欢迎界面
图4.6 安装许可协议
图4.7 产品发布说明
图4.8 选择安装路径
图4.9 选择安装类型
图4.10 文件关联选择
图4.11 安装进度
图4.12 安装完成
软件安装完成后,可以通过选择“开始→程序→Freescale CodeWarrior→CW for HC12 V4.6→CodeWarrior IDE”运行软件,可以看到如图4.13所示的软件界面。
图4.13 CodeWarrior集成开发环境界面
4.4.2 CodeWarrior使用简介
下面简单介绍使用CodeWarrior集成开发环境建立工程,编写、调试程序的步骤及方法。
单击“File”菜单下的“New…”,可以看到如图4.14所示的界面。在左侧选择“HC(S)12 New Project Wizard”通过向导建立一个新工程,右侧的“Project Name”和“Location”分别定义工程的名称和工程存放位置。单击“OK”按钮,可以启动新建工程向导,如图4.15所示。
图4.14 “新建”界面图
图4.15 新建工程向导欢迎界面
单击“Next”按钮进入下一个设置界面,如图4.16所示。在其中选择MC9S12DG128B型号微控制器,之后单击“Next”按钮进入下一设置界面,如图4.17所示。
图4.16 选择微控制器型号
图4.17 选择编程语言
可以选择仅适用汇编语言或者仅适用C语言,也可以同时选择使用汇编语言和高级语言混合编程。这里选择C语言进行程序设计,单击“Next”按钮进入下一设置界面,如图4.18所示。
图4.18 选择是否使用处理器专家模式
这里可以选择是否使用处理器专家模式,处理器专家自动代码生成器将会极大的帮助开发者降低系统开发时间,提高代码质量,同时还方便开发者将应用代码移植到Freescale其他的微控制器上。本例中选择不使用处理器专家模式,单击“Next”按钮进入下一设置界面,如图4.19所示。
图4.19 选择是否使用PC-lint
这里可以选择是否使用PC-lint。PC-lint是一个历史悠久,功能异常强大的静态代码检测工具。经过这么多年的发展,它不但能够监测出许多语法逻辑上的隐患,而且还能够有效地提出许多程序在空间利用、运行效率上的改进点。下面简单归纳一下PC-lint的功能。
① PC-lint是一种静态代码检测工具,可以说,PC-lint是一种更加严格的编译器,不仅可以像普通编译器那样检查出一般的语法错误,还可以检查出那些虽然完全合乎语法要求,但很可能是潜在的、不易发现的错误。
② PC-lint不但可以检测单个文件,也可以从整个项目的角度来检测问题,因为C语言编译器采用固有的单个编译,有些问题在编译器环境下很难被检测,而PC-lint在检查当前文件的同时还会检查所有与之相关的文件,这会对开发者提供更大的帮助。
③ PC-lint a支持几乎所有流行的编辑环境和编译器。但是本例中选择不使用PC-lint。单击“Next”按钮进入下一设置界面,如图4.20所示。这里可以选择加载什么级别的启动代码,本例选择使用ANSI启动代码。单击“Next”按钮进入下一设置界面,如图4.21所示。
图4.20 选择使用什么级别的启动代码
图4.21 选择是否使用浮点运算
这里可以选择是否使用浮点运算及使用的浮点数类型。本例不使用浮点运算。单击“Next”按钮进入下一设置界面,如图4.22所示。这里可以选择存储器类型,本例选择默认类型。单击“Next”按钮进入下一设置界面,如图4.23所示。
图4.22 选择存储器类型
图4.23 选择调试器类型
这里可以选择调试器类型,如果仅作程序仿真而不需要通过其他手段将程序下载到微控制器中,可以只选择第一项。如果有BDM调试器,希望通过BDM调试器调试或者下载程序,应该选中第二项或者最后一项。单击“Finish”按钮完成新建工程工作,出现如图4.24所示的IDE界面。
图4.24 IDE界面
打开项目管理窗下“Sources”文件夹下的“main.c”文件,就可以进行程序编写工作了。项目管理窗一些按钮功能及文件的组织如图4.25所示。
图4.25 项目管理窗
当程序编写完成后,可以单击项目管理窗中工具栏中的调试(Debug)按钮,进入在线调试状态,如果此时有BDM调试器,就可以完成程序的下载和在线调试工作了。程序调试界面如图4.26所示。此时,可以对系统程序进行单步、断点、全速执行等调试工作,直至系统程序设计达到目标要求。
图4.26 程序调试界面