1. 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 定义为static或者extern的结构用零填充;
II> 栈或堆上的结构,例如,用malloc()或者auto定义的结构,使用先前存储在那些存储器位置的任何内容进行填充。不能使用memcmp()来比较以这种方式定义的填充结构!
11)编译器不对声明为volatile类型的数据进行优化;
_ _ nop():延时一个指令周期,编译器绝不会优化它。如果硬件支持NOP指令,则该句被替换为NOP指令,如果硬件不支持NOP指令,编译器将它替换为一个等效于NOP的指令,具体指令由编译器自己决定;
_ _ align(n):指示编译器在n 字节边界上对齐变量。对于局部变量,n的值为1、2、4、8;
14)_ _ attribute _ _ ((at(address))):可以使用此变量属性指定变量的绝对地址;
_ _ inline:提示编译器在合理的情况下内联编译C或C++ 函数;
3.4.2初始化的全局变量和静态变量的初始值被放到了哪里?
我们程序中的一些全局变量和静态变量在定义时进行了初始化,经过编译器编译后,这些初始值被存放在了代码的哪里?我们举个例子说明:
1. unsigned int g_unRunFlag=0xA5;
2. static unsigned int s_unCountFlag=0x5A;
我曾做过一个项目,项目中的一个设备需要在线编程,也就是通过协议,将上位机发给设备的数据通过在应用编程(IAP)技术写入到设备的内部Flash中。我将内部Flash做了划分,一小部分运行程序,大部分用来存储上位机发来的数据。随着程序量的增加,在一次更新程序后发现,在线编程之后,设备运行正常,但是重启设备后,运行出现了故障!
经过一系列排查,发现故障的原因是一个全局变量的初值被改变了。这是件很不可思议的事情,你在定义这个变量的时候指定了初始值,当你在第一次使用这个变量时却发现这个初值已经被改掉了!这中间没有对这个变量做任何赋值操作,其它变量也没有任何溢出,并且多次在线调试表明,进入main函数的时候,该变量的初值已经被改为一个恒定值。
要想知道为什么全局变量的初值被改变,就要了解这些初值编译后被放到了二进制文件的哪里。在此之前,需要先了解一点链接原理。
ARM映象文件各组成部分在存储系统中的地址有两种:一种是映象文件位于存储器时(通俗的说就是存储在Flash中的二进制代码)的地址,称为加载地址;一种是映象文件运行时(通俗的说就是给板子上电,开始运行Flash中的程序了)的地址,称为运行时地址。
赋初值的全局变量和静态变量在程序还没运行的时候,初值是被放在Flash中的,这个时候他们的地址称为加载地址,当程序运行后,这些初值会从Flash中拷贝到RAM中,这时候就是运行时地址了。
原来,对于在程序中赋初值的全局变量和静态变量,程序编译后,MDK将这些初值放到Flash中,位于紧靠在可执行代码的后面。在程序进入main函数前,会运行一段库代码,将这部分数据拷贝至相应RAM位置。由于我的设备程序量不断增加,超过了为设备程序预留的Flash空间,在线编程时,将一部分存储全局变量和静态变量初值的Flash给重新编程了。
在重启设备前,初值已经被拷贝到RAM中,所以这个时候程序运行是正常的,但重新上电后,这部分初值实际上是在线编程的数据,自然与初值不同了。
3.4.3在C代码中使用的变量,编译器将他们分配到RAM的哪里?
我们会在代码中使用各种变量,比如全局变量、静态变量、局部变量,并且这些变量时由编译器统一管理的,有时候我们需要知道变量用掉了多少RAM,以及这些变量在RAM中的具体位置。
这是一个经常会遇到的事情,举一个例子,程序中的一个变量在运行时总是不正常的被改变,那么有理由怀疑它临近的变量或数组溢出了,溢出的数据更改了这个变量值。要排查掉这个可能性,就必须知道该变量被分配到RAM的哪里、这个位置附近是什么变量,以便针对性的做跟踪。
其实MDK编译器的输出文件中有一个“工程名.map”文件,里面记录了代码、变量、堆栈的存储位置,通过这个文件,可以查看使用的变量被分配到RAM的哪个位置。要生成这个文件,需要在Options for Targer窗口,Listing标签栏下,勾选Linker Listing前的复选框,如图3-1所示。
图3-1 设置编译器生产MAP文件
3.4.4默认情况下,栈被分配到RAM的哪个地方?
MDK中,我们只需要在配置文件中定义堆栈大小,编译器会自动在RAM的空闲区域选择一块合适的地方来分配给我们定义的堆栈,这个地方位于RAM的那个地方呢?
通过查看MAP文件,原来MDK将堆栈放到程序使用到的RAM空间的后面,比如你的RAM空间从0x4000 0000开始,你的程序用掉了0x200字节RAM,那么堆栈空间就从0x4000 0200处开始。
使用了多少堆栈,是否溢出?
3.4.5 有多少RAM会被初始化?
在进入main()函数之前,MDK会把未初始化的RAM给清零的,我们的RAM可能很大,只使用了其中一小部分,MDK会不会把所有RAM都初始化呢?
答案是否定的,MDK只是把你的程序用到的RAM以及堆栈RAM给初始化,其它RAM的内容是不管的。如果你要使用绝对地址访问MDK未初始化的RAM,那就要小心翼翼的了,因为这些RAM上电时的内容很可能是随机的,每次上电都不同。
3.4.6 MDK编译器如何设置非零初始化变量?
对于控制类产品,当系统复位后(非上电复位),可能要求保持住复位前RAM中的数据,用来快速恢复现场,或者不至于因瞬间复位而重启现场设备。而keil mdk在默认情况下,任何形式的复位都会将RAM区的非初始化变量数据清零。
MDK编译程序生成的可执行文件中,每个输出段都最多有三个属性:RO属性、RW属性和ZI属性。对于一个全局变量或静态变量,用const修饰符修饰的变量最可能放在RO属性区,初始化的变量会放在RW属性区,那么剩下的变量就要放到ZI属性区了。
默认情况下,ZI属性区的数据在每次复位后,程序执行main函数内的代码之前,由编译器“自作主张”的初始化为零。所以我们要在C代码中设置一些变量在复位后不被零初始化,那一定不能任由编译器“胡作非为”,我们要用一些规则,约束一下编译器。
分散加载文件对于连接器来说至关重要,在分散加载文件中,使用UNINIT来修饰一个执行节,可以避免编译器对该区节的ZI数据进行零初始化。这是要解决非零初始化变量的关键。因此我们可以定义一个UNINIT修饰的数据节,然后将希望非零初始化的变量放入这个区域中。于是,就有了第一种方法:
1)修改分散加载文件,增加一个名为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也就不会被零初始化了。
这种方法的缺点是显而易见的:要程序员手动分配变量的地址。如果非零初始化数据比较多,这将是件难以想象的大工程(以后的维护、增加、修改代码等等)。所以要找到一种办法,让编译器去自动分配这一区域的变量。
2)分散加载文件同方法1,如果还是定义一个数组,可以用下面方法:
unsigned char plc_eu_backup[32] __attribute__((p("NO_INIT"),zero_init));
变量属性修饰符__attribute__((p(“name”),zero_init))用于将变量强制定义到name属性数据节中,zero_init表示将未初始化的变量放到ZI数据节中。因为“NO_INIT”这显性命名的自定义节,具有UNINIT属性。
3)将一个模块内的非初始化变量都非零初始化
假如该模块名字为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数据区的。
4.防御性编程
嵌入式产品的可靠性自然与硬件密不可分,但在硬件确定、并且没有第三方测试的前提下,使用防御性编程思想写出的代码,往往具有更高的稳定性。
防御性编程首先需要认清C语言的种种缺陷和陷阱,C语言对于运行时的检查十分弱小,需要程序员谨慎的考虑代码,在必要的时候增加判断;防御性编程的另一个核心思想是假设代码运行在并不可靠的硬件上,外接干扰有可能会打乱程序执行顺序、更改RAM存储数据等等。
4.1具有形参的函数,需判断传递来的实参是否合法。
程序员可能无意识的传递了错误参数;外界的强干扰可能将传递的参数修改掉,或者使用随机参数意外的调用函数,因此在执行函数主体前,需要先确定实参是否合法。
1. int exam_fun( unsigned char *str )
2. {
3. if( str != NULL ) // 检查“假设指针不为空”这个条件
4. {
5. //正常处理代码
6. }
7. else
8. {
9. //处理错误代码
10. }
11. }
4.2仔细检查函数的返回值
对函数返回的错误码,要进行全面仔细处理,必要时做错误记录。
1. char *DoSomething(…)
2. {
3. char * p;
4. p=malloc(1024);
5. if(p==NULL) /*对函数返回值作出判断*/
6. {
7. UARTprintf(…); /*打印错误信息*/
8. return NULL;
9. }
10. retuen p;
11. }
4.3 防止指针越界
如果动态计算一个地址时,要保证被计算的地址是合理的并指向某个有意义的地方。特别对于指向一个结构或数组的内部的指针,当指针增加或者改变后仍然指向同一个结构或数组。
4.4 防止数组越界
数组越界的问题前文已经讲述的很多了,由于C不会对数组进行有效的检测,因此必须在应用中显式的检测数组越界问题。下面的例子可用于中断接收通讯数据。
1. #define REC_BUF_LEN 100
2. unsigned char RecBuf[REC_BUF_LEN];
3. //其它代码
4. void Uart_IRQHandler(void)
5. {
6. static RecCount=0; //接收数据长度计数器
7. //其它代码
8. if(RecCount< REC_BUF_LEN) //判断数组是否越界
9. {
10. RecBuf[RecCount]=…; //从硬件取数据
11. RecCount++;
12. //其它代码
13. }
14. else
15. {
16. //错误处理代码
17. }
18. //其它代码
19. }
在使用一些库函数时,同样需要对边界进行检查,比如下面的memset(RecBuf,0,len)函数把RecBuf指指向的内存区的前len个字节用0填充,如果不注意len的长度,就会将数组RecBuf之外的内存区清零:
1. #define REC_BUF_LEN 100
2. unsigned char RecBuf[REC_BUF_LEN];
3.
4. if(len< REC_BUF_LEN)
5. {
6. memset(RecBuf,0,len); //将数组RecBuf清零
7. }
8. else
9. {
10. //处理错误
11. }
4.5 数学算数运算 4.5.1除法运算,只检测除数为零就可靠吗?
除法运算前,检查除数是否为零几乎已经成为共识,但是仅检查除数是否为零就够了吗?
考虑两个整数相除,对于一个signed long类型变量,它能表示的数值范围为:-2147483648 ~+2147483647,如果让-2147483648/ -1,那么结果应该是+2147483648,但是这个结果已经超出了signedlong所能表示的范围了。所以,在这种情况下,除了要检测除数是否为零外,还要检测除法是否溢出。
1. #include
2. signed long sl1,sl2,result;
3. /*初始化sl1和sl2*/
4. if((sl2==0)||(sl1==LONG_MIN && sl2==-1))
5. {
6. //处理错误
7. }
8. else
9. {
10. result = sl1 / sl2;
11. }
4.5.2检测运算溢出
整数的加减乘运算都有可能发生溢出,在讨论未定义行为时,给出过一个有符号整形加法溢出判断代码,这里再给出一个无符号整形加法溢出判断代码段:
1. #include
2. unsigned int a,b,result;
3. /*初始化a,b*/
4. if(UINT_MAX-a
嵌入式硬件一般没有浮点处理器,浮点数运算在嵌入式也比较少见并且溢出判断严重依赖C库支持,这里不讨论。
4.5.3检测移位
在讨论未定义行为时,提到有符号数右移、移位的数量是负值或者大于操作数的位数都是未定义行为,也提到不对有符号数进行位操作,但要检测移位的数量是否大于操作数的位数。下面给出一个无符号整数左移检测代码段:
1. unsigned int ui1;
2. unsigned int ui2;
3. unsigned int uresult;
4.
5. /*初始化ui1,ui2*/
6. if(ui2>=sizeof(unsigned int)*CHAR_BIT)
7. {
8. //处理错误
9. }
10. else
11. {
12. uresult=ui1<
4.6如果有硬件看门狗,则使用它
在其它一切措施都失效的情况下,看门狗可能是最后的防线。它的原理特别简单,但却能大大提高设备的可靠性。如果设备有硬件看门狗,一定要为它编写驱动程序。
克莱门汀号在进行第二阶段的任务时,原本预订要从月球飞行到太空深处的Geographos小行星进行探勘,然而这艘太空探测器在飞向小行星时却由于一个软件缺陷而使其中断运作20分钟,不但未能到达小行星,也因为控制喷嘴燃烧了11分钟使电力供应降低,无法再透过远端控制探测器,最终结束这项任务,但也导致了资源与资金的浪费。
“克莱门汀太空任务失败这件事让我感到十分震惊,它其实可以透过硬件中一款简单的看门狗计时器避免掉这项意外,但由于当时的开发时间相当紧缩,程序设计人员没时间编写程序来启动它,”Ganssle说。
遗憾的是,1998年发射的近地号太空船(NEAR)也遇到了相同的问题。由于编程人员并未采纳建议,因此,当推进器减速器系统故障时,29公斤的储备燃料也随之报销──这同样是一个本来可经由看门狗定时器编程而避免的问题,同时也证明要从其他程序设计人员的错误中学习并不容易。
4.7关键数据储存多个备份,取数据采用“表决法”
RAM中的数据在受到干扰情况下有可能被改变,对于系统关键数据应该进行保护。关键数据包括全局变量、静态变量以及需要保护的数据区域。备份数据与原数据不应该处于相邻位置,因此不应由编译器默认分配备份数据位置,而应该由程序员指定区域存储。
可以将RAM分为3个区域,第一个区域保存原码,第二个区域保存反码,第三个区域保存异或码,区域之间预留一定量的“空白”RAM作为隔离。可以使用编译器的“分散加载”机制将变量分别存储在这些区域。需要进行读取时,同时读出3份数据并进行表决,取至少有两个相同的那个值。
假如设备的RAM从0x1000_0000开始,我需要在RAM的0x1000_0000~0x10007FFF内存储原码,在0x1000_9000~0x10009FFF内存储反码,在0x1000_B000~0x1000BFFF内存储0xAA的异或码,编译器的分散加载可以设置为:
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 0x00008000 { ;保存原码
8. .ANY (+RW +ZI )
9. }
10.
11. RW_IRAM3 0x10009000 0x00001000{ ;保存反码
12. .ANY (MY_BK1)
13. }
14.
15. RW_IRAM2 0x1000B000 0x00001000 { ;保存异或码
16. .ANY (MY_BK2)
17. }
18. }
如果一个关键变量需要多处备份,可以按照下面方式定义变量,将三个变量分别指定到三个不连续的RAM区中,并在定义时按照原码、反码、0xAA的异或码进行初始化。
1. uint32 plc_pc=0; //原码
2. __attribute__((p("MY_BK1"))) uint32 plc_pc_not=~0x0; //反码
3. __attribute__((p("MY_BK2"))) uint32 plc_pc_xor=0x0^0xAAAAAAAA; //异或码
当需要写这个变量时,这三个位置都要更新;读取变量时,读取三个值做判断,取至少有两个相同的那个值。
为什么选取异或码而不是补码?这是因为MDK的整数是按照补码存储的,正数的补码与原码相同,在这种情况下,原码和补码是一致的,不但起不到冗余作用,反而对可靠性有害。比如存储的一个非零整数区因为干扰,RAM都被清零,由于原码和补码一致,按照3取2的“表决法”,会将干扰值0当做正确的数据。
4.8对非易失性存储器进行备份存储
非易失性存储器包括但不限于Flash、EEPROM、铁电。仅仅将写入非易失性存储器中的数据再读出校验是不够的。强干扰情况下可能导致非易失性存储器内的数据错误,在写非易失性存储器的期间系统掉电将导致数据丢失,因干扰导致程序跑飞到写非易失性存储器函数中,将导致数据存储紊乱。
一种可靠的办法是将非易失性存储器分成多个区,每个数据都将按照不同的形式写入到这些分区中,需要进行读取时,同时读出多份数据并进行表决,取相同数目较多的那个值。
4.9软件锁
对于初始化序列或者有一定先后顺序的函数调用,为了保证调用顺序或者确保每个函数都被调用,我们可以使用环环相扣,实质上这也是一种软件锁。此外对于一些安全关键代码语句(是语句,而不是函数),可以给它们设置软件锁,只有持有特定钥匙的,才可以访问这些关键代码。也可以通俗的理解为,关键安全代码不能按照单一条件执行,要额外的多设置一个标志。
比如,向Flash写一个数据,我们会判断数据是否合法、写入的地址是否合法,计算要写入的扇区。之后调用写Flash子程序,在这个子程序中,判断扇区地址是否合法、数据长度是否合法,之后就要将数据写入Flash。
由于写Flash语句是安全关键代码,所以程序给这些语句上锁:必须具有正确的钥匙才可以写Flash。这样即使是程序跑飞到写Flash子程序,也能大大降低误写的风险。
1. /****************************************************************************
2. * 名称:RamToFlash()
3. * 功能:复制RAM的数据到FLASH,命令代码51。
4. * 入口参数:dst 目标地址,即FLASH起始地址。以512字节为分界
5. * src 源地址,即RAM地址。地址必须字对齐
6. * no 复制字节个数,为512/1024/4096/8192
7. * ProgStart 软件锁标志
8. * 出口参数:IAP返回值(paramout缓冲区) CMD_SUCCESS,SRC_ADDR_ERROR,DST_ADDR_ERROR,
9. SRC_ADDR_NOT_MAPPED,DST_ADDR_NOT_MAPPED,COUNT_ERROR,BUSY,未选择扇区
10. ****************************************************************************/
11. void RamToFlash(uint32 dst, uint32 src, uint32 no,uint8 ProgStart)
12. {
13. PLC_ASSERT("Sector number",(dst>=0x00040000)&&(dst<=0x0007FFFF));
14. PLC_ASSERT("Copy bytes number is 512",(no==512));
15. PLC_ASSERT("ProgStart==0xA5",(ProgStart==0xA5));
16.
17. paramin[0] = IAP_RAMTOFLASH; // 设置命令字
18. paramin[1] = dst; // 设置参数
19. paramin[2] = src;
20. paramin[3] = no;
21. paramin[4] = Fcclk/1000;
22. if(ProgStart==0xA5) //只有软件锁标志正确时,才执行关键代码
23. {
24. iap_entry(paramin, paramout); // 调用IAP服务程序
25. ProgStart=0;
26. }
27. else
28. {
29. paramout[0]=PROG_UNSTART;
30. }
31. }
该程序段是编程lpc1778内部Flash,其中调用IAP程序的函数iap_entry(paramin, paramout)是关键安全代码,所以在执行该代码前,先判断一个特定设置的安全锁标志ProgStart,只有这个标志符合设定值,才会执行编程Flash操作。如果因为意外程序跑飞到该函数,由于ProgStart标志不正确,是不会对Flash进行编程的。
4.10通信
通讯线上的数据误码相对严重,通讯线越长,所处的环境越恶劣,误码会越严重。抛开硬件和环境的作用,我们的软件应能识别错误的通讯数据。对此有一些应用措施:
2)增加超时判断。当一帧数据接收到一半,长时间接收不到剩余数据,则认为这帧数据无效,重新开始接收。
可选,跟不同的协议有关,但缓冲区溢出判断必须实现。这是因为对于需要帧头判断的协议,上位机可能发送完帧头后突然断电,重启后上位机是从新的帧开始发送的,但是下位机已经接收到了上次未发送完的帧头。
所以上位机的这次帧头会被下位机当成正常数据接收。这有可能造成数据长度字段为一个很大的值,填满该长度的缓冲区需要相当多的数据(比如一帧可能1000字节),影响响应时间;另一方面,如果程序没有缓冲区溢出判断,那么缓冲区很可能溢出,后果是灾难性的。
4.11开关量输入的检测、确认
开关量容易受到尖脉冲干扰,如果不进行滤除,可能会造成误动作。一般情况下,需要对开关量输入信号进行多次采样,并进行逻辑判断直到确认信号无误为止。
4.12开关量输出
开关信号简单的一次输出是不安全的,干扰信号可能会翻转开关量输出的状态。采取重复刷新输出可以有效防止电平的翻转。
4.13初始化信息的保存和恢复
微处理器的寄存器值也可能会因外界干扰而改变,外设初始化值需要在寄存器中长期保存,最容易被破坏。由于Flash中的数据相对不易被破坏,可以将初始化信息预先写入Flash,待程序空闲时比较与初始化相关的寄存器值是否被更改,如果发现非法更改则使用Flash中的值进行恢复。
公司目前使用的4.3寸LCD显示屏抗干扰能力一般。如果显示屏与控制器之间的排线距离过长或者对使用该显示屏的设备打静电或者脉冲群,显示屏有可能会花屏或者白屏。对此,我们可以将初始化显示屏的数据保存在Flash中,程序运行后,每隔一段时间从显示屏的寄存器读出当前值和Flash存储的值相比较,如果发现两者不同,则重新初始化显示屏。下面给出校验源码,仅供参考。
定义数据结构:
1. typedef struct {
2. uint8_t lcd_command; //LCD寄存器
3. uint8_t lcd_get_value[8]; //初始化时写入寄存器的值
4. uint8_t lcd_value_num; //初始化时写入寄存器值的数目
5. }lcd_redu_list_struct;
定义const修饰的结构体变量,存储LCD部分寄存器的初始值,这个初始值跟具体的应用初始化有关,不一定是表中的数据,通常情况下,这个结构体变量被存储到Flash中。
1. /*LCD部分寄存器设置值列表*/
2. lcd_redu_list_struct const lcd_redu_list_str[]=
3. {
4. {SSD1963_Get_Address_Mode,{0x20} ,1}, /*1*/
5. {SSD1963_Get_Pll_Mn ,{0x3b,0x02,0x04} ,3}, /*2*/
6. {SSD1963_Get_Pll_Status ,{0x04} ,1}, /*3*/
7. {SSD1963_Get_Lcd_Mode ,{0x24,0x20,0x01,0xdf,0x01,0x0f,0x00} ,7}, /*4*/
8. {SSD1963_Get_Hori_Period ,{0x02,0x0c,0x00,0x2a,0x07,0x00,0x00,0x00},8}, /*5*/
9. {SSD1963_Get_Vert_Period ,{0x01,0x1d,0x00,0x0b,0x09,0x00,0x00} ,7}, /*6*/
10. {SSD1963_Get_Power_Mode ,{0x1c} ,1}, /*7*/
11. {SSD1963_Get_Display_Mode,{0x03} ,1}, /*8*/
12. {SSD1963_Get_Gpio_Conf ,{0x0F,0x01} ,2}, /*9*/
13. {SSD1963_Get_Lshift_Freq ,{0x00,0xb8} ,2}, /*10*/
14. };
实现函数如下所示,函数会遍历结构体变量中的每一个命令,以及每一个命令下的初始值,如果有一个不正确,则跳出循环,执行重新初始化和恢复措施。这个函数中的MY_DEBUGF宏是我自己的调试函数,使用串口打印调试信息,在接下来的第五部分将详细叙述。
通过这个函数,我可以长时间监控显示屏的哪些命令、哪些位容易被干扰。程序里使用了一个被妖魔化的关键字:goto。大多数C语言书籍对goto关键字谈之色变,但你应该有自己的判断。在函数内部跳出多重循环,除了goto关键字,又有哪种方法能如此简洁高效!
1. /**
2. * lcd 显示冗余
3. * 每隔一段时间调用该程序一次
4. */
5. void lcd_redu(void)
6. {
7. uint8_t tmp[8];
8. uint32_t i,j;
9. uint32_t lcd_init_flag;
10.
11. lcd_init_flag =0;
12. for(i=0;i
4.14陷阱
对于8051内核单片机,由于没有相应的硬件支持,可以用纯软件设置软件陷阱,用来拦截一些程序跑飞。对于ARM7或者Cortex-M系列单片机,硬件已经内建了多种异常,软件需要根据硬件异常来编写陷阱程序,用来快速定位甚至恢复错误。
4.15阻塞处理
有时候程序员会使用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
5.测试,再测试
思维再缜密的程序员也不可能编写完全无缺陷的程序,测试的目的正是尽可能多的发现这些缺陷并改正。这里说的测试,是指程序员的自测试。前期的自测试能够更早的发现错误,相应的修复成本也会很低,如果你不彻底测试自己的代码,恐怕你开发的就不只是代码,可能还会声名狼藉。
优质嵌入式C程序跟优质的基础元素关系密切,可以将函数作为基础元素,我们的测试正是从最基本的函数开始。判断哪些函数需要测试需要一定的经验积累,虽然代码行数跟逻辑复杂度并不成正比,但如果你不能判断某个函数是否要测试,一个简单粗暴的方法是:当函数有效代码超过20行,就测试它。
程序员对自己的代码以及逻辑关系十分清楚,测试时,按照每一个逻辑分支全面测试。很多错误发生在我们认为不会出错的地方,所以即便某个逻辑分支很简单,也建议测试一遍。
第一个原因是我们自己看自己的代码总是不容易发现错误,而测试能暴露这些错误;另一方面,语法正确、逻辑正确的代码,经过编译器编译后,生成的汇编代码很可能与你的逻辑相差甚远。
比如我们前文提及的使用volatile以及不使用volatile关键字编译后生成的汇编代码,再比如我们用低优化级别编译和使用高优化级别编译后生成的汇编代码,都可能相差很大,实际运行测试,可以暴漏这些隐含错误。最后,虽然可能性极小,编译器本身也可能有BUG,特别是构造复杂表达式的情况下(应极力避免复杂表达式)。
5.1使用硬件调试器测试
使用硬件调试器(比如J-link)测试是最通用的手段。可以单步运行、设置断点,可以很方便的查看当前寄存器、变量的值。在寻找缺陷方面,使用硬件调试器测试是最简单却又最有效的手段。
硬件调试器已经在公司普遍使用,这方面的测试不做介绍,想必大家都已经很熟悉了。
5.2有些缺陷很难缠
就像没有一种方法能完美解决所有问题,在实际项目中,硬件调试器也有难以触及的地方。可以举几个例子说明:
除了测试缺陷需要,有时候我们在做稳定性测试时,需要知道软件每时每刻运行到那些分支、执行了哪些操作、我们关心的变量当前值是什么等等,这些都表明,我们还需要一种和硬件调试器互补的测试手段。
这个测试手段就是在程序中增加额外调试语句,当程序运行时,通过这些调试语句将运行信息输出到可以方便查看的设备上,可以是PC机、LCD显示屏、存储卡等等。
以串口输出到PC机为例,下面提供完整的测试思路。在此之前,我们先对这种测试手段提一些要求:
5.2.1简单易用的调试函数
1)使用库函数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库,请自行查阅资料)
2)构建自己的调试函数
使用库函数比较方便,但也少了一些灵活性,不利于随心所欲的定制输出格式。自己编写类似printf函数则会更灵活一些,而且不依赖任何编译器。下面给出一个完整的类printf函数实现,该函数支持有限的格式参数,使用方法与库函数一致。
同库函数类似,该也需要提供一个底层串口发送函数(原型为:int32_t UARTwrite(const uint8_t * pcBuf, uint32_t ulLen)),用来发送指定数目的字符,并返回最终发送的字符个数。
1. #include /*支持函数接收不定量参数*/
2.
3. const char * const g_pcHex = "0123456789abcdef";
4.
5. /**
6. * 简介: 一个简单的printf函数,支持\%c, \%d, \%p, \%s, \%u,\%x, and \%X.
7. */
8. void UARTprintf(const uint8_t *pcString, ...)
9. {
10. uint32_t ulIdx;
11. uint32_t ulValue; //保存从不定量参数堆栈中取出的数值型变量
12. uint32_t ulPos, ulCount;
13. uint32_t ulBase; //保存进制基数,如十进制则为10,十六进制数则为16
14. uint32_t ulNeg; //为1表示从变量为负数
15. uint8_t *pcStr; //保存从不定量参数堆栈中取出的字符型变量
16. uint8_t pcBuf[32]; //保存数值型变量字符化后的字符
17. uint8_t cFill; //'x'->不足8个字符用'0'填充,cFill='0';
18. //'%8x '->不足8个字符用空格填充,cFill=' '
19. va_list vaArgP;
20.
21. va_start(vaArgP, pcString);
22. while(*pcString)
23. {
24. // 首先搜寻非%核字符串结束字符
25. for(ulIdx = 0; (pcString[ulIdx] != '%') && (pcString[ulIdx] != '\0'); ulIdx++)
26. { }
27. UARTwrite(pcString, ulIdx);
28.
29. pcString += ulIdx;
30. if(*pcString == '%')
31. {
32. pcString++;
33.
34. ulCount = 0;
35. cFill = ' ';
36. again:
37. switch(*pcString++)
38. {
39. case '0': case '1': case '2': case '3': case '4':
40. case '5': case '6': case '7': case '8': case '9':
41. {
42. // 如果第一个数字为0, 则使用0做填充,则用空格填充)
43. if((pcString[-1] == '0') && (ulCount == 0))
44. {
45. cFill = '0';
46. }
47. ulCount *= 10;
48. ulCount += pcString[-1] - '0';
49. goto again;
50. }
51. case 'c':
52. {
53. ulValue = va_arg(vaArgP, unsigned long);
54. UARTwrite((unsigned char *)&ulValue, 1);
55. break;
56. }
57. case 'd':
58. {
59. ulValue = va_arg(vaArgP, unsigned long);
60. ulPos = 0;
61.
62. if((long)ulValue < 0)
63. {
64. ulValue = -(long)ulValue;
65. ulNeg = 1;
66. }
67. else
68. {
69. ulNeg = 0;
70. }
71. ulBase = 10;
72. goto convert;
73. }
74. case 's':
75. {
76. pcStr = va_arg(vaArgP, unsigned char *);
77.
78. for(ulIdx = 0; pcStr[ulIdx] != '\0'; ulIdx++)
79. {
80. }
81. UARTwrite(pcStr, ulIdx);
82.
83. if(ulCount > ulIdx)
84. {
85. ulCount -= ulIdx;
86. while(ulCount--)
87. {
88. UARTwrite(" ", 1);
89. }
90. }
91. break;
92. }
93. case 'u':
94. {
95. ulValue = va_arg(vaArgP, unsigned long);
96. ulPos = 0;
97. ulBase = 10;
98. ulNeg = 0;
99. goto convert;
100. }
101. case 'x': case 'X': case 'p':
102. {
103. ulValue = va_arg(vaArgP, unsigned long);
104. ulPos = 0;
105. ulBase = 16;
106. ulNeg = 0;
107. convert: //将数值转换成字符
108. for(ulIdx = 1; (((ulIdx * ulBase) <= ulValue) &&(((ulIdx * ulBase) / ulBase) == ulIdx)); ulIdx *= ulBase, ulCount--)
109. { }
110. if(ulNeg)
111. {
112. ulCount--;
113. }
114. if(ulNeg && (cFill == '0'))
115. {
116. pcBuf[ulPos++] = '-';
117. ulNeg = 0;
118. }
119. if((ulCount > 1) && (ulCount < 16))
120. {
121. for(ulCount--; ulCount; ulCount--)
122. {
123. pcBuf[ulPos++] = cFill;
124. }
125. }
126.
127. if(ulNeg)
128. {
129. pcBuf[ulPos++] = '-';
130. }
131.
132. for(; ulIdx; ulIdx /= ulBase)
133. {
134. pcBuf[ulPos++] = g_pcHex[(ulValue / ulIdx) % ulBase];
135. }
136. UARTwrite(pcBuf, ulPos);
137. break;
138. }
139. case '%':
140. {
141. UARTwrite(pcString - 1, 1);
142. break;
143. }
144. default:
145. {
146. UARTwrite("ERROR", 5);
147. break;
148. }
149. }
150. }
151. }
152. //可变参数处理结束
153. va_end(vaArgP);
154. }
5.2.2对调试函数进一步封装
上文说到,我们增加的调试语句应能很方便的从最终发行版中去掉,因此我们不能直接调用printf或者自定义的UARTprintf函数,需要将这些调试函数做一层封装,以便随时从代码中去除这些调试语句。参考方法如下:
1. #ifdef MY_DEBUG
2. #define MY_DEBUGF(message) do { \
3. {UARTprintf message;} \
4. } while(0)
5. #else
6. #define MY_DEBUGF(message)
7. #endif /* PLC_DEBUG */
在我们编码测试期间,定义宏MY_DEBUG,并使用宏MY_DEBUGF(注意比前面那个宏多了一个‘F’)输出调试信息。经过预处理后,宏MY_DEBUGF(message)会被UARTprintf message代替,从而实现了调试信息的输出;当正式发布时,只需要将宏MY_DEBUG注释掉,经过预处理后,所有MY_DEBUGF(message)语句都会被空格代替,而从将调试信息从代码中去除掉。
6.编程思想 6.1编程风格
《计算机程序的构造和解释》一书在开篇写到:程序写出来是给人看的,附带能在机器上运行。
6.1.1 整洁的样式
使用什么样的编码样式一直都颇具争议性的,比如缩进和大括号的位置。因为编码的样式也会影响程序的可读性,面对一个乱放括号、对齐都不一致的源码,我们很难提起阅读它的兴趣。
我们总要看别人的程序,如果彼此编码样式相近,读起源码来会觉得比较舒适。但是编码风格的问题是主观的,永远不可能在编码风格上达成统一意见。因此只要你的编码样式整洁、结构清晰就足够了。除此之外,对编码样式再没有其它要求。
提出匈牙利命名法的程序员、前微软首席架构师Charles Simonyi说:我觉得代码清单带给人的愉快同整洁的家差不多。你一眼就能分辨出家里是杂乱无章还是整洁如新。这也许意义不大。因为光是房子整洁说明不了什么,它仍可能藏污纳垢!
但是第一印象很重要,它至少反映了程序的某些方面。我敢打赌,我在3米开外就能看出程序拙劣与否。我也许没法保证它很不错,但如果从3米外看起来就很糟,我敢保证这程序写得不用心。如果写得不用心,那它在逻辑上也许就不会优美。
6.1.2清晰的命名
变量、函数、宏等等都需要命名,清晰的命名是优秀代码的特点之一。命名的要点之一是名称应能清晰的描述这个对象,以至于一个初级程序员也能不费力的读懂你的代码逻辑。我们写的代码主要给谁看是需要思考的:给自己、给编译器还是给别人看?
我觉得代码最主要的是给别人看,其次是给自己看。如果没有一个清晰的命名,别人在维护你的程序时很难在整个全貌上看清代码,因为要记住十多个以上的糟糕命名的变量是件非常困难的事;而且一段时间之后你回过头来看自己的代码,很有可能不记得那些糟糕命名的变量是什么意思。
为对象起一个清晰的名字并不是简单的事情。首先能认识到名称的重要性需要有一个过程,这也许跟谭式C程序教材被大学广泛使用有关:满书的a、b、c、x、y、z变量名是很难在关键的初学阶段给人传达优秀编程思想的。
其次如何恰当的为对象命名也很有挑战性,要准确、无歧义、不罗嗦,要对英文有一定水平,所有这些都要满足时,就会变得很困难;此外,命名还需要考虑整体一致性,在同一个项目中要有统一的风格,坚持这种风格也并不容易。
关于如何命名,Charles Simonyi说:面对一个具备某些属性的结构,不要随随便便地取个名字,然后让所有人去琢磨名字和属性之间有什么关联,你应该把属性本身,用作结构的名字。
6.1.3恰当的注释
注释向来也是争议之一,不加注释和过多的注释我都是反对的。不加注释的代码显然是很糟糕的,但过多的注释也会妨碍程序的可读性,由于注释可能存在的歧义,有可能会误解程序真实意图,此外,过多的注释会增加程序员不必要的时间。
如果你的编码样式整洁、命名又很清晰,那么,你的代码可读性不会差到哪去,而注释的本意就是为了便于理解程序。
这里建议使用良好的编码样式和清晰的命名来减少注释,对模块、函数、变量、数据结构、算法和关键代码做注释,应重视注释的质量而不是数量。
如果你需要一大段注释才能说清楚程序做什么,那么你应该注意了:是否是因为程序变量命名不够清晰,或者代码逻辑过于混乱,这个时候你应该考虑的可能就不是注释,而是如何精简这个程序了。
6.2数据结构
数据结构是程序设计的基础。在设计程序之前,应该先考虑好所需要的数据结构。
前微软首席架构师Charles Simonyi:编程的第一步是想象。就是要在脑海中对来龙去脉有极为清晰的把握。在这个初始阶段,我会使用纸和铅笔。我只是信手涂鸦,并不写代码。我也许会画些方框或箭头,但基本上只是涂鸦,因为真正的想法在我脑海里。
我喜欢想象那些有待维护的结构,那些结构代表着我想编码的真实世界。一旦这个结构考虑得相当严谨和明确,我便开始写代码。我会坐到终端前,或者换在以前的话,就会拿张白纸,开始写代码。这相当容易。我只要把头脑中的想法变换成代码写下来,我知道结果应该是什么样的。
大部分代码会水到渠成,不过我维护的那些数据结构才是关键。我会先想好数据结构,并在整个编码过程中将它们牢记于心。
开发过以太网和操作系统SDS 940的Butler Lampson:(程序员)最重要的素质是能够把问题的解决方案组织成容易操控的结构。
开发CP/M操作系统的Gary.A:如果不能确认数据结构是正确的,我是决不会开始编码的。我会先画数据结构,然后花很长时间思考数据结构。在确定数据结构之后我就开始写一些小段的代码,并不断地改善和监测。在编码过程中进行测试可以确保所做的修改是局部的,并且如果有什么问题的话,能够马上发现。
微软创始人比尔·盖茨:编写程序最重要的部分是设计数据结构。接下来重要的部分是分解各种代码块。
编写世界上第一个电子表格软件的Dan Bricklin:在我看来,写程序最重要的部分是设计数据结构,此外,你还必须知道人机界面会是什么样的。
我们举个例子来说明。在介绍防御性编程的时候,提到公司使用的LCD显示屏抗干扰能力一般,为了提高LCD的稳定性,需要定期读出LCD内部的关键寄存器值,然后跟存在Flash中的初始值相比较。
需要读出的LCD寄存器有十多个,从每个寄存器读出的值也不尽相同,从1个到8个字节都有可能。如果不考虑数据结构,编写出的程序将会很冗长。
1. void lcd_redu(void)
2. {
3. 读第一个寄存器值;
4. if(第一个寄存器值==Flash存储值)
5. {
6. 读第二个寄存器值;
7. if(第二个寄存器值==Flash存储值)
8. {
9. ...
10.
11. 读第十个寄存器值;
12. if(第十个寄存器值==Flash存储值)
13. {
14. 返回;
15. }
16. else
17. {
18. 重新初始化LCD;
19. }
20. }
21. else
22. {
23. 重新初始化LCD;
24. }
25. }
26. else
27. {
28. 重新初始化LCD;
29. }
30. }
我们分析这个过程,发现能提取出很多相同的元素,比如每次读LCD寄存器都需要该寄存器的命令号,都会经过读寄存器、判断值是否相同、处理异常情况这一过程。所以我们可以提取一些相同的元素,组织成数据结构,用统一的方法去处理这些数据,将数据与处理过程分开来。
我们可以先提取相同的元素,将之组织成数据结构:
1. typedef struct {
2. uint8_t lcd_command; //LCD寄存器
3. uint8_t lcd_get_value[8]; //初始化时写入寄存器的值
4. uint8_t lcd_value_num; //初始化时写入寄存器值的数目
5. }lcd_redu_list_struct;
这里lcd_command表示的是LCD寄存器命令号;lcd_get_value是一个数组,表示寄存器要初始化的值,这是因为对于一个LCD寄存器,可能要初始化多个字节,这是硬件特性决定的;lcd_value_num是指一个寄存器要多少个字节的初值,这是因为每一个寄存器的初值数目是不同的,我们用同一个方法处理数据时,是需要这个信息的。
就本例而言,我们将要处理的数据都是事先固定的,所以定义好数据结构后,我们可以将这些数据组织成表格:
1. /*LCD部分寄存器设置值列表*/
2. lcd_redu_list_struct const lcd_redu_list_str[]=
3. {
4. {SSD1963_Get_Address_Mode,{0x20} ,1}, /*1*/
5. {SSD1963_Get_Pll_Mn ,{0x3b,0x02,0x04} ,3}, /*2*/
6. {SSD1963_Get_Pll_Status ,{0x04} ,1}, /*3*
7. {SSD1963_Get_Lcd_Mode ,{0x24,0x20,0x01,0xdf,0x01,0x0f,0x00} ,7}, /*4*/
8. {SSD1963_Get_Hori_Period ,{0x02,0x0c,0x00,0x2a,0x07,0x00,0x00,0x00},8}, /*5*/
9. {SSD1963_Get_Vert_Period ,{0x01,0x1d,0x00,0x0b,0x09,0x00,0x00} ,7}, /*6*/
10. {SSD1963_Get_Power_Mode ,{0x1c} ,1}, /*7*/
11. {SSD1963_Get_Display_Mode,{0x03} ,1}, /*8*/
12. {SSD1963_Get_Gpio_Conf ,{0x0F,0x01} ,2}, /*9*/
13. {SSD1963_Get_Lshift_Freq ,{0x00,0xb8} ,2}, /*10*
14. };
至此,我们就可以用一个处理过程来完成数十个LCD寄存器的读取、判断和异常处理了:
1. /**
2. * lcd 显示冗余
3. * 每隔一段时间调用该程序一次
4. */
5. void lcd_redu(void)
6. {
7. uint8_t tmp[8];
8. uint32_t i,j;
9. uint32_t lcd_init_flag;
10.
11. lcd_init_flag =0;
12. for(i=0;i
通过合理的数据结构,我们可以将数据和处理过程分开,LCD冗余判断过程可以用很简洁的代码来实现。更重要的是,将数据和处理过程分开更有利于代码的维护。
比如,通过实验发现,我们还需要增加一个LCD寄存器的值进行判断,这时候只需要将新增加的寄存器信息按照数据结构格式,放到LCD寄存器设置值列表中的任意位置即可,不用增加任何处理代码即可实现!这仅仅是数据结构的优势之一,使用数据结构还能简化编程,使复杂过程变的简单,这个只有实际编程后才会有更深的理解。
7.总结和阅读书目
本文介绍了编写优质嵌入式C程序涉及的多个方面。每年都有亿万计的C程序运行在单片机、ARM7、Cortex-M3这些微处理器上,但在这些处理器上如何编写优质高效的C程序,几乎没有书籍做专门介绍。
本文试图在这方面做一些努力。编写优质嵌入式C程序需要大量的专业知识,本文虽尽力描述编写嵌入式C程序所需要的各种技能,但本文却无力将每一个方面都面面俱到的描述出来,所以本文最后会列举一些阅读书目,这些书大多都是真正大师的经验之谈。站在巨人的肩膀上,可以看的更远。
7.1关于语言特性 7.2关于编译器 7.3关于防御性编程 7.4关于编程思想