3.6 测试硬件(和软件)
虽然我强烈建议准备好工具箱、数字万用表和示波器,但是,如果大家没有准备好独自拥有这些,那么将这些留给硬件工程师也在情理之中。作为一个软件工程师,更重要的是将用于测试硬件的软件尽可能构建得有利于方便调试。
嵌入式系统有3种常见的测试。第一种,在每次系统引导的时候都运行加电自检(POST),即使代码被释放。这个测试验证所有的硬件组件都已经就绪,可以安全地运行系统。加电自检(POST)测试得越多,开机时间就越长,因此需要权衡可能对客户造成的影响。自检完成后,客户就可以使用系统了。
注意: 理想情况下,在启动时打印出的所有POST调试信息,都应该是可以访问的。无论是软件版本字符串还是所连接的传感器类型,总会在某个时候,需要得到这些信息而无需重新启动系统。
第二种,测试应该在每一个软件版本发布之前运行,但它们可能不适合在每次开机时运行,也许是因为它们执行时间很长、将系统恢复到出厂默认设置,或者把难看的测试模式显示在屏幕上。这些测试验证软件和硬件一起像预期的一样工作。
术语单元测试对不同的人意味着不同的事情。对我来说,它是自动地测试代码,验证源代码单元已经就绪,可以使用了。有些开发者希望测试覆盖代码所有可能的路径。这可能会导致单元测试套件大而笨重,这是用来避免在嵌入式系统中应用单元测试的一个根据。我所谓的测试是为了测试基本功能和最有可能发生的个别案例。这个过程让我将测试构建成为开发的一部分,并一直保持它,即使是在发货后。
警告: 找出你的行业(或你的管理层)对单元测试的预期。如果他们的意思是希望测试检查所有的软件路径,那么务必让你(和他们)清楚需要多少工作量和代码量。
有.些单元测试可能是在系统硬件之外的(如第2章建议使用沙箱来验证算法)。对于那些不是在系统硬件之外的单元测试,如果可能,我鼓励将其留在产品代码中,让它们在特定的组合条件下在客户现场运行(比如,“在系统启动的时候,按住这两个按钮并半闭眼睛”)。 这不仅可以让质量部门使用这些测试,而且还会发现在客户现场将这些单元测试作为首要的检查手段,可以告诉我们系统硬件是否出错了。
第三种,也是最后一种测试,就是在电路板设计调试过程中创建的测试,一般是在子系统不能如期发挥功能的时候。这些测试有时候是一些检查,可以用完即扔,可以被内容更广的测试替代或者加入到单元测试中。临时的调试代码是没有问题的。所有这些测试的目的不是为了写出经典的源代码而是构建系统。只要将源代码提交到版本控制系统,那么即使删除它也是没问题的,因为可以在需要这些测试的时候再从版本控制系统恢复。
3.6.1 构建测试
如前所述,控制外部设备(以及相关的测试)的代码经常是正在完成原理图的过程中写出来的。好消息是,刚读过数据表,会有一个比较好的思路去实现代码。不好的消息是,最终我们可能会写了六个外设驱动程序,而这是在将软件和硬件集成之前,因为要等待拿到制作完的电路板。
在第2章中,我们的系统与闪存通过SPI通信协议进行通信(部分原理图如图3-12所示)。我们需要测试什么,我们需要什么工具来验证结果?
I / O线是由软件控制的(用数字万用表外部验证)。
SPI可以发送和接收字节(用逻辑分析仪外部验证)。
闪存可以读取和写入(内部验证,使用调试子系统输出结果)。
图3-12:闪存原理图片段
为了方便调试,需要能够做以上每项测试。可以选择首先运行囊括一切的闪存测试。如果这个测试没有问题,那么其他两个测试也就没有问题。然而,如果不是这样,那么就要按照调试要求准备其他两项测试。
其他章节会讲述控制输入/输出线(第4章)以及与SPI的相关内容(第6章),因此,现在让我们专注于这些对闪存的测试。闪存如何工作并不重要,但可以通过仔细阅读Numonyx M25P80闪存数据表(在谷歌、Digikey,或供应商的网站上搜索零件编号)来检验自己的数据表技能。
3.6.2 闪存测试范例
闪存是一种非易失性内存(因此,当电源关闭时其中的内容不会被清除)。其他一些类型的非易失性内存包括ROM(只读存储器)和电子可擦除可编程只读存储器(EEPROM)。
注意: 易失性内存在系统重启后不保留其中存储的数据。有多种不同类型的易失性内存,但它们都是某种形式的RAM(随机访问存储器)。
像EEPROM一样,闪存可被擦除和写入。然而,大多数的EEPROM只能一次擦除和写入一字节。使用闪存,可以一次写一字节,但为了做到这一点,必须首先擦除整个扇区。扇区的大小取决于闪存(通常是较大的闪存,每个扇区也较大)。对于测试器件,一个扇区是65 536字节,整个芯片包含8Mb(1MB或16个扇区)。闪存通常比EEPROM有更大的存储空间,但耗电量却比较小。然而,EEPROM尺寸较小,因此它们仍然是有用的。
在我们写调试测试程序的时候,闪存芯片中没有什么需要予以保留。而对于加电自检(POST),我们则不应该修改闪存中的内容,因为系统可能会使用它(用于存储版本和图形数据)。对于单元测试,我们就让其保留测试后的状态,但可能设置一些限制。
测试通常需要三个参数:目标闪存地址,这样测试就可以运行在没有被占用(或不重要)的扇区;一个内存指针和内存长度。内存长度是闪存测试将保留在RAM中的数据量。如果内存长度与扇区大小相同,则闪存不会丢失任何数据。下面的原型说明了三种类型的测试:综合测试运行其他测试(并返回遇到的错误数);试图从闪存读取数据的测试(返回实际读取的字节数);尝试写入到闪存的测试(返回写入的字节数)。
int FlashTest(uint32_t address, uint8_t *memory, uint16_t memLength);
uint16_t FlashRead(uint32_t addr, uint8_t *data, uint16_t dataLen);
uint16_t FlashWrite(uint32_t addr, uint8_t *data, uint16_t dataLen);
我们将在调试期间广泛使用FlashTest,然后把它加到单元测试中,并在发生改变或在发布之前根据需要将它打开。我们不打算让这个测试成为加电自检(POST)的一部分。闪存在经过一定数量的写周期之后将会发生损坏(通常是100 000次,这个信息可以从数据表中找到),而且这些测试至少有两个对数据来说有可能是破坏性的。
而且,我们也不需要在启动的时候运行这个测试。如果处理器原本就可以和闪存通信,那么就有理由相信闪存是正常工作的。(可以通过在闪存中存入一个头信息以检查基本的通信,头信息包含一个已知的关键字、一个版本号和一个校验位)。
有两种方式来访问闪存:字节和多字节块。当运行代码时,使用更快的块访问方式。在刚开始的调试和测试阶段,可以从比较简单的字节方式开始,以便为驱动程序建立一个良好的基础。
测试1:读退出数据
测试从闪存读取的数据实际上的验证了,输入/输出线配置为一个SPI端口、SPI端口配置正确,以及正确理解了闪存命令协议的基本原理。
对于这个测试,我们将尽可能多的数据从扇区读取出来,这样可以稍后把它再写回去。这里是FlashTest的开始:
// Test 1: Read existing data in a block just to make sure it is possible
dataLen = FlashRead(startAddress, memory, memLength);
if (dataLen != memLength) {
// read less than desired, note error
Log(LogUnitTest, LogLevelError, "Flash test: truncation on byte read");
memLength = dataLen;
error++;
}
请注意,这里没有对数据进行验证,因为我们不知道此函数是否运行一个空的闪存芯片。如果正在写加电自检(POST),那么可以读然后检查数据是有效的(然后停止测试,因为这对加电自检来说已经足够了)。
测试2:字节访问
下一个测试从擦除扇区的数据开始。然后,将闪存填满数据,每次写入一字节。我们希望在写的时候做一些变化,以确保写命令是有效的。我将基于地址的一个偏移量对它进行写。(偏移量告诉我,我不是偶然地将该地址数据读回。)
FlashEraseSector(startAddress);
// want to put in an incrementing value but don't want it to be the address
addValue = 0x55;
for (i=0; i< memLength; i++) {
value = i + addValue;
dataLen = FlashWrite(startAddress + i, &value, 1);
if (dataLen != 1) {
Log (LogUnitTest, LogLevelError, "Flash test: byte write error.");
error++;
}
}
要完成这个检查,只需要逐字节地将数据读回,并验证每个地址(address + addValue)的数据与预期值是一致的。
测试3:块访问
既然闪存包含了我们新写的数据,那么为了确认块访问是没有问题的,可以把原始数据再写回去。这意味着再次从擦除扇区开始:
FlashEraseSector(startAddress);
dataLen = FlashWrite(startAddress, memory, memLength);
if (dataLen != memLength) {
LogWithNum(LogUnitTest, LogLevelError,
"Flash test: block write error, len ", dataLen);
error++;
}
最后,用另一个逐字节读验证数据。我们已经知道这对测试2来说是可行的。如果这个结果是好的,那么我们就知道本测试的块写入与测试1中的块读取都是没有问题的。错误数将被返回到上一层的验证代码。
注意: 这也测试了闪存驱动程序软件。它不检查闪存没有黏性位(即那些应该改变而永远不会改变的位)。生产测试可以确认所有的位都改变了,但大多数闪存在到你手里之前都以这种方式验证过了。
测试总结
如果电路板通过了这三项测试,那么我们就可以相信,闪存的硬件和软件都没有问题,满足调试测试和单元测试的需要。
对于大多数形式的存储器,我们这里看到的模式是一个比较好的开端:
- 读取原始数据。
- 写一些变化的,但公式化的数据。
- 验证数据。
- 重新写回原始数据。
- 验证原始数据。
然而,还有许多其他类型的外部设备,比这里能够讨论的要多很多。尽管自动化测试是最好的,但有时候还是需要一些外部验证。例如,LCD需要通过获取一种颜色和线条的模式来验证其驱动程序。有些测试需要模拟的外部输入,以便对敏感元素进行检查。
对于每一个外部设备和软件子系统,要搞清楚哪些测试将给我们足够的信心去相信它可以如预期的那样工作,并且可靠。这听起来并不困难,但实现起来有时候却很难。设计良好的测试,是让软件更卓越的方法之一。
3.6.3 命令和响应
假设按照3.6.2节的建议,为硬件的每个部分都创建了测试函数。在调试的过程中,我们生成了一个特殊的映像文件,在每次开机的时候将所有的测试都执行一遍。如果其中某个测试失败了,那么我们和硬件工程师可能需要一遍又一遍地运行该测试。重新编译和重新加载。然后,对不同的测试做同样的事情。然后,另一个测试也打算这样做。如果可以给嵌入式系统发送命令,让它只执行需要的测试,这样岂不是更容易吗?
嵌入式系统常常不具备计算机(或者甚至是智能手机)那样丰富的用户界面。很多都是通过命令行控制。即使那些有屏幕的,也经常使用命令行界面进行调试。本节第一部分将介绍如何在C语言中通过使用命令处理跳转表中的函数指针发送命令。这个极好的问题和解决方案,让我有机会展示一下标准的命令模式,这是一个有用的模式,不管使用什么语言,都应该掌握。
图3-13列出了一些自动化命令处理程序的高层目标。其中之一就是让我们可以使用个人计算机上的串行终端发送命令,并从被测试单元获得响应。
图3-13:命令处理器的目标
在读一些数据之后,代码将判断调用哪个函数。一个小的解释器再加上一个命令表将给大家带来极大的便利。将接口从实际测试代码中分离出来,能够使我们更容易地在不可预见的方向扩展。
创建一个命令
让我们先从将要实现的一个小的命令列表开始:
版本
输出版本信息。
测试内存
运行闪存单元测试,完成后输出错误数。
闪烁LED
让LED以一个给定的频率闪烁。
帮助
列出可用的命令以及联机描述。
在实现了这些命令之后,随后添加新的命令应该很容易。
我将要展示如何在C语言里做到这点,因为它可能是所有实现方式中最让人头痛的。面向对象语言,如C + +和Java等,通过使用对象提供了一种友好的方式来实现这一功能。但是,C语言的方法小而快,因此在选择如何实现这种模式时请将这个因素考虑进去。对于不熟悉C语言函数指针的读者,在本节“函数指针没有那么可怕” 部分对函数指针做了简单介绍。
我们可以调用的命令将由一个名字、一个函数调用,以及一个帮助字符串组成。
typedef void(*functionPointerType)(void);
struct commandStruct {
char const *name;
functionPointerType execute;
char const *help;
};
该数据类型的数组将给出我们的命令列表。
const struct commandStruct commands[] ={
{"ver", &CmdVersion,
"Display firmware version"},
{"flashTest", &CmdFlashTest,
"Runs the flash unit test, printis number of errors upon completion"},
{"blinkLed", &CmdBlinkLed,
"Sets the LED to blink at a desired rate (parameter: frequency (Hz))"},
{"",0,""} //End of table indicator. MUST BE LAST!!!
};
命令执行函数CmdVersion、CmdFlashTest、CmdBlink将在别的地方实现。带参数的命令需要与解析器一起工作,从字符流中获得它们的参数。这不仅简化了这一段代码,而且还提供了更大的灵活性,允许每个命令设置其使用的条件,如参数的数量和类型。
注意,该列表不包含帮助(help)命令。这是一个特殊的宏命令,它输出这个列表中的所有条目的名称和帮助字符串。
函数指针没那么可怕
想象这种情况:直到程序已经运行的时候,我们才知道你想要运行什么函数。比如,从多个信号处理算法中运行一个来处理从传感器获得的数据。可以从由变量控制的switch语句开始:
switch (algorithm) {
case eFIRFilter:
return fir(data, dataLen);
case eIIRFilter:
return iir(data, dataLen);
...
}
现在,当想要改变这个算法时,发送一个命令或者按下一个按钮使算法变量的值改变。如果数据持续不断得到处理,那么系统仍然运行switch语句,即使没有改变算法。
在面向对象的语言,可以使用对接口的引用。每种算法对象将实现相同名的信号处理函数(对这里的信号处理例子,我们可以把它叫做filter)。然后,当该算法需要改变时,调用者对象会改变。根据不同的语言,接口的实现可以用一个关键字识别或通过继承。
C语言没有这些功能(并且由于编译器的限制,使用C++的继承可能是被禁止的)。因此我们使用函数指针,它可以做同样的事情。
为了声明一个函数指针,需要一个函数原型。对于switch语句,这将是一个传入数据指针和数据长度而没有任何返回值的函数。但是,它可以通过修改其第一个参数以返回结果。这些函数之一的原型看起来像这样:
void fir(uint16_t* data, uint16_t dataLen);
现在,取出函数名,并用括号括起来的星号和一个通用名称取代它:
void (filter)(uint16_t data, uint16_t dataLen);
如果要改变算法,就不要改变算法变量并通过switch语句选择一个函数去执行。此时,可以只在需要的时候改变算法并调用函数指针:
filter = &fir;
*filter(data, dataLen);
一旦理解了函数指针的思路,它就是在代码需要时随需而变的一个强大工具。有些常见的函数指针用途包括本章中所描述的命令结构、表明一个事件完成的回调函数、将按钮映射为上下文敏感的动作。
警告:函数指针的过度使用可能会导致处理器运行速度变慢。大多数处理器尝试预测代码将怎么执行,并据此加载适当的指令。函数指针禁止了分支预测,因为处理器无法猜测在调用之后会执行哪里的代码。
然而,其他一些选择动态函数调用的方法也一样中断了分支预测(比如switch语句)。除非处于手工优化汇编代码的阶段,否则通常没有必要为强大的函数指针所带来的一点点性能下降而担忧。
调用命令
当命令行客户端发送命令字符串以指明需要运行哪个命令时,需要选择命令并运行它。要做到这一点,需要遍历该表,寻找相匹配的字符串。找到相匹配的字符串之后,调用函数指针执行该函数。
与3.6.2节相比,这部分似乎太容易了。这是我们的目标。嵌入式系统是复杂的,由于其严格的约束和隐藏的依赖,有时是可怕的。这里(和许多其他模式)的目标是隔离复杂性。我们无法彻底消除复杂性,一个什么也不做的系统并不复杂,但同样也毫无用处。在创建命令表、解析代码并使用它的过程中,仍然有相当的复杂性,但这些细节可以限制在它们自己的问题空间里。
每个命令执行本身所需要的动作,或者调用另一个函数(接收者)来执行该操作。例如,Ver命令可能只是简单地输出版本号。另一方面,blinkled命令可能会调用已经存在任何函数去设置LED接口,并设置一个计时器,使其闪烁。在这个函数里,接收者才是用户真正想调用的代码。
如果一个命令实现了接收者(如版本命令),那么它称为智能命令。这可能有些不妥,但将命令和接收者分离通常是比较聪明的做法,这会让系统比较容易扩展。
3.6.4 命令模式
我们一直寻求的是一个正式的、经典的设计模式。我所描述的命令处理器是一个战术上的问题解决方案,命令模式则是战略上的,可以对它以及其他设计进行指导。
这个模式的总体目标是将命令处理过程与实际要执行的动作解耦合。命令模式展示了一个执行操作的接口。任何时候,当你遇到这样一种情形,系统中的一部分需要对另一部分发起请求,但中介对象不需要知道这些请求的内容时,请考虑命令模式。
如图3-14所示,命令模式有四个部分:
图3-14:命令处理器的工作原理
客户端
将命令映射到接收者。客户端创建具体的命令对象,并创建接收者和命令对象之间的关联。客户端可以在初始化的时候运行(如我们的例子创建一个数组)或动态地创建关联。
调用者
决定何时该命令需要运行并在需要运行时执行它。它不知道关于接收者的任何信息。它把每一个命令都视为相同的。
命令对象
一个用于执行操作的接口。这是一个C++类、Java接口,或者具有函数指针的C结构体。命令接口的一个实例称为具体命令 。
接收者
知道如何为请求提供服务。这是要运行的目标代码。
图3-15显示了命令模式中每个元素的目的。
图3-15:命令模式的知识屏障
注意: 可以将客户端作为通过将所有的命令伪装成一样的以保护系统的秘密。调用者会按照规则发挥作用,否则系统就知道它出错了。
命令接口可以更加丰富,可以添加日志命令、帮助函数和撤销函数。调用者知道何时调用这些命令,但依赖于客户端建立这些命令和实际实现。
因为细节是隐藏的,所以可以非常简单地添加实现多个命令的宏命令。要做到这一点,客户端创建一个要绑定在一起的基本命令清单。调用时,宏调用其中每个子命令(或者undo或者log)的执行函数。例如,输出版本(ver)并设置LED闪烁(blinkLED)的宏,将会调用这些命令的执行函数,并按照函数被调用的顺序使用传入的参数。相对于让宏去直接调用接收者函数,这样做提供了一个比较好的解耦层。