第3章 STM32程序设计
硬件逻辑被虚拟化成汇编语句,汇编语句再次被封装,虚拟化成高级语言语句;高级语言的语句再次被封装,形成一个特定目的的程序,或者称为函数,然后这些函数再通过互相调用,生成更复杂的函数,再将这些函数组合起来,就形成了最终的应用程序;程序再被操作系统虚拟成一个可执行文件。其实这个文件到了底层,就是逐次地对CPU电路的信号刺激。也就是说,硬件电路逻辑,一层层地被虚拟化,最终虚拟成一个程序,程序就是对底层电路作用的一种表达形式。按照与硬件虚拟化关系的远近,计算机程序设计语言分为机器语言、汇编语言和高级语言,它们的关系如图3-1所示。
图3-1 程序设计语言的关系
由于嵌入式系统自身的特点,不是所有的编程语言都适合于嵌入式软件的开发。汇编语言与硬件的关系非常密切,效率最高,但是使用起来不太方便,程序开发和维护的效率比较低。而C语言是一种“高级语言中的低级语言”,它既具有高级语言的特点,又比较接近于硬件,而且效率比较高。一般认为将汇编语言和C语言结合起来进行嵌入式系统软件设计是最佳选择。与硬件关系密切的程序或对性能有特殊要求的程序往往用汇编语言设计,上层应用软件则往往用C语言来设计。
3.1 嵌入式C语言知识精编
1. 关键字
关键字是一类具有固定名称和特定含义的特殊标志符,又称为保留字。在编写程序时一般不允许将关键字另作他用。
C语言的32个关键字见表3-1。
表3-1 C语言的32个关键字
1)const const是constant的缩写,是恒定不变的意思,也翻译为常量和常数等。很多人都认为被const修饰的值是常量,这是不精确的,精确说来应该是只读变量。在整个程序中,常数的作用就像变量一样,可以在使用变量的任何地方使用只读变量,但不能修改只读变量。在编程中有不变的数据时,最适合使用const关键字,数学上的π是一个很好的只读变量示例。例如:
const float Pi=3.14;
声明时必须为只读变量赋一个初始值,因为在程序中不能改变只读变量值。
如果不定义只读变量的数据类型,则默认为整型,因此如下两条语句等价:
const int Number of group=5; const Number of group=5;
只读变量在定义时,const用在类型说明符前后是等价的,因此如下两条语句等价:
const int Number of group=5; int const Number of group=5;
const也可以修饰函数的参数,当不希望这个参数值在函数体内被意外改变时使用,例如:
void Fun(const int I);
在声明只读变量时请使用大写字母,以区别于通常的变量。
2)typedef typedef是给一个已经存在的数据类型(注意是类型而不是变量)取一个别名,而非定义一个新的数据类型。typedef格式如下:
〖格式〗 typedef类型名1类型名2;
其中:类型名1可以是基本类型名,也可以是数组、用户自定义的结构型、共用型等。类型名2是用户自选的一个标志符,作为新的类型名。
〖功能〗 将类型名1定义成用户自选的类型名2,此后可用类型名2来定义相应类型的变量、数组、指针变量、结构型、共用型、函数的数据类型。
〖说明〗 为了突出用户自己的类型名,通常都选用大写字母组成用户类型名。
下面按照类型名1的不同,分4种情况介绍自定义类型的方法和使用。
(1)基本类型的自定义。对所有系统默认的基本类型可以利用下面的自定义类型语句来重新定义类型名。
〖格式〗 typedef基本类型说明符 用户类型名
〖功能〗 将基本类型说明符定义为用户自己的“用户类型名”。
【例3-1】 基本类型自定义实例。
typedef float REAL; //定义单精度型为REAL typedef char CHARACTER; //定义字符型为CHARACTER main() { REAL f1; //相当于float f1 CHARACTER c1; //相当于char c1 }
【例3-2】 基本类型自定义实例。
typedef unsigned long INT32U; //定义一个32位无符号整型 typedef unsigned int INT16U; //定义一个16位无符号整型 typedef unsigned char INT8U; //定义一个8位无符号整型 main() { INT32U Int; INT8U chC; //定义一个无符号字符型变量 }
在STM32的项目文件stm32 f10 x type.h利用typedef定义了许多数据类型:
/* Exported types ------------------------------------------------------------*/ typedef signed long s32; typedef signed short s16; typedef signed char s8; typedef signed long const sc32; /* Read Only */ typedef signed short const sc16; /* Read Only */ typedef signed char const sc8; /* Read Only */ typedef volatile signed long vs32; typedef volatile signed short vs16; typedef volatile signed char vs8; typedef volatile signed long const vsc32; /* Read Only */ typedef volatile signed short const vsc16; /* Read Only */ typedef volatile signed char const vsc8; /* Read Only */ typedef unsigned long u32; typedef unsigned short u16; typedef unsigned char u8; typedef unsigned long const uc32; /* Read Only */ typedef unsigned short const uc16; /* Read Only */ typedef unsigned char const uc8; /* Read Only */ typedef volatile unsigned long vu32; typedef volatile unsigned short vu16; typedef volatile unsigned char vu8; typedef volatile unsigned long const vuc32; /* Read Only */ typedef volatile unsigned short const vuc16; /* Read Only */ typedef volatile unsigned char const vuc8; /* Read Only */ typedef enum {FALSE=0,TRUE=!FALSE} bool; typedef enum {RESET=0,SET=!RESET} FlagStatus,ITStatus; typedef enum {DISABLE=0,ENABLE=!DISABLE} FunctionalState; #define IS FUNCTIONAL STATE(STATE)(((STATE)== DISABLE)||((STATE)== ENABLE)) typedef enum {ERROR=0,SUCCESS=!ERROR} ErrorStatus;
(2)数组类型的自定义。
〖格式〗 typedef类型说明符 用户类型名 [数组长度]
〖功能〗 定义数组用户类型名。
【例3-3】 数组类型自定义实例。
typedef float F-ARRAY[20]; //定义F-ARRAY为单精度型长度为20的数组类型说明符 typedef char C-ARRAY[20]; //定义C-ARRAY为字符型长度为20的数组类型说明符 main() { F-ARRAY f1,f2; //相当于float f1[20],f2[20] C-ARRAY name; //相当于char name[20] }
(3)结构型、共用型的自定义。
〖格式〗 typedef struct
{ 类型说明符 成员名1; 类型说明符 成员名2; … 类型说明符 成员名n; }用户类型名;
〖功能〗 定义结构型用户类型名。
【例3-4】 结构型自定义实例。
typedef struct { long num; char name[10]; char sex; }STUDENT; main() { STUDENT stu1,stu[20]; }
主函数中的定义等价于:
struct { long num; char name[10]; char sex; } stu1,stu[20];
共用型自定义方法和上面介绍的结构型自定义方法基本相同。
(4)指针类型的自定义。
〖格式〗 typedef类型说明符 *用户类型名
〖功能〗 定义指针用户类型名。
【例3-5】 指针类型自定义实例。
typedef int *POINT-1; //定义POINT-1为整型指针的新类型说明符 typedef char *POINT-C; //定义POINT-C为字符型指针的新类型说明符 main() { POINT-1 p1,p2; //相当于int *p1,*p2 POINT-C p3,p4; //相当于char *p3,*p4 }
3)volatile 在嵌入式系统中,volatile大量地用于描述一个对应于内存映射的I/O端口,或者硬件寄存器(如状态寄存器)。编译器优化时,在用到volatile变量时必须每次都重新读取这个变量的值,即每次读/写都必须访问实际地址存储器的内容,而不是使用保存在寄存器中的副本(因为从处理器中的寄存器取数据要比实际存储器地址取数据快,因此没用vol-atile定义的变量放到寄存器中)。在中断服务程序中使用的非自动变量,或者多线程应用程序中多个任务共享的变量也必须使用volatile进行限定。
【例3-6】 使用volatile限定变量实例。
int flag=0; void f() { while(1) { if(flag) some action(); } } void isr f() { flag=1; }
如果没有使用volatile限定flag变量,编译器看到在f()函数中并没有修改flag,可能只执行一次flag读操作并将flag的值缓存在寄存器中,以后每次访问flag(读操作)都使用寄存器中的缓存值而不进行存储器绝对地址访问,导致some action()函数永远无法执行,即使中断函数isr f()执行了将flag置1。
2. 符号
标准C语言的基本符号见表3-2。
表3-2 标准C语言的基本符号
在ARM系统中,对于引脚的处理已经不是简单意义上的位处理,而是用了多个寄存器对其进行控制。要控制每一个引脚,必须借助于左移(<<)、右移(>>)和位逻辑(&、|、^、~)等运算符。有了这些运算符才能真正地完成将要完成的工作。
【例3-7】 STM32的GPIOA口有16个引脚,要想将它的第15个引脚变为低电平,一种办法是对其进行&操作:
GPIOA= GPIOA&0X7FFF;
还有一种易于理解的办法:
unsigned long nlBit,GPIOA; nlBit=1 <<15; / /左移15位,移后二进制码1000000000000000B nlBit=~nlBit; / /取反,0111111111111111B GPIOA=GPIOA& nlBit;
还有1个常用的符号逻辑或(|),它与加号功能类似。
【例3-8】 将GPIOA的14和15位清0,处理代码如下:
unsigned long nl GPIOA14 =1 <<14; / / 0100000000000000B unsigned long nl GPIOA15 =1 <<15; / /1000000000000000B GPIOA14 | =GPIOA15; / /1100000000000000B GPIOA14 =~GPIOA14; GPIOA&= GPIOA14;
【例3-9】 使某数最低位为0。
/* 16位机 */ int a; a=a&0xFFFE /* 32位机 */ a=a&0xFFFFFFFE
上述指令使程序可移植性变差,可修改为:
a=a&~1 /* ~1能自动适应16位机及32位机 */
【例3-10】 不通过中间变量交换两个变量的值(利用异或符号^)。
/* ch3 7.c*/ #include <stdio.h> int main() { int a=21; int b=43; a=a^b; b=b^a; a=a^b; printf("a=%d,b=%d\n",a,b); return 0; }
3. 预处理
预处理命令对源程序编译前做一些处理,生成扩展C语言源程序,它必须放在程序的开始,所有的命令都以“#”为前缀。
1)常用的预处理命令 常用的预处理命令见表3-3。
表3-3 常用的预处理命令
2)#define命令 #define定义数值宏常量,例如:
#define PI 3.141592654
在此后的代码中可以使用PI代替3.141592654,若要把PI的精度再提高一些,只要修改这句宏指令就可以了。#undef是用于撤销宏定义的,用法如下:
#define PI 3.141592654 …… //code #undef PI //下面代码就不能用PI了,它已经被撤销了宏定义。
#define还有许多拓展用法,例如:
#define ABC(x,y)x + y #define Delay(nN)while(——nN);//一个延时程序定义成功
在嵌入式系统中,经常对外设某个地址进行访问,也可以用到#define,例如:
#define HWREG(x)(*((volatile unsigned long *)(x))) //32位地址 #define HWREGH(x)(*((volatile unsigned short *)(x))) //16位地址 #define HWREGB(x)(*((volatile unsigned char *)(x))) //8位地址
在程序中应用如下:
unsigned long temp32; HWREG(0x20000000)=0xAA55AA55; //向0x20000000地址写入0xAA55AA55 temp32=HWREG(0x20000000); //读0x20000000地址内容 if(temp32!=0xAA55AA55) //比较写入与读出的内容是否一致 {……}
在这一例中不难发现,指针与常数定义得很巧妙,仔细分析(*((volatile unsigned long*)(x))),前后两个“*”号的出现,其左侧“*”号是取值符,右侧“*”号是指针说明符,指明的是x的地址。如果在80 C51中C语言与汇编混合编程,可以采用上述方法进行参数传递。例如:
//用汇编向45H的地址中写入#99H …… MOV 45H,#99H …… …… //用C语言读出 Char chChar; chChar=HWREGB(0x45); (*((volatile unsigned long *)(x)))用了多层括号,实际上括号在#define中的作用是很大的。
【例3-11】 定义一年有多少秒。
#define SEC A YEAR 60*60*24*365
特例:若在16位系统下把这样一个数赋给整型变量可能会溢出。
修改:
#define SEC A YEAR 60*60*24*365UL
UL是整数文字量的后缀修饰,表示unsigned long int变量。注意,若用GCC编译程序,上述语句不能写成:
#define SEC A YEAR(60*60*24*365)UL//错误
系统将提示这个错误error:expected ‘,’ or ‘;’ before ‘UL’;是因为UL写在了括号外面,无法与数据进行匹配。
【例3-12】 定义一个宏函数,求x的平方。
#define SQR(x)x*x
特例:x=10+1,则SQR(x)=10+1*10+1,不是需要的结果
修改:#define SQR(x)((x)*(x))
【例3-13】 求两个数的和。
#define SUM(x)(x)+(x)
特例:若x=5*3,进行SUM(x)* SUM(x)的运算,则:
SUM(x)* SUM(x)=(5*3)+(5*3)*(5*3)+(5*3)
这又是不需要的结果。
修改:#define SUM(x)((x)+(x))
3)条件编译 条件编译可以按不同的条件去编译不同的程序部分,因而产生不同的目标代码。这对于程序的移植和调试是很有用的。
(1)条件编译命令的第1种格式。
〖格式〗
#if条件 程序段1 #else 程序段2 #endif
其中的条件是常量表达式。若其值为非0,则条件成立;否则条件不成立。如果没有程序段2,本格式中的#else可以没有,即写成:
#if条件 程序段1 #endif
〖功能〗 在编译预处理时,判定条件是否成立。条件成立,则编译程序段1,不编译程序段2;条件不成立,则不编译程序段1,编译程序段2。
〖说明〗 命令中的“条件”通常是一个符号常量,利用定义该符号常量所给定的值来确定条件是否成立。
【例3-14】 编写一个程序,输入5个整数,利用条件编译使该程序可以求最大整数,也可以求最小整数。
# define N 5 //定义宏名N为5 #define FLAG 1 //定义宏名FLAG,用来作为控制条件编译的条件 main() { Int a[N],n,*p; p=a; while(p<a+N) //输入N个整数存入数组a scanf("%d",*p++); p=a; //让指针变量p指向数组a的首地址 #if FLAG m=*p; //求最大数的程序段 while(p<a+N) if(m<*p)m=*p; printf("max=%d\n",m); #else //若宏名FLAG的值为0,则编译下面程序段 m=*p; //求最小数的程序段 while(p<a+N) if(m>*p)m=*p; printf("min=%d\n",m); #endif }
上述程序在预编译时,由于开始定义的符号常量“FLAG”的值为1(非0),通过宏替换和条件编译后,使得被编译的程序清单如下:
main() { int a[5],m,*p; p=a; while(p<a+5) //输入N个整数存入数组a scanf("%d",p++); p=a; //让指针变量p指向数组a的首地址 m=*p; //求最大数 while(++p<a+5)if(m<*p)m=*p; printf("max=%d\n",m); }
如果将源程序清单中的宏定义“#define FLAG 0”,再编译这个程序,则预编译后的源程序清单将是一个求5个数中最小数的程序。
(2)条件编译命令的第2种格式。
〖格式〗
#ifdef宏名 程序段1 #else 程序段2 #endif
其中的宏名是标志符,可以是前面已定义过的宏名,也可以是前面没有定义过的宏名。
〖功能〗 在编译预处理时,判定宏名是否在前面已定义过。若前面已定义过,则编译程序段1,不编译程序段2;若前面没有定义,则不编译程序段1,编译程序段2。
〖说明〗 命令中的#else及其后的程序段2可以省略。省略时,若在前面宏名已定义,则编译程序段1,宏名未定义,则不编译程序段1。
(3)条件编译命令的第3种格式。
〖格式〗
#ifndef宏名 程序段1 #else 程序段2 #endif
其中的宏名是标志符,可以是前面已定义过的宏名,也可以是前面没有定义过的宏名。
〖功能〗 在编译预处理时,判定宏名是否在前面已定义过。若前面没有定义,则编译程序段1,不编译程序段2;若前面已定义过,则不编译程序段1,编译程序段2。
〖说明〗 命令中的#else及其后的程序段2可以省略。省略时,若在前面宏名没有定义,则编译程序段1,宏名已定义,则不编译程序段1。
从条件编译命令的第2种和第3种格式来看,其作用与第1种格式基本相同,唯一不同的是采用某个宏名是否有定义作为是否编译的判定条件。
4)#error命令 #error指令强制编译器停止编译,主要用于程序调试。
#error指令的一般形式为:
#error error-message
编译到#error时,会显示相应字符串。
#error举例:
#define CON10 #define CON21 #define CON3-1 int main() { #if CON1 #if CON2 #error run to position1 #else #error run to position2 #endif #else #if CON3 #error run to position3 #else #error run to position4 #endif #endif }
5)#include命令 #include文件包含是C预处理程序的另一个重要功能。文件包含命令的功能是把定义的文件插入该命令行位置取代该命令行,从而把指定的文件和当前的源程序文件连成一个源文件。
在程序设计中,一个大的程序可以分成多个模块,由多个程序员分别编程。有些公用的符号常量或宏定义等可单独组成一个文件,在其他文件的开头用包含命令包含该文件即可使用。这样,可避免在每个文件开头都去书写那些公用量,从而节省时间,并减少出错。
#include命令中的文件名可以用双引号(“”)括起来,也可以用尖括号(< >)括起来。使用尖括号表示在包含安装软件的目录中(这个目录是安装软件时,设置的安装路径),一般是安装路径(如C:\Keil MDK***)下的Include目录。使用双引号则表示首先在当前的源文件目录中查找,若未找到才到包含安装软件的目录中查找。用户编程时,可根据自己文件所在的目录来选择某一种命令形式。一个include命令只能指定一个被包含文件,若有多个文件要包含,则需要多个include命令。文件包含允许嵌套,即在一个被包含的文件中又可以包含另一个文件。
6)带参数的宏与函数区别 带参数的宏与函数区别见表3-4。
表3-4 带参数的宏与函数区别
4. 指针和数组
指针确切地说就是地址,对指针的操作就是对地址的操作。可以把指针前的“*”理解成防盗门的钥匙。当你站到家门口时,想进屋的第一件事就是拿出钥匙来开锁,试想防盗门的锁芯是不是很像这个“*”号呢?要进屋必须用钥匙,要读/写一块内存也要一把钥匙,“*”就是指针读/写某块内存的钥匙。在8051汇编指令系统的学习中,所有操作码都是指向寄存器地址的。例如:
MOV R0,#30H ;立即数寻址 对R0进行一般操作: MOVA,R0 ;寄存器寻址
还可以进行操作:
MOVA,@R0 ;寄存器间接寻址
类似指针的操作:
INC R0 MOV @R0,A ;R0非常类似C语言的指针
而C语言中的指针完成的就是汇编中DPTR、R0、R1等寄存器的间接寻址任务。例如:
unsigned char *lpuchR0; lpuchR0=0x30; lpuchR0++; *lpuchR0=0xDF;
上述程序第3句和如下语句等价:
*lpuchR0++ //这里的*没有任何意义,*加与不加没有区别
以上实例表明,指针的本质就是内存地址,所以利用指针可以将数值存储到指定的内存地址,例如:
int *p=(int *)0x12ff7c; *p=0x100; //将0x100存入内存地址0x12ff7c
上述语句也等价于:
*(int *)0x12ff7c=0x100;
注意,将地址0x12ff7c赋给指针变量p时,必须强制转换。
很多初学者时常混淆指针和数组,二者的比较见表3-5。
表3-5 指针和数组的区别
指针和数组混用的实例如下:
unsigned char *lpuchR1; unsigned char chString[] ={"A","B","C","D","E","F"};
要将A、B、C、D、E、F几个字母传给lpuchR1指针,程序如下:
lpuchR1=chString;
如果要将数组0元素中的内容A传递给lpuchR1指针,代码如下:
lpuchR1=&chString[0];
“&”为取地址符号,只有数组名本身带有地址,其他变量名一定要在其前加上“&”取地址符号才可以通过。
5. 函数指针
每一个函数模块都有一个首地址,称为函数的入口地址。指向函数的指针称为函数指针,保存的是函数的入口地址(首地址)。
〖格式〗 int(*ptr1)(int);
〖功能〗 定义了函数指针ptr1,此指针只能保存具有一个整型参数的首地址。
【例3-15】
int *ptr1(int); //函数声明语句,表示一个带有整型参数的函数返回,返回一个整型指针 int(*ptr1)(int); //函数指针
若要函数调用,需要找到函数入口地址,然后传递参数。
【例3-16】 以0.1为步长,计算特定范围内的三角函数之和。
(1)sin(0.1)+sin(0.2)+…+sin(1.0)。
(2)cos(0.5)+cos(0.6)+…+cos(3.0)。程序如下:
#include <stdio.h> #include <math.h> double triangle(double(*func)(double),double begin,double end) { double step,sum=0.0; for(step=begin;step<end;step+=0.1) sum+=func(step); return sum; } int main() { double result; result=triangle(sin,0.1,1.0); printf("the sum of sin from 0.1 to 1.0 is%f\n",result); result=triangle(cos,0.5,3.0); printf("the sum of cos from 0.5 to 3.0 is%f\n",result); return 0; }
在嵌入式操作系统中,经常用函数指针来完成任务的调度。例如,在μC/OSⅡ中,任务创建的函数原型为:
INT8U OSTaskCreate(void(*task)(void*pd),void *pdata,OS STK *ptos,INT8U prio);
第一个参数为函数指针。
6. assert
〖格式〗 assert(expression)
〖功能〗 计算表达式expression的值,如果为0,那么首先向stderr打印一条出错信息,然后调用abort()函数终止程序运行。
【例3-17】
/*ch3 16.c*/ #include<stdio.h> #include <assert.h> #include<stdlib.h> int main() { char *p; p=getenv("HOME"); assert(p); printf("HOME=%s\n",p); p=getenv("NOTEXIST"); assert(p); printf("NOTEXIST=%s\n",p); return 0; }
assert的调用会影响程序的运行效率,有时希望程序中的assert不会起作用,这可以通过在程序前部包含assert.h前定义一个NDEBUG来实现:
#include<stdio.h> #define NDEBUG #include<assert.h>
7. 数据结构
从数据结构的角度看,常量不灵活,为了弥补常量的缺陷就引进了变量,变量可以保存“变化的常量”。但是,如果变量多了,管理起来也比较麻烦,所以数组就出现了。但是,数组有个缺点,就是只能把同类型的变量捆绑在一起,如果不同类呢?而往往外界的一个事物经常有不同的属性,因此需要为这个事物定义多个不同类型的数组,这就显得非常散乱了。此时结构体被引进了。结构体能把不同的数据类型的属性捆绑在一起,能更加紧凑地表示外界的一个事物,然而外界事物不一定只能是属性,所以描述对象个体就需要包含函数。即需要有一种类型,这种类型不仅要有属性,还要有函数。这种类型就是类。类的出现,能真正完美地表达了外界的事物。这就是面向对象的优势:一个类能完整地描述外界的一个事物,所以一个事物就是一个类中的实例,也就是对象。这就从C语言进入了C++和Java的领域。
8. 易于移植的程序
方便移植的程序称为可移植程序。程序不可移植,主要是因为有太多依赖硬件的代码,如表示硬盘缓冲区的大小、屏幕和键盘的特定尺寸等数字。下列代码本质上是不可移植的:
fread(buf,256,1,fp); //缓冲区为256B
程序中要尽量避免与硬件直接相关的代码,可以用宏来取代与硬件相关的数,使可读性增强,而且移植程序时只要修改宏一处即可:
#define BUFFER SIZE 256 fread(buf,BUFFER SIZE,1,fp);
还可以写成:
fread(buf,sizeof(int),1,fp);
【例3-18】 一个图形处理程序中,需要不同的颜色执行不同操作。
/*不利于移植*/ void ShowColor(int color) { if(color==0) sub red(); else if(color==1) sub blue(); else if(color==2) sub green(); return; }
改进后的程序:
/* color.h*/ #define RED 0 #define BLUE 1 #define GREEN 2 /*然后在源文件中直接使用这些宏来判断*/ #include "color.h" void ShowColor(int color) { if(color==RED) sub red(); else if(color==BLUE) sub blue(); else if(color==GREEN) sub green(); return; }
3.2 嵌入式软件层次结构
1. 嵌入式系统程序设计的层次性
低级语言(如机器语言、汇编语言)依赖硬件,不具备可移植性及通用性,其实硬件对语言也是有依赖性的,例如,不同档次、不同品牌的PC存在硬件上的差异,但是BIOS及DOS功能调用掩盖了这种硬件上的差异。BIOS、DOS功能调用程序为系统程序,它们介于系统硬件和用户程序之间,是系统的必备部分。它们除了掩盖系统硬件差异外,还屏蔽了烦琐、复杂的硬件具体操作控制。PC用户程序对I/O的操作是通过中断调用完成的,用户程序中并不包含对硬件的直接驱动,这使得用户程序在一定程度上独立于硬件系统。简化了用户程序,使用户系统易于维护及修改。PC汇编程序的设计方法实际上研究的就是用户程序设计方法。图3-2所示的是PC系统体系结构图,图3-3所示的是嵌入式系统软件结构图。和PC不同,嵌入式系统用户程序直接建立在系统硬件上,并完成对硬件的直接控制,包括复杂、烦琐的I/O控制。嵌入式系统一个主要优点就在于系统的灵活配置,因此在实际应用中,嵌入式系统随着具体情况不同而千差万别。
图3-2 PC体系结构
图3-3 嵌入式系统体系结构
嵌入式系统程序设计方法,应是对PC程序设计方法的继承和发展。PC的程序设计方法具有层次性。嵌入式系统程序设计也应具备这一特性,但它又不等同于PC的层次性,而是PC的延伸。图3-4给出了改进的嵌入式系统体系结构,用户程序划分为3个层次(按与硬件的距离划分):底层是虚拟BIOS子程序层;第二层为虚拟DOS子程序层;上层是高端用户程序层。各个层面相对独立,高层程序可以调用低层子程序。改进后的体系结构与PC的体系结构是相似的。实际上这一结构正是借鉴了PC的体系结构。
图3-4 改进的嵌入式系统体系结构
PC中的DOS功能调用、BIOS功能调用是系统的一部分,完全独立于用户程序。而嵌入式系统则不同。虚拟BIOS、DOS子程序层是用户程序中底层的靠近硬件的部分,其子程序从功能设计到维护修改,都是由程序设计者根据实际情况完成的。正因为它们不是独立的,因此称为虚拟的。从作用看,它们与PC的BIOS、DOS功能调用是一样的,一方面完成复杂琐碎的硬件控制,使高端用户程序变得简洁明了,另一方面完成了对硬件的屏蔽,使高端用户程序不必直接作用于硬件,从而增强通用性和可移植性,使用户程序的修改和维护变得简单。嵌入式系统的虚拟层功能如下所述。
1)虚拟BIOS 虚拟BIOS子程序完成对I/O口的基本控制,完成一次或几次I/O读/写。编写虚拟BIOS模块程序的要求如下所述。
(1)尽可能将驱动模块所有功能归纳为几个函数来实现(越少越好),清晰明了;
(2)传递数据的全局变量尽量少用;
(3)驱动模块程序尽可能占用少的系统资源,并应在注释中详细说明;
(4)驱动模块程序函数接口简单,要有详细的使用说明;
(5)驱动模块程序之间不能相互调用。
2)虚拟DOS 虚拟DOS子程序实现某个基本功能,这个功能可分解为几次或几十次的I/O操作。
3)高端用户程序中的子程序 高端用户程序中的子程序用于实现某些基本功能,它最终分解为几十、几百次的I/O操作。
嵌入式系统软件结构的层次划分可以借鉴PC的程序设计方法,分别设计嵌入式系统高端用户程序及虚拟层的子程序,其设计步骤如下所述。
(1)确认程序需完成的任务;
(2)分解任务,画出层次图;
(3)确切地定义每个任务及如何与其他任务进行通信,写出模块说明;
(4)完成每个任务的程序模块,并进行调试;
(5)把模块连接起来,完成统调。
总之,嵌入式系统用户程序设计包括虚拟层子程序设计和高端用户程序两部分,设计过程中,两部分交叉进行。虽然嵌入式系统程序的分层设计思想使整个用户程序结构变得复杂,但却简化了程序(尤其是大规模程序)的整体设计、修改和维护工作。
2. 程序调试、修改、移植
由于采用层次结构,这使得原来整个调试工作变为在3个层面上且相互独立进行的调试过程,这样就降低了调试工作的复杂度和难度。调试次序依次为虚拟BIOS层子程序,虚拟DOS层子程序,用户程序。
层次结构同样使修改工作变得简单。当系统硬件重新设计、I/O地址重新分配或系统功能调整时,并不需要重写整个程序,根据具体情况,修改相应部分即可。如果重新设计硬件而系统功能没有变化时,主要修改相应虚拟BIOS、DOS层子程序,而高端用户程序不变;如果系统硬件不变,而系统功能发生变化时,相应地修改高端用户程序即可。
采用层次结构,使移植在一定程度上成为可能。之所以说“一定程度上”,是指当移植发生时,程序要做部分修改。
3.3 Cortex微控制器软件接口标准
根据调查研究,软件开发已经被嵌入式行业公认为最主要的开发成本。因此,ARM与Atmel、IAR、Keil、Hami-nary Micro、Micrium、NXP、SEGGER和ST等诸多芯片和软件厂商合作,将所有Cortex芯片厂商产品的软件接口标准化,在2008年11月12日发布了CMSIS(Cortex Microcon-troller Software Interface Standard)标准。此举意在降低软件开发成本,尤其针对新设备项目开发,或者将已有软件移植到其他芯片厂商提供的基于Cortex处理器的微控制器的情况。有了该标准,芯片厂商就能够将其资源专注于产品外设特性的差异化,并且消除对微控制器进行编程时需要维持的不同的、互相不兼容的标准的需求,从而达到降低开发成本的目的。
CMSIS是独立于供应商的Cortex-M处理器系列硬件抽象层,为芯片厂商和中间件供应商提供了连续的、简单的处理器软件接口,简化了软件复用,降低了Cortex-M3上操作系统的移植难度,并缩短了新入门的微控制器开发者的学习时间和新产品的上市时间。
1. 基于CMSIS标准的软件架构
如图3-5所示,基于CMSIS标准的软件架构主要分为以下4层:用户应用层、操作系统及中间件接口层、CMSIS层、硬件寄存器层(MCU)。其中,CMSIS层起着承上启下的作用:一方面该层对硬件寄存器层进行统一实现,屏蔽了不同厂商对Cortex-M系列微处理器核内外设寄存器的不同定义;另一方面又向上层的操作系统及中间件接口层和应用层提供接口,简化了应用程序开发难度,使开发人员能够在完全透明的情况下进行应用程序开发。也正因如此,CMSIS层的实现相对复杂。
图3-5 基于CMSIS标准的软件架构
CMSIS层主要分为3部分。
1)核内外设访问层(CPAL) 由ARM公司负责实现。包括对寄存器地址的定义,对核寄存器、NVIC、调试子系统的访问接口定义,以及对特殊用途寄存器的访问接口(如CONTROL和xPSR)定义。由于对特殊寄存器的访问以内联方式定义,所以ARM针对不同的编译器统一用INLINE来屏蔽差异。该层定义的接口函数均是可重入的。
2)中间件访问层(MWAL) 由ARM公司负责实现,但芯片厂商需要针对所生产的设备特性对该层进行更新。该层主要负责定义一些中间件访问的API函数。例如,为TCP/IP协议栈、SD/MMC、USB协议以及实时操作系统的访问与调试提供标准软件接口。该层在1.1标准中尚未实现。
3)设备外设访问层(DPAL) 由芯片厂商负责实现。该层的实现与CPAL类似,负责对硬件寄存器地址及外设访问接口进行定义。该层可调用CPAL层提供的接口函数,同时根据设备特性对异常向量表进行扩展,以处理相应外设的中断请求。
CMSIS为CM3系统定义了:
◆ 访问外设寄存器的通用方法和定义异常向量的通用方法;
◆ 内核设备的寄存器名称和内核异常向量的名称;
◆ 独立于微控制器的RTOS接口、带调试通道;
◆ 中间设备组件接口(TCP/IP协议栈、闪存文件系统)。
2. CMSIS规范
1)文件结构 CMSIS文件夹结构见表3-6。
表3-6 CMSIS文件夹结构
CMSIS的文件结构如图3-6所示(以STM32为例)。其中stdint.h包括对8位、16位、32位等类型指示符的定义,主要用于屏蔽不同编译器之间的差异。core cm3.h和core cm3.c中包括Cortex M3核的全局变量声明和定义,并定义一些静态功能函数,用于访问CM3内核及其设备如NVIC、SysTick等。system <device>.h和system <device >.c(即图3-6中的system stm32.h和system stm32.c)是不同芯片厂商定义的系统初始化函数Sys-temInit(),以及一些指示时钟的变量。<device>.h(即图3-6中的stm32.h)是提供给应用程序的头文件,它包含core cm3.h和system <device>.h,定义了与特定芯片厂商相关的寄存器及各中断异常号,并可定制M3核中的特殊设备,如MCU、中断优先级位数及Sy-sTick时钟配置。虽然CMSIS提供的文件很多,但在应用程序中只需包含<device>.h。
图3-6 CMSIS文件结构
2)工具链 CMSIS支持目前嵌入式开发的三大主流工具链,即ARM ReakView(arm-cc)、IAR EWARM(iccarm)及GNU工具链(gcc)。通过在core cm3.c中的如下定义,来屏蔽一些编译器内置关键字的差异。
/* define compiler specific symbols */ #if defined( CC ARM ) #define ASM asm /*! < asm keyword for ARM Compiler */ #define INLINE inline /*! < inline keyword for ARM Compiler */ #elif defined( ICCARM ) #define ASM asm /*! < asm keyword for IAR Compiler */ #define INLINE inline /*! < inline keyword for IAR Compiler. Only avaiable in High optimization mode! */ #elif defined ( GNUC ) #define ASM asm /*! < asm keyword for GNU Compiler */ #define INLINE inline/*! < inline keyword for GNU Compiler */ #elif defined ( TASKING ) #define ASM asm /*! < asm keyword for TASKING Compiler */ #define INLINE inline /*! < inline keyword for TASKING Compiler */ #endif
3.4 FWLib固件库
固件库是一个固件函数包,它由程序、数据结构和宏组成,包括了微控制器所有外设的标准驱动函数(接口)。写程序时只要去调用它即可。通过使用固件函数库,无需深入掌握细节,用户就可以轻松应用每一个外设。因此,使用固态函数库可以大大减少用户的程序编写时间,进而降低开发成本。每个外设驱动都由一组函数组成,这组函数覆盖了该外设所有功能。每个器件的开发都由一个通用应用程序接口API(Application Programming Interface)驱动,API对该驱动程序的结构,函数和参数名称都进行了标准化。所有的驱动源代码都符合“Strict ANSI-C”标准。由于整个固态函数库按照“Strict ANSI-C”标准编写,它不受开发环境的影响,仅启动文件取决于开发环境。因为该固件库是通用的,并且包括了所有外设的功能,所以应用程序代码的大小和执行速度可能不是最优的。对大多数应用程序来说,用户可以直接使用,对于那些在代码大小和执行速度方面有严格要求的应用程序,该固件库驱动程序可以作为如何设置外设的一份参考资料,根据实际需求对其进行调整。
从2007年5月发布第1版STM32固件库FWLibV0.3,到2009年6月的标准外设库Std-Periph LibV3.0.1,ST公司提供了低密度、中密度、高密度全系列支持。
1. 固件库命名规则
固态函数库遵从以下命名规则。
(1)PPP表示任一外设缩写,如ADC、CAN等。更多缩写详见表3-7。
表3-7 外设缩写列表
(2)系统、源程序文件和头文件命名都以“stm32f10x ”作为开头,如stm32f10x conf.h。
(3)常量仅被应用于一个文件的,定义于该文件中;被应用于多个文件的,在对应头文件中定义。所有常量都由英文字母大写书写。
(4)寄存器作为常量处理。它们的命名都由英文字母大写书写。
(5)外设函数的命名以该外设的缩写加下画线为开头。每个单词的第一个字母都由英文字母大写书写,如SPI SendData。在函数名中,只允许存在一个下划线,用以分隔外设缩写和函数名的其他部分。
(6)名为PPP Init的函数,其功能是根据PPP InitTypeDef中指定的参数,初始化外设PPP,如TIM Init。
(7)名为PPP DeInit的函数,其功能为复位外设PPP的所有寄存器至默认值,如TIM DeInit。
(8)名为PPP StructInit的函数,其功能为通过设置PPP InitTypeDef结构中的各种参数来定义外设的功能,如USART StructInit。
(9)名为PPP Cmd的函数,其功能为使能或失能外设PPP,如SPI Cmd。
(10)名为PPP ITConfig的函数,其功能为使能或失能来自外设PPP某中断源,如RCC ITConfig。
(11)名为PPPDMAConfig的函数,其功能为使能或失能外设PPP的DMA接口,如TIM1DMAConfig。用以配置外设功能的函数,总是以字符串“Config”结尾,如GPIO Pin-RemapConfig。
(12)名为PPP GetFlagStatus的函数,其功能为检查外设PPP某标志位被设置与否,如I2C GetFlagStatus。
(13)名为PPP ClearFlag的函数,其功能为清除外设PPP标志位,如I2 C ClearFlag。
(14)名为PPP GetITStatus的函数,其功能为判断来自外设PPP的中断发生与否,如I2C GetITStatus。
(15)名为PPP ClearITPendingBit的函数,其功能为清除外设PPP中断待处理标志位,如I2 C ClearITPendingBit。
2. 数据类型和结构
1)变量 固态函数库定义了24个变量类型,其类型和大小是固定的。在文件stm32f10x type.h中定义了这些变量,详见3.1节关于typedef的讲解。
2)布尔型 在文件stm32f10x type.h中,布尔型变量被定义如下:
typedef enum { FALSE=0, TRUE=!FALSE } bool;
3)标志位状态类型 在文件stm32f10x type.h中,定义标志位类型(FlagStatus type)的两个可能值为“设置”与“重置”(SET or RESET)。
typedef enum { RESET=0, SET=!RESET } FlagStatus;
4)功能状态类型 在文件stm32f10x type.h中,定义功能状态类型(FunctionalState type)的两个可能值为“使能”与“失能”(ENABLE or DISABLE)。
typedef enum { DISABLE=0, ENABLE=!DISABLE } FunctionalState;
5)错误状态类型 在文件stm32f10x type.h中,错误状态类型(ErrorStatus type)的两个可能值为“成功”与“出错”(SUCCESS or ERROR)。
typedef enum { ERROR=0, SUCCESS=!ERROR } ErrorStatus;
6)外设 用户可以通过指向各个外设的指针访问各外设的控制寄存器。这些指针所指向的数据结构与各个外设的控制寄存器布局一一对应。
(1)外设控制寄存器结构:文件stm32 f10 x map.h包含了所有外设控制寄存器的结构,下例为SPI寄存器结构的声明。
/*------------------Serial Peripheral Interface ----------------*/ typedef struct { vu16 CR1; u16 RESERVED0; vu16 CR2; u16 RESERVED1; vu16 SR; u16 RESERVED2; vu16 DR; u16 RESERVED3; vu16 CRCPR; u16 RESERVED4; vu16 RXCRCR; u16 RESERVED5; vu16 TXCRCR; u16 RESERVED6; } SPI TypeDef;
(2)外设声明:寄存器命名遵循寄存器缩写命名规则。RESERVEDi(i为一个整数索引值)表示被保留区域。
文件stm32 f10 x map.h包含了所有外设的声明,下例为SPI外设的声明。
#ifndef EXT #Define EXT extern #endif … #define PERIPH BASE((u32)0x40000000) #define APB1PERIPH BASE PERIPH BASE #define APB2PERIPH BASE(PERIPH BASE + 0x10000) … /* SPI2 Base Address definition*/ #define SPI2 BASE(APB1PERIPH BASE + 0x3800) … /* SPI2 peripheral declaration*/ #ifndef DEBUG … #ifdef SPI2 #define SPI2((SPI TypeDef *)SPI2 BASE) #endif /* SPI2 */ … #else /* DEBUG */ … #ifdef SPI2 EXT SPI TypeDef *SPI2; #endif /* SPI2 */ … #endif /* DEBUG */
如果用户希望使用外设SPI,那么必须在文件stm32 f10xconf.h中定义SPI标签。
通过定义标签SPIn,用户可以访问外设SPIn的寄存器。例如,用户必须在文件stm32 f10xconf.h中定义标签SPI2,否则是不能访问SPI2的寄存器的。在文件stm32 f10xconf.h中,用户可以按照下例定义标签SPI和SPIn:
#define SPI #define SPI1 #define SPI2
每个外设都有若干寄存器专门分配给标志位。我们按照相应的结构定义这些寄存器。标志位的命名,同样遵循外设缩写规范,以“PPP FLAG”开始。对于不同的外设,标志位都被定义在相应的文件stm32 f10 x ppp.h中。
用户想要进入除错(DEBUG)模式的话,必须在文件stm32 f10 x conf.h中定义标签DE-BUG。这样会在SRAM的外设结构部分创建一个指针。在所有情况下,SPI2都是一个指向外设SPI2首地址的指针。
变量DEBUG可以仿照下例定义。
#define DEBUG 1
调试模块在文件stm32 f10 x lib.c中的初始化如下所述:
#ifdef DEBUG void debug(void) { … #ifdef SPI2 SPI2=(SPI TypeDef *)SPI2 BASE; #endif /* SPI2 */ … } #endif /* DEBUG*/
注意(1)当用户选择DEBUG模式,宏assert param被扩展,同时运行时间检查功能也在固态函数库代码中被激活。
(2)进入DEBUG模式会增大代码的尺寸,降低代码的运行效率。因此,建议仅在除错时使用相应代码,在最终的应用程序中删除它们。
3. STM32标准外设库
STM32 F10×××固件函数库(V3.3.0版)被压缩在一个zip文件中。解压该文件会产生一个文件夹,该文件夹主要由图3-7所示文件夹构成。从图3-7中可以学习到在多个源码文件并存时一定要注意养成良好的文件组织习惯,使其结构清晰、合理。这是一个优秀开发人员必备的素质。
图3-7 STM32F10xxx标准外设库包结构
在STM32 F10×××标准外设库包里的新文件夹见表3-8。表中RVMDK、RIDE、EWARMv5用于不同开发环境使用。
表3-8 STM32 F10×××标准外设库包文件夹描述
图3-7中CMSIS文件夹下的CM3文件夹包括微控制器外设访问层和内核设备访问层文件。其中core cm3.h是CMSIS的CM3内核设备访问层头文件;core cm3.c是CMSIS的CM3内核设备访问层源文件;stm32 f10 x.c是CMSIS的CM3 STM32 F10×××微控制器外设访问层文件;system stm32 f10 x.h是CMSIS的CM3 STM32 F10×××微控制器外设访问层头文件;system stm32 f10 x.c是CMSIS的CM3 STM32 F10 xxx微控制器外设访问层源文件。
子文件夹inc包含固件函数库所需要的头文件,用户无需修改该文件夹。其中stm32 f10 x type.h、stm32 f10 x map.h、stm32 f10 x lib.h、stm32 f10 x ppp.h详细说明见表3-9;cor-rtexm3 macro.h是文件corrtexm3 macro.s对应的头文件。
表3-9 固件函数库文件描述
子文件夹src包含固件函数库所需要的源文件,用户无需修改该文件夹。每一个外设都有一个对应的源文件stm32 f10xppp.c和一个对应的头文件stm32 f10xppp.h。stm32 f10xppp.c包含该外设使用的函数体。stm32 f10xlib.c是初始化所有外设的指针。
常用固件函数库文件描述见表3-9。
STM32的固件库具有以下优点。
◆ 兼容性好:使用宏定义能够灵活地兼容各个型号和不同功能。
◆ 命名规范:不用注释就能看懂变量或函数,可读性好,而且不会重名,嵌入式工程师应借鉴ST固件函数的命名规范,养成良好的编码风格。
◆ 通用性强:多数库文件都是只读类型,不用修改便可以实现不同功能间的调用。
固件库的缺点就是代码效率不高,速度会变慢。因此,对于时序要求严格的地方,完全可以直接访问寄存器。要根据实际开发设计的要求来确定选择。
4. STM32编程步骤
(1)解压缩,改目录名称,以便与其他程序区分或创建项目目录,复制公共文件。
(2)配置项目。
(3)更改设置:在“stm32 f10 x conf.h”关闭不用的外设(在其声明函数前面加注释符号“//”)。并根据外部晶振速度更改其中“HSE Value”的数值,其单位是Hz。
(4)完成各种头文件的包含(#include "xxx.h"),公共变量的声明(static数据类型 变量名称;),子程序声明(void函数名称(参数))等C语言必须的前置工作。
(5)改写程序库里面所预设的模板,再进行其他模块的初始化子程序代码的编写,并在程序代码的开始部分调用。
注意 必须记住所有外设的使用需要考虑4个问题:开时钟RCC(在RCC初始化中);自身初始化;相关引脚配置(在GPIO初始化中);是否使用中断(在NVIC初始化中)。
(6)编写main.c中的主要代码和各种子函数。
(7)在“stm32 f10 x it.c”填写各种中断所需的执行代码,如果用不到中断的简单程序则不用编写此文件。
(8)编译生成“bin”的方法:Project\Option\ Linker\Output\Format,里面选择“Oth-er”,在下面的“Output”选中“raw-binary”生成bin。
(9)编译生成“hex”的方法:Project\Option\ Linker\Output\Format,里面选择“Oth-er”,在下面的“Output”选中“intel-extended”,生成文件直接改扩展名成为hex,或者选中上面的“Output Flie”在“Overrride default”项目里面改扩展名为hex。
(10)使用软件界面的Debug下载并按钮调试程序。
3.5 嵌入式C编程标准
嵌入式系统的开发应用,除了必须注意硬件电路的正确连接外,更重要的工作是软件的开发。嵌入式系统好的程序代码应具有下列特点。
◆ 软件在结构上清晰、简洁、流程合理。
◆ 各功能程序实现模块化、子程序化,这样便于调试和连接,也便于移植和修改。
◆ 程序存储区和数据存储区安排合理,既能节约内存容量,操作又方便。
◆ 运行状态实现标志化管理,各功能程序运行状态、运行结果及运行要求都设置有状态标志以便查询。
◆ 对需要特殊抗干扰的应用系统应采用软件抗干扰措施,以提高系统可靠性。
是否按照代码标准就像字写得好不好看一样,如果一个公司招聘秘书,肯定不要字写得难看的,同理,代码风格糟糕的程序员肯定也是不称职的。虽然编译器不会挑剔难看的代码,照样能编译通过,但是一个团队的其他程序员肯定受不了,写完代码几天后再来看,自己都不知道自己写的是什么。[SICP] 里有句话说得好:“Thus,programs must be written for people to read,and only incidentally for machines to execute.”代码主要是为了写给人看的,而不是写给机器看的,只是顺便也能用机器执行而已,如果是为了写给机器看那直接写机器指令就好了,没必要用高级语言了。代码和语言文字一样是为了表达思想、记载信息,所以一定要写得清楚、整洁才能有效地表达。正因为如此,在一个软件项目中,代码风格一般都用文档规定,所有参与项目的人不管他自己原来是什么风格,都要遵守统一的风格。
1. 空白和缩进
C语言的语法对缩进和空白没有要求,空格、Tab、换行都可以随意写。
(1)关键字if、while、for与其后的控制表达式的括号之间插入一个空格分隔,但括号内的表达式应紧贴括号。如while□(1);
(2)双目运算符的两侧各插入一个空格分隔,单目运算符和操作数之间不加空格,如i□=□i□+□1、++i、!(i□<□1)、-x、&a[1]等。
(3)后缀运算符和操作数之间也不加空格,如取结构体成员s.a、函数调用foo(arg1)、取数组成员a。
(4)“,”号和“;”号后要加空格,这是英文的书写习惯,如for□(i□=□1;□i□<□10;□i++)、foo(arg1,□arg2)。
(5)以上关于双目运算符和后缀运算符的规则并没有严格要求,有时为了突出优先级也可以写得更紧凑一些,如for□(i=1;□i<10;□i++)、distance□=□sqrt(x*x□+□y*y)等。但是省略的空格一定不要误导读代码的人,如a||b□&&□c很容易让人理解成错误的优先级。
(6)较长语句要折行写,折行后用空格和上面的表达式或参数对齐,例如:
if□(sqrt(x*x□+□y*y)□>□5.0 &&□x□<□0.0 &&□y□>□0.0)
再比如:
foo(sqrt(x*x□+□y*y), a[i-1]□+□b[i-1]□+□c[i-1])
(7)较长的字符串可以断成多个字符串,然后分行书写,例如:
printf("This is such a long sentence that " "it cannot be held within a line\n");
C编译器会自动把相邻的多个字符串接在一起,以上两个字符串相当于一个字符串"This is such a long sentence that it cannot be held within a line\n"。
(8)有的人喜欢在变量定义语句中用Tab字符,使变量名对齐,这样看起来很美观。
→int →a,b; →double →c;
关于缩进的规则有以下6条。
(1)要用缩进体现出语句块的层次关系,使用Tab字符缩进,不能用空格代替Tab。在标准的字符终端上一个Tab看起来是8个空格的宽度,如果文本编辑器可以设置Tab的显示宽度是几个空格,建议也设成8,这样大的缩进使代码看起来非常清晰。如果有的行用空格做缩进,有的行用Tab做缩进,甚至空格和Tab混用,那么一旦改变了文本编辑器的Tab显示宽度就会看起来非常混乱,所以代码风格规定只能用Tab做缩进,不能用空格代替Tab。
(2)if/else、while、do/while、for、switch这些可以带语句块的语句,语句块的“{”或“}”应该和关键字写在同一行,用空格隔开,而不是单独占一行。例如,应该这样写:
if□(…)□{ →语句列表 }□else□if□(…)□{ →语句列表 }
但很多人习惯这样写:
if□(…) { →语句列表 } else□if□(…) { →语句列表 }
这两种写法用得都很广泛,只要在同一个项目中能保持统一就可以了。
(3)函数定义的“{”和“}”单独占一行,这一点和语句块的规定不同,例如:
int□foo(int□a,□int□b) { →语句列表 }
(4)switch和语句块里的case、default对齐写,也就是说语句块里的case、default标号相对于switch不往里缩进,但标号下的语句要往里缩进。例如:
→switch□(c)□{ →case′A′: → →语句列表 →case′B′: → →语句列表 →default: → →语句列表 →}
用于goto语句的自定义标号应该顶头写不缩进,而不管标号下的语句缩进到第几层。
(5)代码中每个逻辑段落之间应该用一个空行分隔开。例如,每个函数定义之间应该插入一个空行,头文件、全局变量定义和函数定义之间也应该插入空行,例如:
#include <stdio.h> #include <stdlib.h> int g; double h; int foo(void) { →语句列表 } int bar(int a) { →语句列表 } int main(void) { →语句列表 }
(6)一个函数的语句列表如果很长,也可以根据相关性分成若干组,用空行分隔。这条规定不是严格要求,通常把变量定义组成一组,后面加空行,return语句之前加空行,例如:
int main(void) { →int →a,b; →double →c; →语句组1 →语句组2 →return 0; }
2. 注释
单行注释应采用“/*□comment□*/”的形式,用空格把界定符和文字分开。多行注释最常见的是这种形式:
/* □*□Multi-line □*□comment □*/
也有更花哨的形式:
/*************\ * Multi-line * * comment * \*************/
使用注释的场合主要有以下6种。
(1)整个源文件的顶部注释:说明此模块的相关信息,如文件名、作者和版本历史等,顶头写不缩进。
(2)函数注释:说明此函数的功能、参数、返回值、错误码等,写在函数定义上侧,和此函数定义之间不留空行,顶头写不缩进。
(3)相对独立的语句组注释:对这一组语句做特别说明,写在语句组上侧,和此语句组之间不留空行,与当前语句组的缩进一致。
(4)代码行右侧的简短注释:对当前代码行做特别说明,一般为单行注释,和代码之间至少用一个空格隔开,一个源文件中所有的右侧注释最好能上下对齐。
函数内的注释要尽可能少用。写注释主要是为了说明代码“能做什么”(如函数接口定义),而不是为了说明“怎样做”,只要代码写得足够清晰,“怎样做”是一目了然的,如果需要用注释才能解释清楚,那就表示代码可读性很差,除非是特别需要提醒注意的地方才使用函数内注释。
(5)复杂的结构体定义比函数更需要注释。
(6)复杂的宏定义和变量声明也需要注释。
3. 标志符命名
标志符命名应遵循以下原则。
(1)标志符命名要清晰明了,可以使用完整的单词和易于理解的缩写。短的单词可以通过去元音形成缩写,较长的单词可以取单词的头几个字母形成缩写。别人的代码看多了就可以总结出一些缩写惯例,如count写成cnt,block写成blk,length写成len,window写成win,message写成msg,number写成nr;temporary可以写成temp,也可以进一步写成tmp;最有意思的是internationalization写成i18 n;词根trans经常缩写成x,如transmit写成xmt。请读者在看代码时自己注意总结和积累。
(2)编码风格规定变量、函数和类型采用全小写加下划线的方式命名,常量(如宏定义和枚举常量)采用全大写加下划线的方式命名,如函数名radix tree insert、类型名struct radix tree root、常量名RADIX TREE MAP SHIFT等。
(3)全局变量和全局函数的命名一定要详细,不惜多用几个单词多写几个下划线,如函数名radix tree insert,因为它们在整个项目的许多源文件中都会用到,必须让使用者明确这个变量或函数是干什么用的。局部变量和只在一个源文件中调用的内部函数的命名可以简略一些,但不能太短。尽量不要使用单个字母做变量名,但用i、j、k做循环变量是可以的。
(4)针对中国程序员的一条特别规定:禁止用汉语拼音做标志符,可读性极差。
4. 函数
每个函数都应该设计得尽可能简单,简单的函数才容易维护。应遵循以下原则。
◆ 实现一个函数只是为了做好一件事情,不要把函数设计成用途广泛、面面俱到的,这样的函数肯定会超长,而且往往不可重用,维护困难。
◆ 函数内部的缩进层次不宜过多,一般以少于4层为宜。如果缩进层次太多就说明设计得太复杂了,应考虑分割成更小的函数(Helper Function)来调用。
◆函数不要写得太长,建议在24行的标准终端上不超过两屏,太长会造成阅读困难,如果一个函数超过两屏就应该考虑分割函数了。如果一个函数在概念上是简单的,只是长度很长,这倒没关系。例如,函数由一个大的switch组成,其中有非常多的case,这是可以的,因为各case分支互不影响,整个函数的复杂度只等于其中一个case的复杂度,这种情况很常见,如TCP协议的状态机实现。
◆ 执行函数就是执行一个动作,函数名通常应包含动词,如get current、radix tree insert。
◆ 比较重要的函数定义上侧必须加注释,说明此函数的功能、参数、返回值、错误码等。
◆ 另一种度量函数复杂度的办法是看有多少个局部变量,5~10个局部变量已经很多了,再多就很难维护了,应该考虑分割成多个函数。