设备的可靠性涉及多个方面:稳定的硬件、优秀的软件架构、严格的测试以及市场和时间的检验等等。这里着重谈一下作者自己对嵌入式软件可靠性设计的一些理解,通过一定的技巧和方法提高软件可靠性。
1、判错
工欲善其事必先利其器。判错的最终目的是用来暴露设计中的Bug并加以改正,所以将错误信息提供给编程者是必要的。
有时候需要将故障信息储存于非易失性存储器中,便于查看。这里以使用串口打印错误信息到PC显示屏为例,来说明一般需要显示什么信息。
编写或移植一个类似C标准库中的printf函数,可以格式化打印字符、字符串、十进制整数、十六进制整数。这里称为UARTprintf()。
unsigned int WriteData(unsigned int addr)
{
if((addr>= BASE_ADDR)&&(addr<=end_addr))> {
…/*地址合法,进行处理*/
}
else
{ /*地址错误,打印错误信息*/
UARTprintf ("文件%s的第 %d 行写数据时发生地址错误,错误地址为:0x%x\n",__FILE__,__LINE__,addr);
…/*错误处理代码*/
}
假设UARTprintf()函数位于main.c模块的第256行,并且WriteData()函数在读数据时传递了错误地址0x00000011,则会执行UARTprintf()函数,打印如下所示的信息:
文件main.c的第256行写数据时发生地址错误,错误地址为:0x00000011。类似这样的信息会有助于程序员定位分析错误产生的根源,更快的消除Bug。
2、判断实参是否合法
程序员可能无意识的传递了错误参数;外界的强干扰可能将传递的参数修改掉,或者使用随机参数意外的调用函数,因此在执行函数主体前,需要先确定实参是否合法。
int exam_fun( unsigned char *str )
{
if( str != NULL )
{ // 检查“假设指针不为空”这个条件
... //正常处理代码
}
else
{
UARTprintf(…); // 打印错误信息
…//处理错误代码
}
}
3、仔细检查函数的返回值
对函数返回的错误码,要进行全面仔细处理,必要时做错误记录。
char *DoSomething(…)
{
char * p;
p=malloc(1024);
if(p==NULL)
{ /*对函数返回值作出判断*/
UARTprintf(…); /*打印错误信息*/
return NULL;
}
retuen p;
}
4、防止指针越界
如果动态计算一个地址时,要保证被计算的地址是合理的并指向某个有意义的地方。特别对于指向一个结构或数组的内部的指针,当指针增加或者改变后仍然指向同一个结构或数组。
5、防止数组越界
数组越界的问题前文已经讲述的很多了,由于C不会对数组进行有效的检测,因此必须在应用中显式的检测数组越界问题。下面的例子可用于中断接收通讯数据。
#define REC_BUF_LEN 100
unsigned char RecBuf[REC_BUF_LEN];
… //其它代码
void Uart_IRQHandler(void)
{
static RecCount=0; //接收数据长度计数器
… //其它代码
if(RecCount < REC_BUF_LEN)
{
RecBuf[RecCount]=…; //从硬件取数据
RecCount++;
… //其它代码
}
else
{
UARTprintf(…); //打印错误信息
… //其它错误处理代码
}
…
}
在使用一些库函数时,同样需要对边界进行检查:
#define REC_BUF_LEN 100
unsigned char RecBuf[REC_BUF_LEN];
if(len< REC_BUF_LEN)
{
memset(RecBuf,0,len); //将数组RecBuf清零
}
else
{
//处理错误
}
6、数学算数运算
检测除数是否为零
检测运算溢出情况
「有符号整数除法,仅检测除数为零就够了吗?」
两个整数相除,除了要检测除数是否为零外,还要检测除法是否溢出。对于一个signed long类型变量,它能表示的数值范围为:-2147483648 ~ +2147483647,如果让-2147483648 / -1,那么结果应该是+ 2147483648,但是这个结果已经超出了signed long所能表示的范围了。
#include
signed long sl1,sl2,result;
/*初始化sl1和sl2*/
if((sl2==0)||((sl1==LONG_MIN) && (sl2==-1)))
{
//处理错误
}
else
{
result = sl1 / sl2;
}
「加法溢出检测:」
a)无符号加法
#include
unsigned int a,b,result;
/*初始化a,b*/
if(UINT_MAX-a
//处理溢出
}
else
{
result=a+b;
}
b)有符号加法
#includesigned int a,b,result;
/*初始化a,b */
if((a>0 && INT_MAX-a0) && (INT_MIN-a>b))
{
//处理溢出
}
else
{
result=a+b;
}
「乘法溢出检测:」
a)无符号乘法
#includeunsigned int a,b,result;
/*初始化a,b*/
if((a!=0) && (UINT_MAX/a{
//
}
else
{
result=a*b;
}
b)有符号乘法
#includesigned int a,b,tmp,result;
/*初始化a,b*/
tmp=a * b;
if(a!=0 && tmp/a!=b)
{
//
}
else
{
result=tmp;
}
7、其它可能出现运行时错误的地方
运行时错误检查是C 程序员需要加以特别的注意的,这是因为C语言在提供任何运行时检测方面能力较弱。对于要求可靠性较高的软件来说,动态检测是必需的。
因此C 程序员需要谨慎考虑的问题是,在任何可能出现运行时错误的地方增加代码的动态检测。大多数的动态检测与应用紧密相关,在程序设计过程中要根据系统需求设置动态代码检测。
8、编译器语义检查为了更简单的设计编译器,目前几乎所有编译器的语义检查都比较弱小,加之为了获得更快的执行效率,C语言被设计的足够灵活且几乎不进行任何运行时检查,比如数组越界、指针是否合法、运算结果是否溢出等等。
C语言足够灵活,对于一个数组a[30],它允许使用像a[-1]这样的形式来快速获取数组首元素所在地址前面的数据;允许将一个常数强制转换为函数指针,使用代码( * ((void( * )())0))()来调用位于0地址的函数。
C语言给了程序员足够的自由,但也由程序员承担滥用自由带来的责任。下面的两个例子都是死循环,如果在不常用分支中出现类似代码,将会造成看似莫名其妙的死机或者重启。
a. unsigned char i;for(i=0;i<256;i++) {… }
b. unsigned chari;
for(i=10;i>=0;i--) { … }
对于无符号char类型,表示的范围为0~255,所以无符号char类型变量i永远小于256(第一个for循环无限执行),永远大于等于0(第二个for循环无线执行)。需要说明的是,赋值代码i=256是被C语言允许的,即使这个初值已经超出了变量i可以表示的范围。C语言会千方百计的为程序员创造出错的机会,可见一斑。
假如你在if语句后误加了一个分号改变了程序逻辑,编译器也会很配合的帮忙掩盖,甚至连警告都不提示。代码如下:
if(a>b); //这里误加了一个分号a=b; //这句代码一直被执行
不但如此,编译器还会忽略掉多余的空格符和换行符,就像下面的代码也不会给出足够提示:
if(n<3)return //这里少加了一个分号
logrec.data=x[0];
logrec.time=x[1];
logrec.code=x[2];
这段代码的本意是n<3时程序直接返回,由于程序员的失误,return少了一个结束分号。编译器将它翻译成返回表达式logrec.data=x[0]的结果,return后面即使是一个表达式也是c语言允许的。这样当n>=3时,表达式logrec.data=x[0];就不会被执行,给程序埋下了隐患。
可以毫不客气的说,弱小的编译器语义检查在很大程度上纵容了不可靠代码可以肆无忌惮的存在。
上文曾提到数组常常是引起程序不稳定的重要因素,程序员往往不经意间就会写数组越界。一位同事的代码在硬件上运行,一段时间后就会发现LCD显示屏上的一个数字不正常的被改变。经过一段时间的调试,问题被定位到下面的一段代码中:
int SensorData[30];for(i=30;i>0;i--)
{
SensorData[i]=…;
…
}
这里声明了拥有30个元素的数组,不幸的是for循环代码中误用了本不存在的数组元素SensorData[30],但C语言却默许这么使用,并欣然的按照代码改变了数组元素SensorData[30]所在位置的值。
SensorData[30]所在的位置原本是一个LCD显示变量,这正是显示屏上的那个值不正常被改变的原因。真庆幸这么轻而易举的发现了这个Bug。
9、关键数据多区备份,取数据采用“表决法”RAM中的数据在受到干扰情况下有可能被改变,对于系统关键数据必须进行保护。关键数据包括全局变量、静态变量以及需要保护的数据区域。数据备份与原数据不应该处于相邻位置,因此不应由编译器默认分配备份数据位置,而应该由程序员指定区域存储。
可以将RAM分为3个区域,第一个区域保存原码,第二个区域保存反码,第三个区域保存异或码,区域之间预留一定量的“空白”RAM作为隔离。
可以使用编译器的“分散加载”机制将变量分别存储在这些区域。需要进行读取时,同时读出3份数据并进行表决,取至少有两个相同的那个值。
假如设备的RAM从0x1000_0000开始,我需要在RAM的0x1000_0000~0x10007FFF内存储原码,在0x1000_9000~0x10009FFF内存储反码,在0x1000_B000~0x1000BFFF内存储0xAA的异或码,编译器的分散加载可以设置为:
LR_IROM1 0x00000000 0x00080000 { ; load region size_regionER_IROM1 0x00000000 0x00080000 { ; load address = execution address
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
RW_IRAM1 0x10000000 0x00008000 { ;保存原码
.ANY (+RW +ZI )
}
RW_IRAM3 0x10009000 0x00001000{ ;保存反码
.ANY (MY_BK1)
}
RW_IRAM2 0x1000B000 0x00001000 { ;保存异或码
.ANY (MY_BK2)
}
}
如果一个关键变量需要多处备份,可以按照下面方式定义变量,将三个变量分别指定到三个不连续的RAM区中,并在定义时按照原码、反码、0xAA的异或码进行初始化。
uint32 plc_pc=0; //原码__attribute__((section("MY_BK1"))) uint32 plc_pc_not=~0x0; //反码
__attribute__((section("MY_BK2"))) uint32 plc_pc_xor=0x0^0xAAAAAAAA; //异或码
当需要写这个变量时,这三个位置都要更新;读取变量时,读取三个值做判断,取至少有两个相同的那个值。
为什么选取异或码而不是补码?这是因为MDK的整数是按照补码存储的,正数的补码与原码相同,在这种情况下,原码和补码是一致的,不但起不到冗余作用,反而对可靠性有害。
比如存储的一个非零整数区因为干扰,RAM都被清零,由于原码和补码一致,按照3取2的“表决法”,会将干扰值0当做正确的数据。
10、非易失性存储器的数据存储非易失性存储器包括但不限于Flash、EEPROM、铁电。仅仅将写入非易失性存储器中的数据再读出校验是不够的。强干扰情况下可能导致非易失性存储器内的数据错误,在写非易失性存储器的期间系统掉电将导致数据丢失,因干扰导致程序跑飞到写非易失性存储器函数中,将导致数据存储紊乱。
一种可靠的办法是将非易失性存储器分成多个区,每个数据都将按照不同的形式写入到这些分区中,需要进行读取时,同时读出多份数据并进行表决,取相同数目较多的那个值。
对于因干扰导致程序跑飞到写非易失性存储器函数,还应该配合软件锁以及严格的入口检验,单单依靠写数据到多个区是不够的也是不明智的,应该在源头进行阻截。
11、软件锁软件锁可以实现但不局限于环环相扣。对于初始化序列或者有一定先后顺序的函数调用,为了保证调用顺序或者确保每个函数都被调用,我们可以使用环环相扣,实质上这也是一种软件锁。此外对于一些安全关键代码语句(是语句,而不是函数),可以给它们设置软件锁,只有持有特定钥匙的,才可以访问这些关键代码。
比如,向Flash写一个数据,我们会判断数据是否合法、写入的地址是否合法,计算要写入的扇区。之后调用写Flash子程序,在这个子程序中,判断扇区地址是否合法、数据长度是否合法,之后就要将数据写入Flash。
由于写Flash语句是安全关键代码,所以程序给这些语句上锁:必须具有正确的钥匙才可以写Flash。这样即使是程序跑飞到写Flash子程序,也能大大降低误写的风险。
/**************************************************************** 名称:RamToFlash()
* 功能:复制RAM的数据到FLASH,命令代码51。
* 入口参数:dst 目标地址,即FLASH起始地址。以512字节为分界
* src 源地址,即RAM地址。地址必须字对齐
* no 复制字节个数,为512/1024/4096/8192
* ProgStart 软件锁标志
* 出口参数:IAP返回值(paramout缓冲区) CMD_SUCCESS,SRC_ADDR_ERROR,DST_ADDR_ERROR,
SRC_ADDR_NOT_MAPPED,DST_ADDR_NOT_MAPPED,COUNT_ERROR,BUSY,未选择扇区
****************************************************************/
void RamToFlash(uint32 dst, uint32 src, uint32 no,uint8 ProgStart)
{
PLC_ASSERT("Sector number",(dst>=0x00040000)&&(dst<=0x0007FFFF));
PLC_ASSERT("Copy bytes number is 512",(no==512));
PLC_ASSERT("ProgStart==0xA5",(ProgStart==0xA5));
paramin[0] = IAP_RAMTOFLASH; // 设置命令字
paramin[1] = dst; // 设置参数
paramin[2] = src;
paramin[3] = no;
paramin[4] = Fcclk/1000;
if(ProgStart==0xA5) //只有软件锁标志正确时,才执行关键代码
{
iap_entry(paramin, paramout); // 调用IAP服务程序
ProgStart=0;
}
else
{
paramout[0]=PROG_UNSTART;
}
}
该程序段是编程lpc1778内部Flash,其中调用IAP程序的函数iap_entry(paramin, paramout)是关键安全代码,所以在执行该代码前,先判断一个特定设置的安全锁标志ProgStart,只有这个标志符合设定值,才会执行编程Flash操作。
如果因为意外程序跑飞到该函数,由于ProgStart标志不正确,是不会对Flash进行编程的。
12、通信数据的检错通讯线上的数据误码相对严重,通讯线越长,所处的环境越恶劣,误码会越严重。抛开硬件和环境的作用,我们的软件应能识别错误的通讯数据。对此有一些应用措施:
制定协议时,限制每帧的字节数;
每帧字节数越多,发生误码的可能性就越大,无效的数据也会越多。对此以太网规定每帧数据不大于1500字节,高可靠性的CAN收发器规定每帧数据不得多于8字节,对于RS485,基于RS485链路应用最广泛的Modbus协议一帧数据规定不超过256字节。因此,建议制定内部通讯协议时,使用RS485时规定每帧数据不超过256字节;
使用多种校验
编写程序时应使能奇偶校验,每帧超过16字节的应用,建议至少编写CRC16校验程序。
增加额外判断
增加缓冲区溢出判断。这是因为数据接收多是在中断中完成,编译器检测不出缓冲区是否溢出,需要手动检查,在上文介绍数据溢出一节中已经详细说明。
增加超时判断。当一帧数据接收 到一半,长时间接收不到剩余数据,则认为这帧数据无效,重新开始接收。
可选,跟不同的协议有关,但缓冲区溢出判断必须实现。这是因为对于需要帧头判断的协议,上位机可能发送完帧头后突然断电,重启后上位机是从新的帧开始发送的,但是下位机已经接收到了上次未发送完的帧头,所以上位机的这次帧头会被下位机当成正常数据接收。
这有可能造成数据长度字段为一个很大的值,填满该长度的缓冲区需要相当多的数据(比如一帧可能1000字节),影响响应时间;另一方面,如果程序没有缓冲区溢出判断,那么缓冲区很可能溢出,后果是灾难性的。
重传机制
如果检测到通讯数据发生了错误,则要有重传机制重新发送出错的帧。
13、开关量输入的检测、确认开关量容易受到尖脉冲干扰,如果不进行滤除,可能会造成误动作。一般情况下,需要对开关量输入信号进行多次采样,并进行逻辑判断直到确认信号无误为止。多次采样之间需要有一定时间间隔,具体跟开关量的最大切换频率有关,一般不小于1ms。
14、开关量输出开关信号简单的一次输出是不安全的,干扰信号可能会翻转开关量输出的状态。采取重复刷新输出可以有效防止电平的翻转。
15、初始化信息的保存与恢复微处理器的寄存器值也可能会因外界干扰而改变,外设初始化值需要在寄存器中长期保存,最容易被破坏。由于Flash中的数据相对不易被破坏,可以将初始化信息预先写入Flash,待程序空闲时比较与初始化相关的寄存器值是否被更改,如果发现非法更改则使用Flash中的值进行恢复。
16、while循环有时候程序员会使用while(!flag);语句来等待标志flag改变,比如串口发送时用来等待一字节数据发送完成。这样的代码时存在风险的,如果因为某些原因标志位一直不改变则会造成系统死机。良好冗余的程序是设置一个超时定时器,超过一定时间后,强制程序退出while循环。
2003年8月11日发生的W32.Blaster.Worm蠕虫事件导致全球经济损失高达5亿美元,这个漏洞是利用了Windows分布式组件对象模型的远程过程调用接口中的一个逻辑缺陷:在调用GetMachineName()函数时,循环只设置了一个不充分的结束条件。
原代码简化如下所示:
HRESULT GetMachineName ( WCHAR *pwszPath,WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])
{
WCHAR *pwszServerName = wszMachineName;
WCHAR *pwszTemp = pwszPath + 2;
while ( *pwszTemp != L’\\’ ) /* 这句代码循环结束条件不充分 */
*pwszServerName++= *pwszTemp++;
/*… */
}
微软发布的安全补丁MS03-026解决了这个问题,为GetMachineName()函数设置了充分终止条件。一个解决代码简化如下所示(并非微软补丁代码):
HRESULT GetMachineName( WCHAR *pwszPath,WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])
{
WCHAR *pwszServerName = wszMachineName;
WCHAR *pwszTemp = pwszPath + 2;
WCHAR *end_addr = pwszServerName +MAX_COMPUTTERNAME_LENGTH_FQDN;
while ((*pwszTemp != L’\\’ ) && (*pwszTemp != L’\0’)
&& (pwszServerName/*充分终止条件*/
*pwszServerName++= *pwszTemp++;
/*… */
}
17、系统自检
对CPU、RAM、Flash、外部掉电保存存储器以及其他线路自检。
18、其它一些编程建议:深入理解嵌入式C语言以及编译器
细致、谨慎的编程
使用好的风格和合理的设计
不要仓促编写代码,写每一行的代码时都要三思而后行:可能会出现什么样的错误?是否考虑了所有的逻辑分支?
打开编译器所有警告开关
使用静态分析工具分析代码
安全的读写数据(检查所有数组边界…)
检查指针的合法性
检查函数入口参数合法性
检查所有返回值
在声明变量位置初始化所有变量
合理的使用括号
谨慎的进行强制转换
使用好的诊断信息日志和工具