出现灵异事件的嵌入式?——难查的数组越界、神奇的volatile、局部变量......

文章目录

  • 难查的数组越界
  • 神奇的volatile
  • 局部变量
  • 了解你的编译器
    • 编译器的一些小知识
    • 初始化的全局变量和静态变量的初始值被放到了哪里?
    • 在C代码中使用的变量,编译器将他们分配到RAM的哪里?
    • 默认情况下,栈被分配到RAM的哪个地方?
    • 有多少RAM会被初始化?
    • MDK编译器如何设置非零初始化变量?
  • 如果有硬件看门狗,则使用它
  • 通信
  • 阻塞处理
  • 简单易用的调试函数

难查的数组越界

数组常常是引起程序不稳定的重要因素,程序员往往不经意间就会写数组越界。

一位同事的代码在硬件上运行,一段时间后就会发现LCD显示屏上的一个数字不正常的被改变。经过一段时间的调试,问题被定位到下面的一段代码中:

1. int SensorData[30];
2. //其他代码 
3. for(i=30;i>0;i--)
4. {
     
5.      SensorData[i]=;
6.      //其他代码   
7. }

这里声明了拥有30个元素的数组,不幸的是for循环代码中误用了本不存在的数组元素SensorData[30],但C语言却默许这么使用,并欣然的按照代码改变了数组元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一个LCD显示变量,这正是显示屏上的那个值不正常被改变的原因。真庆幸这么轻而易举的发现了这个Bug。

其实很多编译器会对上述代码产生一个警告:赋值超出数组界限。

但并非所有程序员都对编译器警告保持足够敏感,况且,编译器也并不能检查出数组越界的所有情况。比如下面的例子:

你在模块A中定义数组:

int SensorData[30];

在模块B中引用该数组,但由于你引用代码并不规范,这里没有显示声明数组大小,但编译器也允许这么做:

extern int SensorData[]; 

这次,编译器不会给出警告信息,因为编译器压根就不知道数组的元素个数。所以,当一个数组声明为具有外部链接,它的大小应该显式声明。

再举一个编译器检查不出数组越界的例子。

函数func()的形参是一个数组形式,函数代码简化如下所示:

1. char * func(char SensorData[30])  
2. {
     
3.      unsignedint i;
4.      for(i=30;i>0;i--)
5.      {
     
6.           SensorData[i]=;
7.           //其他代码
8.      }
9. }

这个给SensorData[30]赋初值的语句,编译器也是不给任何警告的。

实际上,编译器是将数组名Sensor隐含的转化为指向数组第一个元素的指针,函数体是使用指针的形式来访问数组的,它当然也不会知道数组元素的个数了。造成这种局面的原因之一是C编译器的作者们认为指针代替数组可以提高程序效率,而且,可以简化编译器的复杂度。

指针和数组是容易给程序造成混乱的,我们有必要仔细的区分它们的不同。其实换一个角度想想,它们也是容易区分的:

可以将数组名等同于指针的情况有且只有一处,就是上面例子提到的数组作为函数形参时。其它时候,数组名是数组名,指针是指针。

下面的例子编译器同样检查不出数组越界。

我们常常用数组来缓存通讯中的一帧数据。在通讯中断中将接收的数据保存到数组中,直到一帧数据完全接收后再进行处理即使定义的数组长度足够长,接收数据的过程中也可能发生数组越界,特别是干扰严重时。这是由于外界的干扰破坏了数据帧的某些位,对一帧的数据长度判断错误,接收的数据超出数组范围,多余的数据改写与数组相邻的变量,造成系统崩溃。由于中断事件的异步性,这类数组越界编译器无法检查到。

如果局部数组越界,可能引发ARM架构硬件异常。

同事的一个设备用于接收无线传感器的数据,一次软件升级后,发现接收设备工作一段时间后会死机。调试表明ARM7处理器发生了硬件异常,异常处理代码是一段死循环(死机的直接原因)。接收设备有一个硬件模块用于接收无线传感器的整包数据并存在自己的缓冲区中,当硬件模块接收数据完成后,使用外部中断通知设备取数据,外部中断服务程序精简后如下所示:

1. __irq ExintHandler(void)  
2. {
     
3.      unsignedchar DataBuf[50];
4.      GetData(DataBug);        //从硬件缓冲区取一帧数据  
5.      //其他代码 
6. }

由于存在多个无线传感器近乎同时发送数据的可能加之GetData()函数保护力度不够,数组DataBuf在取数据过程中发生越界。由于数组DataBuf为局部变量,被分配在堆栈中,同在此堆栈中的还有中断发生时的运行环境以及中断返回地址。溢出的数据将这些数据破坏掉,中断返回时PC指针可能变成一个不合法值,硬件异常由此产生。

如果我们精心设计溢出部分的数据,化数据为指令,就可以利用数组越界来修改PC指针的值,使之指向我们希望执行的代码。

1988年,第一个网络蠕虫在一天之内感染了2000到6000台计算机,这个蠕虫程序利用的正是一个标准输入库函数的数组越界Bug。起因是一个标准输入输出库函数gets(),原来设计为从数据流中获取一段文本,遗憾的是,gets()函数没有规定输入文本的长度。gets()函数内部定义了一个500字节的数组,攻击者发送了大于500字节的数据,利用溢出的数据修改了堆栈中的PC指针,从而获取了系统权限。

目前,虽然有更好的库函数来代替gets函数,但gets函数仍然存在着。

神奇的volatile

做嵌入式设备开发,如果不对volatile修饰符具有足够了解,实在是说不过去。volatile是C语言32个关键字中的一个,属于类型限定符,常用的const关键字也属于类型限定符。

volatile限定符用来告诉编译器,该对象的值无任何持久性,不要对它进行任何优化;它迫使编译器每次需要该对象数据内容时都必须读该对象,而不是只读一次数据并将它放在寄存器中以便后续访问之用(这样的优化可以提高系统速度)。

这个特性在嵌入式应用中很有用,比如你的IO口的数据不知道什么时候就会改变,这就要求编译器每次都必须真正的读取该IO端口。这里使用了词语“真正的读”,是因为由于编译器的优化,你的逻辑反应到代码上是对的,但是代码经过编译器翻译后,有可能与你的逻辑不符。你的代码逻辑可能是每次都会读取IO端口数据,但实际上编译器将代码翻译成汇编时,可能只是读一次IO端口数据并保存到寄存器中,接下来的多次读IO口都是使用寄存器中的值来进行处理。因为读写寄存器是最快的,这样可以优化程序效率。与之类似的,中断里的变量、多线程中的共享变量等都存在这样的问题。

不使用volatile,可能造成运行逻辑错误,但是不必要的使用volatile会造成代码效率低下(编译器不优化volatile限定的变量),因此清楚的知道何处该使用volatile限定符,是一个嵌入式程序员的必修内容。

一个程序模块通常由两个文件组成,源文件和头文件。如果你在源文件定义变量:

unsigned int test; 

并在头文件中声明该变量:

 extern unsigned long test;

编译器会提示一个语法错误:变量’ test’声明类型不一致。但如果你在源文件定义变量:

volatile unsigned int test;

在头文件中这样声明变量:

extern unsigned int test;     /*缺少volatile限定符*/

编译器却不会给出错误信息(有些编译器仅给出一条警告)。当你在另外一个模块(该模块包含声明变量test的头文件)使用变量test时,它已经不再具有volatile限定,这样很可能造成一些重大错误。比如下面的例子,注意该例子是为了说明volatile限定符而专门构造出的,因为现实中的volatile使用Bug大都隐含,并且难以理解。

在模块A的源文件中,定义变量:

volatile unsigned int TimerCount=0;

该变量用来在一个定时器中断服务程序中进行软件计时:

TimerCount++; 

在模块A的头文件中,声明变量:

extern unsigned int TimerCount;   //这里漏掉了类型限定符volatile  

在模块B中,要使用TimerCount变量进行精确的软件延时:

1. #include “…A.h”                     //首先包含模块A的头文件  
2. //其他代码  
3. TimerCount=0;
4. while(TimerCount<=TIMER_VALUE);   //延时一段时间(感谢网友chhfish指出这里的逻辑错误)  
5. //其他代码  

实际上,这是一个死循环。由于模块A头文件中声明变量TimerCount时漏掉了volatile限定符,在模块B中,变量TimerCount是被当作unsigned int类型变量。由于寄存器速度远快于RAM,编译器在使用非volatile限定变量时是先将变量从RAM中拷贝到寄存器中,如果同一个代码块再次用到该变量,就不再从RAM中拷贝数据而是直接使用之前寄存器备份值。代码while(TimerCount<=TIMER_VALUE)中,变量TimerCount仅第一次执行时被使用,之后都是使用的寄存器备份值,而这个寄存器值一直为0,所以程序无限循环。图3-1的流程图说明了程序使用限定符volatile和不使用volatile的执行过程。
出现灵异事件的嵌入式?——难查的数组越界、神奇的volatile、局部变量......_第1张图片
为了更容易的理解编译器如何处理volatile限定符,这里给出未使用volatile限定符和使用volatile限定符程序的反汇编代码:

  • 没有使用关键字volatile,在keil MDK V4.54下编译,默认优化级别,如下所示(注意最后两行):
122:     unIdleCount=0;
2.    123:
3. 0x00002E10  E59F11D4  LDR       R1,[PC,#0x01D4]
4. 0x00002E14  E3A05000  MOV       R5,#key1(0x00000000)
5. 0x00002E18  E1A00005  MOV       R0,R5
6. 0x00002E1C  E5815000  STR       R5,[R1]
7.    124:     while(unIdleCount!=200);   //延时2S钟   
8.    125:
9.      0x00002E20  E35000C8  CMP       R0,#0x000000C8  
10. 0x00002E24  1AFFFFFD  BNE       0x00002E20</span>
  • 使用关键字volatile,在keil MDK V4.54下编译,默认优化级别,如下所示(注意最后三行):
122:     unIdleCount=0;
2.    123:
3. 0x00002E10  E59F01D4  LDR       R0,[PC,#0x01D4]
4. 0x00002E14  E3A05000  MOV       R5,#key1(0x00000000)
5. 0x00002E18  E5805000  STR       R5,[R0]
6.    124:     while(unIdleCount!=200);   //延时2S钟   
7.    125:
8. 0x00002E1C  E5901000  LDR       R1,[R0]
9. 0x00002E20  E35100C8  CMP       R1,#0x000000C8  
10. 0x00002E24  1AFFFFFC  BNE       0x00002E1C `

可以看到,如果没有使用volatile关键字,程序一直比较R0内数据与0xC8是否相等,但R0中的数据是0,所以程序会一直在这里循环比较(死循环);再看使用了volatile关键字的反汇编代码,程序会先从变量中读出数据放到R1寄存器中,然后再让R1内数据与0xC8相比较,这才是我们C代码的正确逻辑!

局部变量

ARM架构下的编译器会频繁的使用堆栈,堆栈用于存储函数的返回值、AAPCS规定的必须保护的寄存器以及局部变量,包括局部数组、结构体、联合体和C++的类。默认情况下,堆栈的位置、初始值都是由编译器设置,因此需要对编译器的堆栈有一定了解。从堆栈中分配的局部变量的初值是不确定的,因此需要运行时显式初始化该变量。一旦离开局部变量的作用域,这个变量立即被释放,其它代码也就可以使用它,因此堆栈中的一个内存位置可能对应整个程序的多个变量。

局部变量必须显式初始化,除非你确定知道你要做什么。下面的代码得到的温度值跟预期会有很大差别,因为在使用局部变量sum时,并不能保证它的初值为0。编译器会在第一次运行时清零堆栈区域,这加重了此类Bug的隐蔽性。

1. unsigned intGetTempValue(void)  
2. {
     
3.     unsigned int sum;                     //定义局部变量,保存总值  
4.     for(i=0;i<10;i++)
5.     {
     
6.         sum+=CollectTemp();               //函数CollectTemp可以得到当前的温度值  
7.     }
8.     return (sum/10);
9. }

由于一旦程序离开局部变量的作用域即被释放,所以下面代码返回指向局部变量的指针是没有实际意义的,该指针指向的区域可能会被其它程序使用,其值会被改变。

1. char * GetData(void)  
2. {
     
3.      char buffer[100];                 //局部数组  
4.5.      return buffer;
6. }

了解你的编译器

在嵌入式开发过程中,我们需要经常和编译器打交道,只有深入了解编译器,才能用好它,编写更高效代码,更灵活的操作硬件,实现一些高级功能。下面以公司最常用的Keil MDK为例,来描述一下编译器的细节。

编译器的一些小知识

  • 默认情况下,char类型的数据项是无符号的,所以它的取值范围是0~255;

  • 在所有的内部和外部标识符中,大写和小写字符不同;

  • 通常局部变量保存在寄存器中,但当局部变量太多放到栈里的时候,它们总是字对齐的。

  • 压缩类型的自然对齐方式为1。使用关键字__packed来压缩特定结构,将所有有效类型的对齐边界设置为1;

  • 整数以二进制补码形式表示;浮点量按IEEE格式存储;

  • 整数除法的余数的符号于被除数相同,由ISO C90标准得出;

  • 如果整型值被截断为短的有符号整型,则通过放弃适当数目的最高有效位来得到结果。如果原始数是太大的正或负数,对于新的类型,无法保证结果的符号将于原始数相同。

  • 整型数超界不引发异常;像unsigned char test; test=1000;这类是不会报错的;

  • 在严格C中,枚举值必须被表示为整型。例如,必须在‑2147483648 到+2147483647的范围内。但MDK自动使用对象包含enum范围的最小整型来实现(比如char类型),除非使用编译器命令‑‑enum_is_int 来强制将enum的基础类型设为至少和整型一样宽。超出范围的枚举值默认仅产生警告:#66:enumeration value is out of “int” range;

  • 对于结构体填充,根据定义结构的方式,keil MDK编译器用以下方式的一种来填充结构:

I> 定义为static或者extern的结构用零填充;

II> 栈或堆上的结构,例如,用malloc()或者auto定义的结构,使用先前存储在那些存储器位置的任何内容进行填充。不能使用memcmp()来比较以这种方式定义的填充结构!

编译器不对声明为volatile类型的数据进行优化;

  • __nop():延时一个指令周期,编译器绝不会优化它。如果硬件支持NOP指令,则该句被替换为NOP指令,如果硬件不支持NOP指令,编译器将它替换为一个等效于NOP的指令,具体指令由编译器自己决定;
  • __align(n):指示编译器在n 字节边界上对齐变量。对于局部变量,n的值为1、2、4、8;
  • attribute((at(address))):可以使用此变量属性指定变量的绝对地址;
  • __inline:提示编译器在合理的情况下内联编译C或C++ 函数;

初始化的全局变量和静态变量的初始值被放到了哪里?

我们程序中的一些全局变量和静态变量在定义时进行了初始化,经过编译器编译后,这些初始值被存放在了代码的哪里?我们举个例子说明:

1. unsigned int g_unRunFlag=0xA52. static unsigned int s_unCountFlag=0x5A

我曾做过一个项目,项目中的一个设备需要在线编程,也就是通过协议,将上位机发给设备的数据通过在应用编程(IAP)技术写入到设备的内部Flash中。我将内部Flash做了划分,一小部分运行程序,大部分用来存储上位机发来的数据。随着程序量的增加,在一次更新程序后发现,在线编程之后,设备运行正常,但是重启设备后,运行出现了故障!经过一系列排查,发现故障的原因是一个全局变量的初值被改变了。这是件很不可思议的事情,你在定义这个变量的时候指定了初始值,当你在第一次使用这个变量时却发现这个初值已经被改掉了!这中间没有对这个变量做任何赋值操作,其它变量也没有任何溢出,并且多次在线调试表明,进入main函数的时候,该变量的初值已经被改为一个恒定值。

要想知道为什么全局变量的初值被改变,就要了解这些初值编译后被放到了二进制文件的哪里。在此之前,需要先了解一点链接原理。

ARM映象文件各组成部分在存储系统中的地址有两种:一种是映象文件位于存储器时(通俗的说就是存储在Flash中的二进制代码)的地址,称为加载地址;一种是映象文件运行时(通俗的说就是给板子上电,开始运行Flash中的程序了)的地址,称为运行时地址。赋初值的全局变量和静态变量在程序还没运行的时候,初值是被放在Flash中的,这个时候他们的地址称为加载地址,当程序运行后,这些初值会从Flash中拷贝到RAM中,这时候就是运行时地址了。

原来,对于在程序中赋初值的全局变量和静态变量,程序编译后,MDK将这些初值放到Flash中,位于紧靠在可执行代码的后面。在程序进入main函数前,会运行一段库代码,将这部分数据拷贝至相应RAM位置。由于我的设备程序量不断增加,超过了为设备程序预留的Flash空间,在线编程时,将一部分存储全局变量和静态变量初值的Flash给重新编程了。在重启设备前,初值已经被拷贝到RAM中,所以这个时候程序运行是正常的,但重新上电后,这部分初值实际上是在线编程的数据,自然与初值不同了。

在C代码中使用的变量,编译器将他们分配到RAM的哪里?

我们会在代码中使用各种变量,比如全局变量、静态变量、局部变量,并且这些变量时由编译器统一管理的,有时候我们需要知道变量用掉了多少RAM,以及这些变量在RAM中的具体位置。这是一个经常会遇到的事情,举一个例子,程序中的一个变量在运行时总是不正常的被改变,那么有理由怀疑它临近的变量或数组溢出了,溢出的数据更改了这个变量值。要排查掉这个可能性,就必须知道该变量被分配到RAM的哪里、这个位置附近是什么变量,以便针对性的做跟踪。

其实MDK编译器的输出文件中有一个“工程名.map”文件,里面记录了代码、变量、堆栈的存储位置,通过这个文件,可以查看使用的变量被分配到RAM的哪个位置。要生成这个文件,需要在Options for Targer窗口,Listing标签栏下,勾选Linker Listing前的复选框,如图3-1所示。
出现灵异事件的嵌入式?——难查的数组越界、神奇的volatile、局部变量......_第2张图片

图3-1 设置编译器生产MAP文件

默认情况下,栈被分配到RAM的哪个地方?

MDK中,我们只需要在配置文件中定义堆栈大小,编译器会自动在RAM的空闲区域选择一块合适的地方来分配给我们定义的堆栈,这个地方位于RAM的那个地方呢?

通过查看MAP文件,原来MDK将堆栈放到程序使用到的RAM空间的后面,比如你的RAM空间从0x4000 0000开始,你的程序用掉了0x200字节RAM,那么堆栈空间就从0x4000 0200处开始。

使用了多少堆栈,是否溢出?

有多少RAM会被初始化?

在进入main()函数之前,MDK会把未初始化的RAM给清零的,我们的RAM可能很大,只使用了其中一小部分,MDK会不会把所有RAM都初始化呢?

答案是否定的,MDK只是把你的程序用到的RAM以及堆栈RAM给初始化,其它RAM的内容是不管的。如果你要使用绝对地址访问MDK未初始化的RAM,那就要小心翼翼的了,因为这些RAM上电时的内容很可能是随机的,每次上电都不同。

MDK编译器如何设置非零初始化变量?

对于控制类产品,当系统复位后(非上电复位),可能要求保持住复位前RAM中的数据,用来快速恢复现场,或者不至于因瞬间复位而重启现场设备。而keil mdk在默认情况下,任何形式的复位都会将RAM区的非初始化变量数据清零。

MDK编译程序生成的可执行文件中,每个输出段都最多有三个属性:RO属性、RW属性和ZI属性。对于一个全局变量或静态变量,用const修饰符修饰的变量最可能放在RO属性区,初始化的变量会放在RW属性区,那么剩下的变量就要放到ZI属性区了。默认情况下,ZI属性区的数据在每次复位后,程序执行main函数内的代码之前,由编译器“自作主张”的初始化为零。所以我们要在C代码中设置一些变量在复位后不被零初始化,那一定不能任由编译器“胡作非为”,我们要用一些规则,约束一下编译器。

分散加载文件对于连接器来说至关重要,在分散加载文件中,使用UNINIT来修饰一个执行节,可以避免编译器对该区节的ZI数据进行零初始化。这是要解决非零初始化变量的关键。因此我们可以定义一个UNINIT修饰的数据节,然后将希望非零初始化的变量放入这个区域中。于是,就有了第一种方法:
修改分散加载文件,增加一个名为MYRAM的执行节,该执行节起始地址为0x1000A000,长度为0x2000字节(8KB),由UNINIT修饰:

	1:   LR_IROM1 0x00000000 0x00080000  {
         ; load region size_region
    2:   ER_IROM1 0x00000000 0x00080000  {
       ; load address = execution address
    3:    *.o (RESET, +First)
    4:    *(InRoot$$Sections)
    5:    .ANY (+RO)
    6:   }
    7:   RW_IRAM1 0x10000000 0x0000A000  {
       ; RW data
    8:    .ANY (+RW +ZI)
    9:   }
   10:   MYRAM 0x1000A000 UNINIT 0x00002000  {
     
   11:    .ANY (NO_INIT)
   12:   }
   13: }

那么,如果在程序中有一个数组,你不想让它复位后零初始化,就可以这样来定义变量:

1. unsigned char  plc_eu_backup[32] __attribute__((at(0x1000A000)));

变量属性修饰符__attribute__((at(adde)))用来将变量强制定位到adde所在地址处。由于地址0x1000A000开始的8KB区域ZI变量不会被零初始化,所以位于这一区域的数组plc_eu_backup也就不会被零初始化了。

这种方法的缺点是显而易见的:要程序员手动分配变量的地址。如果非零初始化数据比较多,这将是件难以想象的大工程(以后的维护、增加、修改代码等等)。所以要找到一种办法,让编译器去自动分配这一区域的变量。

分散加载文件同方法1,如果还是定义一个数组,可以用下面方法:

unsigned char  plc_eu_backup[32] __attribute__((section("NO_INIT"),zero_init));

变量属性修饰符__attribute__((section(“name”),zero_init))用于将变量强制定义到name属性数据节中,zero_init表示将未初始化的变量放到ZI数据节中。因为“NO_INIT”这显性命名的自定义节,具有UNINIT属性。

将一个模块内的非初始化变量都非零初始化

假如该模块名字为test.c,修改分散加载文件如下所示:

	1: LR_IROM1 0x00000000 0x00080000  {
         ; load region size_region
    2:   ER_IROM1 0x00000000 0x00080000  {
       ; load address = execution address
    3:    *.o (RESET, +First)
    4:    *(InRoot$$Sections)
    5:    .ANY (+RO)
    6:   }
    7:   RW_IRAM1 0x10000000 0x0000A000  {
       ; RW data
    8:    .ANY (+RW +ZI)
    9:   }
   10:   RW_IRAM2 0x1000A000 UNINIT 0x00002000  {
     
   11:    test.o (+ZI)
   12:   }
   13: }

在该模块定义时变量时使用如下方法:

这里,变量属性修饰符__attribute__((zero_init))用于将未初始化的变量放到ZI数据节中变量,其实MDK默认情况下,未初始化的变量就是放在ZI数据区的。

如果有硬件看门狗,则使用它

在其它一切措施都失效的情况下,看门狗可能是最后的防线。它的原理特别简单,但却能大大提高设备的可靠性。如果设备有硬件看门狗,一定要为它编写驱动程序。

要尽可能早的开启看门狗

这是因为从上电复位结束到开启看门狗的这段时间内,设备有可能被干扰而跳过看门狗初始化程序,导致看门狗失效。尽可能早的开启看门狗,可以降低这种概率;

不要在中断中喂狗,除非有其他联动措施

在中断程序喂狗,由于干扰的存在,程序可能一直处于中断之中,这样会导致看门狗失效。如果在主程序中设置标志位,中断程序喂狗时与这个标志位联合判断,也是允许的;

喂狗间隔跟产品需求有关,并非特定的时间

产品的特性决定了喂狗间隔。对于不涉及安全性、实时性的设备,喂狗间隔比较宽松,但间隔时间不宜过长,否则被用户感知到,是影响用户体验的。对于设计安全性、有实时控制类的设备,原则是尽可能快的复位,否则会造成事故。

克莱门汀号在进行第二阶段的任务时,原本预订要从月球飞行到太空深处的Geographos小行星进行探勘,然而这艘太空探测器在飞向小行星时却由于一个软件缺陷而使其中断运作20分钟,不但未能到达小行星,也因为控制喷嘴燃烧了11分钟使电力供应降低,无法再透过远端控制探测器,最终结束这项任务,但也导致了资源与资金的浪费。

“克莱门汀太空任务失败这件事让我感到十分震惊,它其实可以透过硬件中一款简单的看门狗计时器避免掉这项意外,但由于当时的开发时间相当紧缩,程序设计人员没时间编写程序来启动它,”Ganssle说。

遗憾的是,1998年发射的近地号太空船(NEAR)也遇到了相同的问题。由于编程人员并未采纳建议,因此,当推进器减速器系统故障时,29公斤的储备燃料也随之报销──这同样是一个本来可经由看门狗定时器编程而避免的问题,同时也证明要从其他程序设计人员的错误中学习并不容易。

通信

通讯线上的数据误码相对严重,通讯线越长,所处的环境越恶劣,误码会越严重。抛开硬件和环境的作用,我们的软件应能识别错误的通讯数据。对此有一些应用措施:

制定协议时,限制每帧的字节数;

每帧字节数越多,发生误码的可能性就越大,无效的数据也会越多。对此以太网规定每帧数据不大于1500字节,高可靠性的CAN收发器规定每帧数据不得多于8字节,对于RS485,基于RS485链路应用最广泛的Modbus协议一帧数据规定不超过256字节。因此,建议制定内部通讯协议时,使用RS485时规定每帧数据不超过256字节;

使用多种校验

编写程序时应使能奇偶校验,每帧超过16字节的应用,建议至少编写CRC16校验程序;

增加额外判断

1)增加缓冲区溢出判断。这是因为数据接收多是在中断中完成,编译器检测不出缓冲区是否溢出,需要手动检查,在上文介绍数据溢出一节中已经详细说明。

2)增加超时判断。当一帧数据接收到一半,长时间接收不到剩余数据,则认为这帧数据无效,重新开始接收。可选,跟不同的协议有关,但缓冲区溢出判断必须实现。这是因为对于需要帧头判断的协议,上位机可能发送完帧头后突然断电,重启后上位机是从新的帧开始发送的,但是下位机已经接收到了上次未发送完的帧头,所以上位机的这次帧头会被下位机当成正常数据接收。这有可能造成数据长度字段为一个很大的值,填满该长度的缓冲区需要相当多的数据(比如一帧可能1000字节),影响响应时间;另一方面,如果程序没有缓冲区溢出判断,那么缓冲区很可能溢出,后果是灾难性的。

重传机制

如果检测到通讯数据发生了错误,则要有重传机制重新发送出错的帧。

阻塞处理

有时候程序员会使用while(!flag);语句阻塞在此等待标志flag改变,比如串口发送时用来等待一字节数据发送完成。这样的代码时存在风险的,如果因为某些原因标志位一直不改变则会造成系统死机。

一个良好冗余的程序是设置一个超时定时器,超过一定时间后,强制程序退出while循环。
2003年8月11日发生的W32.Blaster.Worm蠕虫事件导致全球经济损失高达5亿美元,这个漏洞是利用了Windows分布式组件对象模型的远程过程调用接口中的一个逻辑缺陷:在调用GetMachineName()函数时,循环只设置了一个不充分的结束条件。

原代码简化如下所示:

1. HRESULT GetMachineName ( WCHAR *pwszPath,  
2. WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])
3. {
     
4.        WCHAR *pwszServerName = wszMachineName;
5.        WCHAR *pwszTemp = pwszPath + 2;
6.        while ( *pwszTemp != L’\\’ )           /* 这句代码循环结束条件不充分 */  
7.              *pwszServerName++= *pwszTemp++;
8.        /*… */  
9. }

微软发布的安全补丁MS03-026解决了这个问题,为GetMachineName()函数设置了充分终止条件。一个解决代码简化如下所示(并非微软补丁代码):

1. HRESULT GetMachineName( WCHAR *pwszPath,  
2. WCHARwszMachineName[MAX_COMPUTTERNAME_LENGTH_FQDN+1])
3. {
     
4.        WCHAR *pwszServerName = wszMachineName;
5.        WCHAR *pwszTemp = pwszPath + 2;
6.        WCHAR *end_addr = pwszServerName +MAX_COMPUTTERNAME_LENGTH_FQDN;
7.        while (*pwszTemp != L’\\’ ) && (*pwszTemp != L’\0)
8. && (pwszServerName<end_addr))  /*充分终止条件*/  
9.              *pwszServerName++= *pwszTemp++;
10.        /*… */  
11. }

简单易用的调试函数

使用库函数printf。以MDK为例,方法如下:

I>初始化串口

II>重构fputc函数,printf函数会调用fputc函数执行底层串口的数据发送。

1. /** 
2.   * @brief  将C库中的printf函数重定向到指定的串口. 
3.   * @param  ch:要发送的字符 
4.   * @param  f :文件指针 
5.   */  
6. int fputc(int ch, FILE *f)  
7. {
     
8.   
9.     /*这里是一个跟硬件相关函数,将一个字符写到UART */  
10.     //举例:USART_SendData(UART_COM1, (uint8_t) ch);  
11.       
12.     return ch;
13. }

III> 在Options for Targer窗口,Targer标签栏下,勾选Use MicroLIB前的复选框以便避免使用半主机功能。(注:标准C库printf函数默认开启半主机功能,如果非要使用标准C库,请自行查阅资料)

你可能感兴趣的:(STM32)