嵌入式程序编写方法与规范

嵌入式程序编写方法与规范


前言

本文主要讲解嵌入式单片机程序的编写方法以及编写规范,以MSP430单片机作为例子,无论是51,AVR还是STM32单片机都同样适用,本文对C语言各种语法各种关键字进行详细解释,对操作物理地址的方法进行剖析,对程序编写的框架进行分析,对嵌入式的编码规范进行总结,本文属于个人总结,能力有限,如有错误,还望斧正,不胜感激。


一、C语言的发展历史

C语言于1972年11月问世,1978年美国电话电报公司(AT&T)贝尔实验室正式发布C语言,1983年由美国国家标准局(American National Standards Institute,简称ANSI)开始制定C语言标准,于1989年12月完成,并在1990年春天发布,称之为ANSI C,有时也被称为 C89 或 C90。1969-1973年在美国电话电报公司(AT&T)贝尔实验室开始了C语言的最初研发。根据C语言的发明者丹尼斯·里奇 (Dennis Ritchie) 说,C 语言最重要的研发时期是在1972年。C语言之所以命名为C,是因为C语言源自Ken Thompson发明的 B语言,而B语言则源自BCPL语言。C语言的诞生是和UNIX操作系统的开发密不可分的,原先的UNIX操作系统都是用汇编语言写的,1973年UNIX操作系统的核心用C语言改写,从此以后,C语言成为编写操作系统的主要语言。

二、C语言中的关键字解释

说明

C语言中的关键字有32个,基本数据类型5个,类型修饰关键字4个,复杂类型关键字5个,存储级别关键字6个,流程控制关键字中的跳转结构关键字有4个,分支结构关键字有5个,循环结构关键字有3个。需要注意的是,C语言中的关键字没有string,如果需要在C语言中使用字符串,那么就需要定义头文件#include,string 是字符串,char是单个的字符。string相当于一个容器,char可以放在里面。string有结束符,char是没有的。string是c++里的,不是c里的。用string存储字符串时,不用设定字符串的长度,而char要设定。还有就是,string有很强很方便的功能,比如可以方便的赋值,方便的比较大小。

基本数据类型5个:

void:声明函数无返回值或无参数,声明无类型指针,显式丢弃运算结果
char:字符型类型数据,属于整型数据的一种
int:整型数据,通常为编译器指定的机器字长
float:单精度浮点型数据,属于浮点数据的一种
double:双精度浮点型数据,属于浮点数据的一种

类型修饰关键字4个:

short:修饰int,短整型数据,可省略被修饰的int。
long:修饰int,长整形数据,可省略被修饰的int。
signed:修饰整型数据,有符号数据类型
unsigned:修饰整型数据,无符号数据类型

复杂类型关键字5个:

struct:结构体声明
union:共用体声明
enum:枚举声明
typedef:声明类型别名
sizeof:得到特定类型或特定类型变量的大小

存储级别关键字6个:

auto:指定为自动变量,由编译器自动分配及释放。通常在栈上分配
static:指定为静态变量,分配在静态变量区,修饰函数时,指定函数作用域为文件内部
register:指定为寄存器变量,建议编译器将变量存储到寄存器中使用,也可以修饰函数形参,建议编译器通过寄存器而不是堆栈传递参数
extern:指定对应变量为外部变量,即在另外的目标文件中定义,可以认为是约定由另外文件声明的对象的一个“引用“
const:与volatile合称“cv特性”,指定变量不可被当前线程/进程改变(但有可能被系统或其他线程/进程改变)
volatile:与const合称“cv特性”,指定变量的值有可能会被系统或其他进程/线程改变,强制编译器每次从内存中取得该变量的值

控制流程关键字
跳转结构关键字有4个:

return:用在函数体中,返回特定值(或者是void值,即不返回值)
continue:结束当前循环,开始下一轮循环
break:跳出当前循环或switch结构
goto:无条件跳转语句

分支结构关键字有5个:

if:条件语句
else:条件语句否定分支(与if连用)
switch:开关语句(多重分支语句)
case:开关语句中的分支标记
default:开关语句中的“其他”分支,可选。

循环结构关键字有3个:

for:for循环结构,for(1;2;3)4;的执行顺序为1->2->4->3->2…循环,其中2为循环条件
do:do循环结构,do 1 while(2);的执行顺序是1->2->1…循环,2为循环条件
while:while循环结构,while(1) 2;的执行顺序是1->2->1…循环,1为循环条件

以上循环语句,当循环条件表达式为真则继续循环,为假则跳出循环。

三、嵌入式程序编写的规范

3.1 文件的结构

  1. 每个模块功能应该至少由一个或多个.h 和.c 文件构成,.h 文件在编译器环
    境可见;
  2. 对外提供的调用接口在.h 文中定义;
  3. 为了防止重复包含,.h 文件中应进行预编译宏定义;
  4. 文件头部应标识文件修改履历;

3.2 程序排版的风格

  1. 程序块采用缩进风格编写,缩进为 4 个空格位;
  2. 相对独立的程序块之间、变量说明之后必须加空行;
  3. 循环、判断等语句中若有较长的表达式或语句,则要进行适应的划分,
    长表达式要在低优先级操作符处划分新行,操作符放在新行之首;
  4. if 、 for 、 do 、 while 、 case 、 switch 、 default 等语句自占一行,且 if 、for 、do 、while 等语句的执行语句部分无论多少都要加括号 {};
  5. 关键字、变量、常量等操作符之间要加空格(属性操作符->、. 以及单目运
    算符除外);
  6. 函数体的开始,枚举、结构的定义,do、while 、for 、if、switch 及 case 语
    句中的程序都应采用缩进方式,‘{’和‘}’应各独占一行并且位于同一列,同时
    与引用它们的语句左对齐;
  7. 每条语句一行;
  8. 单行语句字符不能多于 140 个(要求一行代码不用操作滚动条即可完整阅
    读);
  9. 不同类型的操作符混合使用时,使用小括号明确优先级;

3.3 命名方式

变量的命名方式

  1. 禁止取单个字符(如 i 、 j 、 k… ),同时不能多于 31 个字符。
  2. 名称 = [作用域前缀]+类型+描述
    其中:前缀是可选项,以小写字母表示; 类型是必选项,以小写字母表示; 描 述是必选项,可多个单词(或缩写)合在一起,每个单词首字母大写。
  3. 缩写规则:
    ① 去掉所有不是首字母的元音,例如:Count 为 Cnt;
    ② 去掉单词后缀,如 ing、ed 等;
    ③ 使用标准或者约定的缩写:API(Application Program Interface);

结构体命名

typedef Struct
{
	Float fltLong;
	Float fltWide;
} tSquare, *tpSquare;

枚举命名

要求所有单词大写,以 ENUM 为首,单词之间用下划线隔开,举例:

typedef enum
{
   ENUM_STATE_INIT,
   ENUM_STATE_IDLE,
} tEnumState, * tpEnumState;

函数命名

单词首字母为大写,其余均为小写,单词之间不用下划线。函数名应以一个
动词开头,即函数名应类似“动宾结构”。命名举例:

void GetErrCode(int nErrCode);

宏和常量

所有单词均大写,各个单词之间 用下划线隔开,举例:

#define MAX_NUM 66

函数、过程

  1. 函数的最后一个参数为函数执行返回的错误码;
    ※如果 INT16U 满足使用优先定义为 INT16U 类型。
  2. 对所调用函数的错误返回码要仔细、全面地处理;
  3. 函数的规模尽量限制在 200 行以内; ※不包括注释和空格行 。
  4. 一个函数仅完成一件功能;
  5. 为简单功能编写函数;
  6. 检查函数所有参数输入的有效性;
  7. 在多任务操作系统的环境下编程,要注意函数可重入性的构造;
  8. 禁止使用 goto 语句;
  9. 所有的 if … else if 结构应该由 else 子句结束;
  10. switch 语句的最后子句应该是 default 子句;
  11. 不可将浮点变量用“== ”或“ != ”与任何数字比较,而应采用“ <= ” 或
    “ >= ”;
  12. 指针变量用“== ”或“ != ”与 NULL 比较,勿直接跟 0 比较;

3.4 防御性编码

  1. 所有指针需要赋初值,在使用是进行非空判断;
  2. 所有数据定义应显示说明数组内部数据长度,在写入数据时做溢出判断;
  3. 除零判断;
  4. 变量在使用前应初始化,防止引用未经初始化变量;
  5. 尽量不使用动态内存;若使用,须对动态申请的内存做出有效性检查,并进
    行初始化;动态内存的释放必须和分配成对以防止内存泄漏,释放后内存指针置
    为 NULL。

3.5 注释

一般情况下,源程序有效注释量必须在 30 %以上。

结构体、枚举注释

typedef struct
{
    uint16_t u16TransactionID; /* 事务标识 */ 
	uint16_t u16ProtocolID; /* 协议标识 */
	uint16_t u16Len; /* 报文后续部分长度 */
    uint8_t u8UnitID; /* 单元标识符*/ 
}tMBAP,*tpMBAP;

函数功能性注释

函数功能性注释,特指位于函数头部起始位置的说明,举例如下:

Des:协议序列化
NOTE:
REF:
Author:llc
Date:2019910*/

函数过程注释

函数过程注释发生在编码阶段,函数逻辑明显的逻辑层次要注释,格式如
下:
/S1: XXXXXXXXXXXXXXXXXX/
/S1-1: XXXXXXXXXXXXXXXXXX/
…/Sn: XXXXXXXXXXXXXXXXXX/
/Sn-1: XXXXXXXXXXXXXXXXXX/

修改注释

/[修改特征][日期][修改人]/ /* Reason:*/
修改特征包含:
FunAdd(功能增加)、FunDel(功能删除)、BugFix(bug 修正)

四、嵌入式程序编写框架思路

4.1 文件的建立

系统文件

在一个程序系统中,无论是哪款单片机都会有系统的文件,比如51单片机就会有系统的头文件,MSP430也有头文件,STM32如果用库函数写的话就会有很多文件需要加载到系统工程里,所以需要建立一个系统的文件夹作为存放系统文件。

此为STM32F103的启动文件和内核文件:

BSP层文件

BSP是底层驱动文件,用于直接操作寄存器直接去操作物理地址,来改变单片机中寄存器的配置,所以在本文件中的函数都是各类驱动程序,比如IIC的驱动,SPI的驱动,USART的驱动,这些驱动直接由APP函数调用,(APP函数在下文中解释),BSP驱动程序是最底层的,它跟单片机的结构以及类型息息相关,每个单片机的寄存器配置以及底层的地址都不同,BSP直接与底层打交道,不参与LOGIC逻辑层的运行(logic逻辑层在后文提到),当我们的项目需要更换单片机时,我们只需要将BSP驱动进行修改即可,不用重新编写整个工程,增强程序的可移植性。
嵌入式程序编写方法与规范_第1张图片
下面是FRAM的驱动程序,驱动程序的编写需要有输入参数,返回参数,输入参数可以是指针,地址或者是常量,函数需要对运行过程中的错误进行返回,利于调用它的程序对它的运行情况进行判断,所有的变量需要定义成局部变量,方便移植,增强程序的内聚性。

嵌入式程序编写方法与规范_第2张图片

APP层文件

APP是application的缩写,顾名思义,本文件中的程序全部调用BSP驱动程序,在APP层的文件中,不能出现直接操作寄存器的语句,在APP层的函数中,我们需要对整个工程的各个功能按模块进行封装,封装好的函数可以是一个数据处理的过程也可以是对外设的一个完整的操作,相当于对BSP驱动函数进行二次封装,当然,这个封装是根据你自己的需要去封装,在APP函数中,我们直接调用bsp函数,当跨平台移植时,优秀的app程序也是不需要任何修改的,只要BSP驱动封装的好,APP函数可以直接进行跨平台移植。

嵌入式程序编写方法与规范_第3张图片
此函数为上电之后获取FRAM中的系统参数的程序,上电后我们直接对FRAM进行初始化,然后获取在FRAM中的参数并对参数计算CRC,计算后的参数与我们保存的CRC进行比对,若CRC与我之前保存进去的CRC相等时,就判断我们的数据没有丢失,则此时直接写系统的参数范围并直接return,若出现错误我们则让计数标志位++,持续获取三次都出错,那么直接进入备份区域参数获取,并上报故障码。
*显而易见,在本函数中,我们直接调用了BSP层的FRAMINIT函数,这个函数在BSP层进行封装后直接被APP函数所调用,如果此时更换了单片机,我们只需要在BSP底层的函数中编写同样的函数,那么本APP函数就可以不做任何修改直接移植进新的单片机,这就是当一个bsp函数写的好,跨平台移植时会非常省时省力。

LOGIC逻辑层文件

本文件中就是该系统或者该项目的整体运行逻辑,它的函数全部来源于APP函数,不能从BSP文件中直接调用函数,这个是不允许的,因为当LOGIC直接从BSP中调用函数,系统的低耦合性会被破坏,当跨平台移植时,LOGIC的应用程序也需要去修改,程序出错的可能性会加大.LOGIC层只是整个程序运行的流程和逻辑,在本程序中禁止出现操作寄存器,直接操作IO口的语句。
下面是重点:
利用状态机的思路去编写逻辑层的程序,程序上电后先对所有的外设进行初始化,当然,初始化程序中禁止出现GPIO初始化,USART初始化以及IIC初始化这类BSP驱动层的初始化流程,既然是逻辑,那么就不能出现底层的东西。初始化完成之后,进入一个POLL循环函数,这个循环函数在主函数中持续被轮询,举个例子,如果我们要使一个LED先亮10秒后灭10秒然后继续点亮,那么就可以定义四种状态,利用ENUM枚举来定义,我们可以定义ENUM_OPEN_STATE;ENUM_CLOSE_STATE;ENUM_FREE;以及ENUM_FINISH;四种状态,当程序运行至POLL函数中时,首先进入FREE状态,在本状态中我们就可以打开LED后使这个状态进入LED_OPEN状态,此时程序第一次轮询后会打开LED,并且下次运行进来时直接进入OPEN状态,此时开启一个定时器,定时10S,十秒到后将一个标志位置1,在OPEN状态中,持续监测这个标志位,当监测到这个标志位被置1后马上将标志位置0并且关闭LED并进入下一个状态,并且打开定时器开始计时,在CLOSE状态中我们持续监测这个标志位,当标志位被置1后马上打开这个标志位,并使程序状态进入OPEN,这样程序在运行时只是跑一次switch之后就可以直接跳出程序,不在程序中死等保证了程序的实时性,顺面说一句,在while中禁止使用delay语句,除非一些特殊情况必须使用除外,程序一直处于死等使得程序运行效率非常低下。
这样程序在运行的时候只会占用很少很少的机器周期就可以完成对LED状态的监测和判断,并且实时改变LED灯的状态,当然,这种思路只能用在对实时性要求不严苛的场合,我所指的严苛,是微秒级的延时都不能有,如果对实时性要求很严苛,那么就必须直接在定时器中直接改变LED的状态,但是大部分场合这种思想都是适用的,毕竟不是所有系统都要求微秒级的响应速度。
正因为状态机有这个好处,那么我们就可以同时轮询很多任务,比如MODBUS的轮询,各种外设的轮询都可以用这个办法,虽然说51单片机的主频率只有12M,但是MODBUS的响应速度依旧可以做到50ms以内,所以只要程序优化的到位,程序的架构设计的合理,虽然只有12M的频率,但是速度还是非常快的。
嵌入式程序编写方法与规范_第4张图片
切记,break写在case语句内部,这样比较规范。

中断文件的建立

在这里我们模仿STM32的库函数,毕竟人家这样写有人家这样写的好处和理由,所以无论是STM32,MSP430还是51单片机我都建议把中断文件单独摘出来,这样方便管理也方便维护,这个文件介于BSP,APP以及LOGIC文件之间,但是尽可能的不要出现操作寄存器或者直接操作IO的语句,但毕竟是中断,很多语句无法避免,所以在跨平台移植时,中断函数也是移植的重点。
在interrupt程序中,我们可以将定时器中断,外部中断和串口中断都放进来,注意顺序和优先级,尽量把优先级比较高的函数放在前面,优先级低的放在后面,便于维护。

主函数的编写

最重要的就是主函数了,程序的入口就是main函数,程序会在main函数中持续的循环,但最忌把所有的程序和函数一股脑的放在void main中,在主函数中我们只需要初始化我们的外设,在while中寻论相应的poll函数即可,需要注意,在main函数中也不要出现底层驱动的初始化,很多人会把串口,IIC或者spi的初始化都写在main中,这样不利于维护,因为你的IIC是为哪个外设服务的?你的SPI是为哪个外设服务的?有新的程序员看到你的程序也不知道你具体是怎么对应的,所以必须在主函数中直接初始化模块,在模块中封装你的驱动。
嵌入式程序编写方法与规范_第5张图片

4.2 C文件与H文件的编写

1. H文件的编写

在嵌入式程序中,H文件时至关重要的,因为函数在声明,在调用以及交叉引用时都会涉及H文件的调用,为了防止在交叉引用时的冲突,所以需要利用预编译来避免这个问题。编写H文件时,首先要对文件的属性,名称以及编写日期和编写人员进行说明,便于以后调用或者移植,新程序员看到你的H文件时能快速对文件有理解,第二部就是需要利用预编译来防止H文件重复声明的问题,我们可以利用以下编写方式来规避:

#ifndef __HH_XX_H_HH__
#define __HH_XX_H_HH__	
#ifdef XX
   #defineXX_EX
#else
   #define XX extern
#endif
*/

这几句话的意思是如果定义了本H文件,则定义本H文件,如果没有定义本H文件,那么则用extern来声明它,有了这几句话,无论有多少H文件,只要H文件的名称不同,那么就不会出现重复定义的问题,在工程或者系统中会避免很多麻烦。下来我们就需要定义主函数的头文件:

#include "main.h"

在main.h中,我们需要添加本系统或者工程中所用到的所有H文件,将所有的H文件全部添加到main.h中,这样我们在各个C文件中只需要定义main.h后,就可以将所有H文件全部关联起来,调用起来也不会出错,需要添加就在main.h中添加,删除也只需要删除一次,不需要在每个C文件中都删除一次,省去了很多麻烦。
在每个H文件中,都需要对自身的数字量(不变的固定数字量或者是需要全局修改的参数)进行宏定义,在系统或者工程软件编写时尽可能的不要出现数字,因为数字本身对于程序来说是无意义的,只有给这个数字起名字,新的程序员才能看得懂这个数字的具体含义,这也是为什么单片机的头文件都要给寄存器起一个名字而不是直接给你一个长长的地址让你去这个地址操作寄存器。如果需要修改这个参数,可以直接在宏定义中对这个参数进行修改,这样全局的这个数字都可以被修改,省去了在整个C文件中一个一个去更改,并且宏定义在编译的时候是直接替换的,不会占据单片机的内存。
在最后就是声明本H文件对应的C文件中的所有函数的声明,只有声明了这些函数,当函数在别的C文件中被引用时,才不会出现未定义的错误,并且移植起来也方便,如果更换单片机,直接将C文件和H文件打包,在新的工程中直接声明H文件,那么这个C文件中的所有函数都会被声明,可以大大提高开发效率。并且所有引脚的定义都需要写在H文件中,这样如果硬件方面修改了器件管脚,那么我们需要做的只是在C文件中修改相应的初始化程序,在H文件中修改相应的管脚定义就可以完成对器件管脚的修改。

1. C文件的编写

在程序编写中,需要遵从高内聚,低耦合的方法,每个C文件都对应着一个H文件,所以编写完相应的H文件后我们就需要开始C文件的编写,首先我们需要声明H文件,因为需要将C文件与H文件关联起来,同样的,我们也需要在一开始就对本C文件的属性,作用,编写人员名字以及编写日期注明,养成良好习惯。下面就是对各个功能进行封装,将每个小功能分别封装成不同的函数,每个函数仅仅完成一件功能即可,一个函数的行数不要超过200行,空格和注释不算在内。在C文件的函数编写中,我们也尽量避免对“易变的”数据或者引脚直接操作,如上面所说可以直接定义在H文件中,需要修改时C文件利用宏定义直接映射,不需要修改。在C文件中我们对需要用到的函数进行封装,函数的定义尽量用输入输出参数的方式,让函数实现一个“过程”,输入一个参数且输出一个参数,输出的参数可以直接返回到函数本身,如果在数据处理期间出现错误,我们一般用retrun 1 来表示数据处理成功,return 0来表示函数运行失败,当函数不需要输入参数时,就定义为void空函数,在函数后面的返回数中,如果没有返回的参数也没有处理后的参数那么需要在括号中填void,避免空括号(虽然不会出现问题但是标准起见需要加上void)。

uint8_t Ds18b20Read(void)
{
   uint8_t u8LoopData,u8BackData = 0;
   for(u8LoopData = 8; u8LoopData > 0; u8LoopData--)   /*读18B20数据*/
   {
       u8BackData >>= 1;   /*右移1位写最高位*/
       DQ_OUT;
       DS18B20_L;
       delay_us(6);
       DS18B20_H;
       delay_us(4);
       DQ_IN;
       if(READ_DQ)   /*如果为高电平就写1*/
       {
           u8BackData |= 0x80;             
       }
       delay_us(65);                   
   }
   return(u8BackData);
}

五、如何直接操作底层寄存器

C语言是面向过程的语言,它的强大之处就在于指针,指针可以说是C语言的灵魂,开始学C语言的时候就听到过这句话,但是对这句话没有自己的理解,随着C语言学习的不断加深,才慢慢理解了这句话的涵义。C语言的指针是可以直接访问物理地址的,所以可以利用C语言的指针来直接操作单片机的寄存器。
单片机是通过控制寄存器来实现不同功能的设置的,在此,我们利用MSP430来举例,首先如果我们要操作寄存器,那就需要知道这个寄存器的地址,若我们需要操作P5.0的引脚的电平的高低,抛开头文件不说,如果直接利用指针去操作寄存器,理论上是可以不需要头文件的。我们要操作P5.0这个引脚首先需要根据MSP430官方给的用户手册查到P5的地址,此处和STM32相同,GPIO挂载在总线上,官方给出总线的基地址,这个基地址是厂家设定好的,我们查询手册就可以知道P5对于总线基地址的偏移量,那么总线的基地址加上偏移量就是P5这个寄存器的地址,经过查询我们得到P5的地址为0x0200,但是这个是P5的地址,我们需要找到控制P5引脚输出高低电平的引脚也就是P5OUT,经过查询我们得到P5OUT的地址为0x0242,知道了这个地址,我们就可以利用指针去操作这个实际的物理地址。

uint8_t*0x0242

因为本身0x0242是一个常量,毕竟编译器不知道这是一个地址,所以我们需要将其强制转换成一个指针,虽然MSP430是16位单片机,但是引脚只有P5.0-P5.7这八个引脚,所以只需要控制这个寄存器的低八位,所以我们强制转换的是uint8_t,将这个数据强制转为指针后需要:

*((uint8_t*0x0242|= 0x01

在上句指令的前面加上星号*的意思是取出这个地址的数据也就是指向这个寄存器内部的数据,将这个寄存器内部的数据改为0x01后我们就成功的修改了这个寄存器内部的数据,加上或符号是因为在修改这个寄存器这一位的时候不能改变其他位的数据,否则其他位全部会被清零只有P5.0被置高,同样的操作,如果需要将P5.0清零则为:

*((uint8_t*0x0242&= ~0x01

总结

学习单片机学习C语言贵在多练多看多学,有时候看一些书的时候当时有可能看不懂,但是囫囵吞枣通读之后,脑子里大概有一些印象,以后遇到或者需要操作的时候就会有种醍醐灌顶,恍然大悟的感觉,所有知识都是一点一点积累的不是一蹴而就,沉下心,一点点去学一点点去扣,往深挖才能学习到单片机的精髓,勤练习,只有在练习实践中才能发现自己的问题,然后去解决,解决问题的过程就是学习的过程,越学越发现自己无知,越学越发现知识永远没有尽头,虽然到现在我还是一个半瓶子,但是我坚信,只要下功夫,只要钻研,就没有学不会的知识。

2022.3.11

你可能感兴趣的:(单片机,stm32,嵌入式硬件)