引脚输出的隐藏BUG | 深入思考

2019-08-26 08:53发布

    最近在看一个接手项目的时候,突然发现了看门狗(外部看门狗)程序中有这么一条喂狗语句:

GPIOC->ODR ^= (uint16_t)0x01;

    不知道各位道友看到这条语句的第一想法是什么(当时这条语句被宏定义包装了的)?

    看到这么一条语句的时候,鱼鹰第一感想是:坏了,这条语句会出大问题,所以我毫不犹豫的修改了这条语句。

    因为这条语句本意是翻转电平,而且是一个宏定义,所以我直接将这个宏删掉,由函数进行替换,函数名就是宏名,如此整个程序只要改动几个地方就行,不用整个工程修改(这就是写宏定义的一个好处了,由此你也可以理解为什么宏定义喜欢后面加括号"()"(当然你也可以使用全局替换,但这个替换有一定的风险))。

    替换的函数如下:


    现在来看看为什么鱼鹰看到那条语句就觉得有问题。

    其实有经验的道友应该都听说过读-改-写这个几个词,但不知你是否真正去理解这些名词背后的道理。而鱼鹰深知对于使用操作系统的人来说,资源互斥的概念尤其重要,所以当初就花了整整一个晚上的时间去思考这当中的原理并将当时的思考记录了下来。正因为对此有深入的思考,所以当初一看到这条语句就意识到这有一个隐藏BUG。

    由于当时只是理论上的思考,也因为当时是一边思考一边记录的,所以这篇笔记(信号量)可能不是很好懂,但是也有一定的借鉴性,建议看完。

    这条语句是读-改-写的典型例子,据此我们展开深入讨论:

GPIOC->ODR ^= (uint16_t)0x01;

    这条语句看似只有一条语句,但事实上从汇编角度的功能划分的话,有以下几种功能:

1、读出ODR寄存器的值(读)

2、这个值和0x01异或(即翻转bit 0)(改)

3、将结果写入ODR(写)

    为什么会出现问题,如果说这条语句是顺序执行的,那么这条语句没有一点问题,但是这种条件很难符合(必须看完前面关于信号量的内容,才能理解以下内容)。

1、裸机情况下,可能会有中断产生,只要你在中断中执行关于GIPIOC->ODR的指令,不管是GPIOC的哪一个引脚,都有可能出现问题(认真思考这句话)。

2、操作系统情况下,任务执行情况和中断处理类似。

3、即使你保证了当前的程序只有一个地方使用了这条语句,或者保证了这条语句的顺序执行,但是有可能其他地方使用了关于GPIOC端口输出的功能(比如有的地方使用了GPIO_ResetBits(GPIOC,XXX)),或者说以后需求改变了,增加了关于GPIOC输出引脚的操作,那么照样会出现BUG,而这个隐藏BUG的会出现的非常偶然,让你莫名其妙,而要找出这条隐藏BUG会非常困难(如果不是偶然看到这么一条语句,我还真没想到这里的BUG)。

综上所述,为了尽可能的保证程序的健壮性,必须把一切可能扼杀在摇篮中,这样你就不会在使用这条语句时各种小心翼翼了。

    我们来看这样一种情况:

    程序一开始读取ODR的值为0x0011(这个值是副本),即bit 0和 bit 4置1,然后被代码X打断(中断程序或者其它任务),这个代码X开始对bit 4置0(不管是直接操作ODR还是BRR寄存器,情况都一样,反正结果就是成功设置bit4为0了),然后回到原先被中断的程序继续执行,因为此时使用的是副本的值,程序并不知道bit 4已经修改为0了,所以经过异或操作后,计算的值为0x0010,并将其写入至ODR,即此时ODR值为0x0010,但期望的是0x0000,也就是说,那个代码X所做的事情因为这条BUG代码而失效了,你说,代码X还能继续运行正常下去吗(假设代码X为使能CS片选信号)?

    所以说,因为这条BUG代码,你很可能需要花费很长的时间去发现它,为什么?因为这个情况出现的是如此的偶然,不可能每次代码读取完ODR的值之后才会被中断,有可能是完整操作之后才被中断,但只要有这种可能性,这个BUG大概率是会出现的,而出现的时候是没有一点的规律,也就是说你很难复现这个BUG,也就很难去解决了。(这条BUG代码虽然不是鱼鹰写的,但如果不是发现了这点,自己很可能会写出这种代码,但经过此事之后,鱼鹰将会尽可能的避免,诸位道友也要引以为戒)。

    那么使用修改后的代码为什么就能避免这个问题了呢?

    首先需要了解GPIO_ResetBits()和GPIO_SetBits()



    这里面涉及了两个寄存器BRR和BSRR,一个是清0操作,一个是置1操作。或许你会奇怪,为什么直接操作ODR就可以实现清零和置一操作,还要这两个寄存器呢?

    很大程度上就是为了避免读-改-写的缺陷,因为如果没有这两个寄存器,为了防止BUG,一般会采用禁止中断的方式去解决,而简单一条指令却要用禁止中断、恢复中断进行保护,实在是太麻烦了,所以因为这两条指令的出现,就可以实现对某一位的原子操作,而不必那么麻烦了。

    那么再看原因,继续以上述例子为例,当程序读取为ODR的值为0x0011时,程序被代码X中断,该代码完成了对bit4 清零的操作,此时ODR为0x0001,之后回到原来的程序位置后,程序能正确判断为真,所以将执行GPIO_ResetBits()清零操作,由于这个操作只对写1的bit有作用,所以只有bit0被设置为0,而bit4保持不变,也就是说代码X对bit4的操作并没有消失。

    由此可知,这样改完之后,能够顺利避免之前的BUG。

    但是这样的代码真的没有问题吗?还是有的!

    一开始也以为这样改完之后就万无一失了,可是今天准备写这篇笔记时,又考虑了到了一个bug(所以说,写笔记除了是对经验的记录,也是让自己能静下心来进行更全面的思考)。

    为什么能考虑到这个bug呢,因为鱼鹰当初在看uCOS源码的时候,一直理解不了为什么一条简单的判断语句,还是采用了禁止中断的方式进行了保护(请看操作系统小节),虽然后来因为对信号量的深入思考,有些理解了,但毕竟没有真正实践过,所以今天在看到这条判断语句的时候,就在考虑是否会出现BUG,因为uCOS的源码对这种判断情况一律采用了禁止中断的方式,我不认为uCOS的作者是闲的没事干,多写了这些语句。那么原因何在?

    现在我们假设有两个任务都使用了这个函数。

    任务A,执行了读取ODR的值为0x0001,判断为真,此时系统任务切换为任务B,它也读取ODR的值,也是0x0001,判断为真,现在假设任务X(代表任务A或者任务B,即随便其中一个任务继续执行)执行清零操作,之后与之相对的另一个任务也开始执行,那么它也将继续执行清零操作,如此一来,这个函数的本意是翻转电平状态,但是因为这个BUG,这一次意外的并没有进行翻转,你说会发生什么情况?

    所以说,一旦有两个以上任务用了此函数,那么BUG不可避免!!

    相信看到这里各位道友也能总结出一些结论:

1、第一个BUG的会导致其它代码出现问题

2、第二个BUG会导致自身代码功能出现问题。

    而要避免这个问题,唯有保护这段代码,使之成为原子操作,具体如何做请看之前关于信号量的小节即可。

    现在继续深入思考,思考互斥锁。

    这里简单说个比喻来理解互斥锁,比如门上有一把锁,这把锁是从里面反锁的,一旦锁上,外面就会显示(想象火车的门)「有人」,一般情况下,人进去之后马上会反锁,而其他人进之前也会查看是否是「无人」状态,这样就不会擅闯房间了。但偏偏不凑巧的,一个人刚进去,刚关上门,正准备反锁呢,另一个人也来到门前,看到门上显示的是「无人」,所以他就打开了门,然后就尴尬了……

    现实中只是尴尬一下,看到有人在里面,退出来就是了,但是程序不一样,它的执行流程是固化的,既然已经判断没锁门,必然按照既定流程走,所以两个程序会使用同一个房间(共享资源),这样必然导致严重后果(比如串口打印,两个任务同时使用串口打印,打印的出来的东西必然不伦不类)。

    现在继续。

    ODR虽然是一个简单的寄存器,但因为有多个地方使用这个寄存器,由此它就成为了共享资源,所以必须要对它进行保护。

怎么保护?对共享资源的保护一般会想到互斥锁的概念,所谓互斥锁,说白了就是申明一个变量指示资源的使用情况,一般来说这个互斥机制由是由操作系统提供的,但是如果我们要自己实现呢?

    这个变量可能会到处使用(不然也不需要互斥锁了),也就是说,它本身就是一个共享资源,既然是共享资源,肯定也要保护,继续用互斥锁?别闹了,这个变量本身就是为了保护共享资源的,再申请一个变量保护这把互斥锁,那你新申请的这个变量就不需要保护了吗?这样永远没有尽头。

    那么怎么办?禁止中断。最简单,最常用的方式就是通过禁止中断的方式来保护这把互斥锁的安全性。但既然是采用禁止中断的方式,何不直接对整个共享资源进行禁止中断呢?何必要弄出一个互斥锁来多次一举呢?

    我们知道,一般嵌入式系统要求实时性,假设一个任务长时间使用串口这个共享资源,那么禁止中断的时间必然很长,对系统的实时性很不利,而如果使用互斥锁的话,只会在查看这把锁的状态和上锁的时候才使用禁止中断,一旦自己将门反锁或者已被别人反锁了,立马恢复中断,也就是说,使用了互斥锁,禁止中断的时间只有很短的时间。这样系统的实时性就得到了保障。

    由此展开思考,凡是有可能被多个任务(或中断)使用的资源,不管是一个寄存器,还是一个字节变量,都一定要考虑使用的风险,把它当成互斥资源来看待,这样才能让你自己用的放心,也不会被后来者暗中骂娘了。




登录 后发表评论
0条评论
还没有人评论过~