7.2 一次产品异常复位引发的质量提升经历

借助在跨国公司的研发经历,我得以带着大家漫游一次产品质量之旅。然而,知道并非做到,看似触手可及,但又好似隔着万水千山。明明知道采用那些策略可以有效的提升产品质量,然而现实世界中的自己却长时间无动于衷。

幸好,上天送给我一个契机。一次,某产品的运行现场出现随机异常复位,问题比较棘手,大家一筹莫展。我脑袋中灵光一闪,为何不增加异常dump信息来协助分析问题呢?

我快速行动起来,发现我们的嵌入式设备异常复位有如下原因:
在这里插入图片描述
其中包含了看门狗、时钟、电源、晶振、软件触发复位、调试等多种复位原因。我快速写了一段异常处理程序,记录下了异常时的复位值和PC指针后,我们发现是因为非法地址访问导致的异常。进一步检测系统符号表,发现异常时PC指针定位到了如下函数内部:

/* 搜帧函数 */
DWORD search(T103 *me, DWORD dwLen)
{
    DWORD i;
    BYTE *pBuf

    /* 过滤掉帧头可能存在的干扰数据,定位帧头 */
    pBuf = me->pRxd;
    for (i = 0; i < dwLen - 4; i++)
    {
        if (pBuf[i] == START_FIX && pBuf[i + 4] == FRAME_END)	/* 异常时pc指针指向 */
            ...
    }
    ...
  }

定位过程如下图所示:
在这里插入图片描述
上面这段代码是IEC60870-5-103的搜帧函数。在串口通讯时,因为硬件干扰等原因,会导致帧头或帧尾存在1~2个字符的乱码(现场经验,很少超过4个),上述这段代码用于跳过帧头的乱码。

测试时我们发现,该现场通讯电缆质量差,距离长,而且经过一些强干扰区域,通讯链路上频繁出现一些1~2个字符的干扰数据,此时dwLen长度小于4,减法溢出,pBuf[i]出现了非法内存访问异常。

一旦定位异常,程序修改就很容易了,增加一条合法判断语句即可,如下所示:

/* 搜帧函数 */
DWORD search(T103 *me, DWORD dwLen)
{
    DWORD i;
    BYTE *pBuf

    /* 过滤掉帧头可能存在的干扰数据,定位帧头 */
    pBuf = me->pRxd;
    if (dwLen < 4)
        return 0;		/* 搜帧失败 */
    for (i = 0; i < dwLen - 4; i++)
    {
        if (pBuf[i] == START_FIX && pBuf[i + 4] == FRAME_END)
            ...
    }
    ...
}

此次异常被我们幸运的解决了,大家尝到了甜头。更为关键的是,异常dump程序成为我们产品的内建缺省子模块,一旦碰到异常后,大家会自然得第一时间去查看异常dump信息。

◇◇◇

第一次碰到的问题恰好很单纯,因此我们可以快速定位问题。真实世界真实产品复杂的多,如异常时pc指针指向无效程序空间,或者指向某个类库内部。一些程序发生异常时经常还能带病执行了一段时间,导致异常时pc指针信息失去意义。

缓冲区读写指针翻转一般情况下使用取模操作(%),但单片机环境下模操作最耗时间,一些强实时模块习惯替换为&操作,如下代码示例:

/* 缓冲区 */
BYTE buf[64];

/* 写指针 */
BYTE reader;
void write(BYTE byData)
{
   buf[reader] = byData;
   reader = (reader + 1) & 0x3f;		/* 指针回卷 */
}

这段代码原来工作正常,后来为了将这段代码移植到一款ram更受限的cpu上,有人将buf[64]修改为buf[32],但未同步修改0x3f处。

设备集成时总是出现一些莫名其妙的运行异常,主要是事件记录异常。跟踪了很久,才发现事件模块某些数据结构存在被篡改的现象,分析事件缓冲区周边缓冲区,才逐步定位到上述代码。修改容易,但这个问题当初定位花费了自己很多精力和时间。有没有更好的策略去尽快发现此类问题呢?

首要策略是规范上述代码,我们在项目知识库中增加了一个新check点,要求这类代码必须使用如下模式,且仅允许用于强实时模块,弱实时模块中可读可维护性高于实时性。

/* 缓冲区大小 */
#define BUF_SIZE 32  /* 2幂数要求 */

/* 缓冲区 */
BYTE buf[BUF_SIZE];

/* 写指针 */
BYTE reader;
void write(BYTE byData)
{
   buf[reader] = byData;
   reader = (reader + 1) & (BUF_SIZE - 1);		/* 指针回卷 */
}

其次,就是我们借助这类问题构建了一个被动检测模块:内存越界检测模块。

内存越界检测模块的主要任务是出现缓冲区越界后容易异常定位,为了做到这一点,首先需要各模块内存靠近在一起,且在各模块之间增加检测桩。如下图示意:
在这里插入图片描述
以前,我们是在全局空间直接为各程序模块分配变量和缓冲区的,如下述代码示意,下述代码是IEC60870-5-103规约模块中部分变量定义列表。

static DWORD dwPort;					/* 端口 */
static THWPortDrvObj *pLink;			/* 链路对象 */

/* 缓冲区 */
static BYTE pRxd[256];					/* 接受缓冲区 */
static BYTE *pCurRxd;					/* 当前接受帧地址 */
static BYTE pTxd[256];					/* 发送缓冲区 */
static BYTE pReTxd[256];				/* 重发缓冲区 */
static BYTE byWritePtr;					/* 发送缓冲区写指针 */
static BYTE byReSendLen;				/* 记录重发帧长度 */
static BYTE byRev[2];

/* 通用功能 */
static BOOL bSendIdentifier;			/* 是否发送装置标识 */
static BOOL bResetDevice;				/* 是否复位通信单元 */
static BOOL bResetFCB; 					/* 是否复位FCB */
...

这种变量定义风格,其内存位置是由链接模块决定的,为了提高内存利用率,可能还存在顺序调整。我们不仅没法规定其紧靠在一起,更没法在各模块之间插入检测桩。为了适应内存越界检测模块,需要用结构重写上述代码,示意如下:

typedef struct T103Protocol
{
	DWORD dwPort;					/* 端口 */
	THWPortDrvObj* pLink;			/* 链路对象 */

	/* 缓冲区 */
	BYTE pRxd[256];					/* 接受缓冲区 */
	BYTE* pCurRxd;					/* 当前接受帧地址 */
	BYTE pTxd[256];					/* 发送缓冲区 */
	BYTE pReTxd[256];				/* 重发缓冲区 */
	BYTE byWritePtr;				/* 发送缓冲区写指针 */
	BYTE byReSendLen;				/* 记录重发帧长度 */
	BYTE byRev[2];

	/* 通用功能 */
	BOOL bSendIdentifier;			/* 是否发送装置标识 */
	BOOL bResetDevice;				/* 是否复位通信单元 */
	BOOL bResetFCB; 				/* 是否复位FCB */
	...
}T103Protocol;

这种编程风格一开始大家颇为不适应,最痛苦的是所有的变量都需要用到指针来访问,代码显得繁琐。但后期慢慢习惯以后,发现这种风格恰好契合面向对象设计理念,反而能衍生出很多价值,大家也就慢慢接受了。假如103规约需要多个实例时,上述代码仅需要完成多次分配即可,但原先的代码就需要修改几乎所有变量,将单一变量修改为数组模式。再比如,告警报告、事件报告、开入跳变报告等模块,可能也仅需要一套代码了。

除了要求各模块变量统一分配外,内存越界检测模块还需要系统级别的内存管理。现在,我们已经习惯在系统构建初期,就仔细的划定各ram区域,哪些空间由连接器管理,哪些空间由程序主动管理,哪些空间是堆栈,哪些空间是交换区等。

然后,针对主动管理的内存空间,构建内存分配函数,并在分配函数末尾增加检测点,定期检测。一般情况下,内存越界是由前面几个模块引起的,这给我们快速定位解决问题提供了便利。伪代码示意如下:

/* 分配内存 */
void *hwMalloc(DWORD dwSize, HW_MEM_MALLOC_TYPE MemType)
{
	/* 剩余空间合法检测 */
	...

	/* SRAM空间内存分配并清零 */
	if (MemType == HW_MEM_MALLOC_SRAM)
	{
		...
	}

	/* NVRAM空间内存分配并清零 */
	else if (MemType == HW_MEM_MALLOC_NVRAM)
	{
		...
	}

	/* 内存边界4字节对齐*/
	...

	/* 分配检测桩空间*/
	...
}

在嵌入式系统中,为了提升程序稳定性,经常采用静态内存空间分配策略,但也带来了一个问题,多大的静态空间比较合适。有了内存越界检测模块,不仅可以检测各模块是否存在越界,也可以计算各缓冲区最大用了多少容量。有了这些数据支撑,我们就具备进一步优化内存组织的能力。

关于内存空间优化,最典型的应用莫过于堆栈了。堆栈溢出时增加检测仅仅是被动策略,如果能测出最大堆栈使用空间,且适度预留,我们就能保证不会出现堆栈溢出异常。实践中,我们经常依据CPU内存大小,按照1.5~2倍裕度去分配堆栈空间。

多年的实践经验,我们发现大部分莫名其妙的异常都是因为内存紊乱造成的,或许,是时候在你的产品中增加内存越界检测模块了。

◇◇◇

产品发生异常复位后,经常出现异常时刻pc信息没有意义的情况,面对代码庞杂的工业产品,如何快速定位依然让大家颇为苦恼。

直接定位到PC指针比较困难,能否退而求其次,如果能在数以百计的软件模块中,定位到某个具体软件模块或函数,也会极大的改善定位问题的难度。

抱着这样的想法,在原先基于传统前后台的系统中,我们给每个软件模块都增加了编号,并可以记录异常前一段时间内都执行了哪些模块。这相当于给出了一个异常时程序执行快照,借助该机制,我们解决了多例困扰大家颇久的老问题。

采取动态执行框架后,各子模块不再有明确的调用入口,给各子模块进行编号变得不可行。此时,我们跟踪的对象成了一个个的消息,记录下异常时前一段时间内执行过哪些消息,每个消息从哪儿来到哪儿去,以及执行时刻等信息,可为异常时分析提供更多针对性策略。
在这里插入图片描述

很多模块在构建之后都会衍生出一些新功能,异常消息记录模块也如此。我们发现,不仅可以记录异常时刻消息,也可以手动触发,或者发生某些事件时触发。这个功能最大的用途是集成测试,在uml中有一类图叫顺序图,用来描述一些跨越多个任务的控制流程非常合适,捕捉该过程的消息记录,并和顺序图进行核对,是我们常用的一种集成测试手段。

◇◇◇

从一次产品复位起步,我们慢慢的构建出一套异常时程序检测分析机制。然后,借助这套机制,一个一个去克服各类现场问题。慢慢的,时间给我们以惊喜,大家开始明显感觉到产品问题变少了。更重要的是心态的变化,碰到各类问题可以淡然处之了,以往那种惴惴不安的感觉终于可以抛到九霄云外了。

——————————————

返回目录

我是小马儿,一个渴望良知与灵魂的嵌入式软件工程师,欢迎您的陪伴与同行,如感兴趣可加个人微信号nzn_xiaomaer交流,需备注“异维”二字。

猜你喜欢

转载自blog.csdn.net/zhangmalong/article/details/107417912
7.2