3、硬件抽象层(HAL)测试策略:HAL库函数接口测试、寄存器读写测试、GPIO/UART/Timer外设的单元测试方法
硬件抽象层,说白了就是给上层软件和底层硬件之间加了一层「翻译官」。我做了这么多年嵌入式,见过太多项目因为HAL层测试不到位,最后整板子跑起来全是玄学问题。你想想看,如果这层没测透,你根本分不清是硬件坏了还是代码写错了。
这一章,我们就来聊聊HAL层的测试到底该怎么搞。我会把HAL库函数接口测试、寄存器读写测试,还有GPIO、UART、Timer这三个最常用的外设单元测试方法,一个一个拆开讲清楚。
核心观点:HAL层测试不是为了测HAL本身,而是为了给上层应用提供一个「可信的硬件操作界面」。测透了HAL,上层调试时你才敢说「硬件没问题,肯定是逻辑的锅」。
3.1 HAL库函数接口测试
HAL库函数接口测试,测的是「函数调用能不能按预期操作硬件」。我个人习惯把这类测试分成三块:参数校验、状态机转换、回调机制。
3.1.1 参数校验测试
很多HAL函数都有参数范围限制。比如设置定时器分频系数,你传个0进去,函数应该返回错误码,而不是直接死机。我在项目中遇到过,某款芯片的HAL库对参数校验做得特别松,传了个非法值进去,结果定时器直接不工作了,查了两天才发现是参数越界。
参数校验测试的典型用例:
| 测试场景 | 输入参数 | 期望结果 |
|---|---|---|
| 正常参数 | 分频系数=100 | 返回HAL_OK |
| 边界值下限 | 分频系数=1 | 返回HAL_OK |
| 边界值上限 | 分频系数=65535 | 返回HAL_OK |
| 越界值 | 分频系数=0 | 返回HAL_ERROR |
| 空指针 | 句柄=NULL | 返回HAL_ERROR |
小技巧:写参数校验测试时,建议把边界值、非法值、空指针这三种情况做成一个测试矩阵。我一般用Excel维护这个矩阵,每次芯片换型号时,直接复用矩阵改参数范围就行。
3.1.2 状态机转换测试
HAL库内部通常维护着外设的状态机。比如UART,初始化后是HAL_UART_STATE_RESET,调用HAL_UART_Init()后变成HAL_UART_STATE_READY,开始收发后变成HAL_UART_STATE_BUSY。状态机转换测试,就是要验证这些状态跳转是否符合预期。
我曾经踩过一个坑:某款MCU的HAL库,在UART接收出错后,状态机卡在了HAL_UART_STATE_BUSY,再也回不到READY状态。上层应用以为还在接收,实际上已经死锁了。后来我在测试用例里专门加了一条「错误恢复后状态是否回到READY」的断言,才把这个bug揪出来。
3.1.3 回调机制测试
HAL库的很多操作是异步的,比如UART接收完成、定时器溢出,都会触发回调函数。回调机制测试要验证两件事:回调函数是否被正确调用、回调函数中传递的参数是否正确。
测试方法其实很简单——在测试代码里注册一个「桩回调」,里面只做一件事:把收到的参数存到全局变量里。然后触发外设事件,检查全局变量里的值对不对。
// 桩回调函数
volatile uint32_t g_rx_callback_count = 0;
volatile uint16_t g_rx_received_byte = 0;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
g_rx_callback_count++;
g_rx_received_byte = huart->Instance->DR;
}
// 测试用例:验证回调是否被触发
void test_uart_rx_callback(void) {
// 模拟硬件接收一个字节 0x55
uart_simulate_rx_byte(0x55);
// 断言回调被调用
TEST_ASSERT_EQUAL(1, g_rx_callback_count);
// 断言收到的数据正确
TEST_ASSERT_EQUAL(0x55, g_rx_received_byte);
}
3.2 寄存器读写测试
寄存器读写测试,是HAL层测试的「底线」。如果寄存器读写都不可靠,那上面所有的测试都是白搭。我一般把寄存器测试分成三个层次:单比特读写、多比特域读写、连续地址读写。
3.2.1 单比特读写测试
单比特测试,就是验证某个寄存器的某一位能不能单独置1和清0。做法很简单:先读回原始值,然后写一个只有目标位为1的值,再读回来检查。清0测试同理。
这里有个坑要注意——有些寄存器的位是只读的,或者写1清0的。你写1进去,读回来可能是0。所以测试前一定要先看芯片手册,搞清楚每个位的属性。
警告:千万不要在寄存器读写测试里用「写-读-比较」这种简单模式。很多芯片的寄存器有「写缓冲」或「读缓冲」,你写进去的值可能被硬件缓存了,读回来的其实是缓存值,不是真正的寄存器值。正确的做法是:写一个值,然后做一次无关的寄存器操作(比如读另一个寄存器),再回来读目标寄存器。
3.2.2 多比特域读写测试
多比特域测试,主要针对那些一个寄存器里包含多个功能位的场景。比如GPIO的配置寄存器,可能低4位是模式选择,中间4位是输出类型,高8位是上下拉配置。这种测试要验证:修改某个域时,其他域的值不会被意外改变。
我常用的测试模式是「写-改-读」三步法:
- 先写一个已知的初始值到寄存器
- 然后只修改目标域的值
- 最后读回整个寄存器,检查目标域是否变了,其他域是否保持不变
3.2.3 连续地址读写测试
连续地址测试,主要是为了发现地址译码错误或总线竞争问题。比如你操作UART的波特率寄存器,结果不小心把旁边Timer的寄存器也改了。这种bug在复杂的MCU里并不少见。
测试方法:选一段连续的寄存器地址空间,依次写入不同的模式值,然后读回来检查。如果发现某个地址的值和写入的不一致,或者相邻地址的值互相串了,那就要怀疑硬件设计或者HAL库的地址映射有问题了。
3.3 GPIO外设单元测试
GPIO的测试,核心就三件事:输出电平、输入读取、中断触发。我习惯用「回环测试」的方法——把一个GPIO引脚设为输出,另一个相邻引脚设为输入,然后用杜邦线把两个引脚连起来。
3.3.1 输出电平测试
测试步骤:
- 调用HAL_GPIO_WritePin()设置输出高电平
- 通过输入引脚读回电平
- 断言读回的是高电平
- 同理测试低电平
这里要注意,GPIO的输出速度会影响电平的建立时间。如果你用高速输出模式,但测试代码读得太快,可能读到的是中间态。我一般会在写操作后加几个NOP延时,等电平稳定了再读。
3.3.2 输入读取测试
输入测试和输出测试是配套的。但有一个容易被忽略的点——上下拉电阻的测试。很多MCU的GPIO内部有可编程的上拉或下拉电阻,这个功能在按键检测、I2C总线等场景中特别重要。
测试方法:先把引脚配置为输入+上拉,然后读回电平,应该是高电平;再改为下拉,读回应该是低电平。如果读回来的值和预期不符,说明内部上下拉电阻没生效,或者配置寄存器写错了。
3.3.3 中断触发测试
GPIO中断测试,我建议用「软件触发+硬件回环」的方式。具体做法:
- 把GPIO_A配置为输出,GPIO_B配置为输入中断(上升沿触发)
- 在GPIO_B的中断服务函数里,设置一个全局标志位
- 然后通过GPIO_A输出一个上升沿
- 检查中断标志位是否被置位
我曾经遇到过一个问题:某款芯片的GPIO中断,在上升沿和下降沿同时使能时,会触发两次中断。后来我在测试用例里专门加了一个「中断次数计数器」,才发现这个bug。
3.4 UART外设单元测试
UART测试比GPIO复杂一些,因为它涉及波特率、数据格式、流控等多个维度。我一般把UART测试分成:基本收发测试、波特率精度测试、错误处理测试。
3.4.1 基本收发测试
基本收发测试,用回环法最直接。把UART的TX和RX引脚短接,然后发送一串数据,看能不能原样收回来。
void test_uart_loopback(void) {
uint8_t tx_data[] = {0x01, 0x02, 0x03, 0xFF, 0xFE};
uint8_t rx_buffer[5] = {0};
// 发送数据
HAL_UART_Transmit(&huart1, tx_data, 5, 100);
// 延时等待接收完成
HAL_Delay(10);
// 接收数据
HAL_UART_Receive(&huart1, rx_buffer, 5, 100);
// 比较发送和接收的数据
for(int i = 0; i < 5; i++) {
TEST_ASSERT_EQUAL(tx_data[i], rx_buffer[i]);
}
}
经验之谈:回环测试时,发送和接收之间一定要加延时。因为UART是异步的,发送完最后一个字节后,接收器可能还没把数据从移位寄存器搬到接收缓冲区。不加延时的话,你读到的可能是上一帧的残留数据。
3.4.2 波特率精度测试
波特率精度测试,说白了就是验证实际通信速率和理论值之间的误差。这个测试需要用到示波器或者逻辑分析仪。我一般会在测试板上留一个测试点,把UART的TX引脚引出来,然后用示波器测量一个字节的位宽。
比如设置115200波特率,理论位宽是8.68微秒。如果实测位宽是8.5微秒,误差约2%,还在可接受范围内。但如果误差超过5%,就可能出现误码。我在项目中遇到过,某款MCU的HAL库在计算波特率分频系数时,用了整数除法,导致某些波特率下的误差高达8%。
3.4.3 错误处理测试
UART通信中常见的错误有:帧错误、奇偶校验错误、溢出错误。测试这些错误,需要人为制造异常情况。比如:
- 帧错误:发送一个停止位为0的数据帧(正常停止位是1)
- 奇偶校验错误:发送一个校验位与数据不匹配的帧
- 溢出错误:连续发送数据,不给接收端读取的时间
测试时,要验证HAL库的错误回调函数是否被触发,以及错误标志位是否被正确置位。
3.5 Timer外设单元测试
Timer的测试,核心是验证定时精度和PWM波形质量。我一般分三步走:基本定时功能测试、PWM输出测试、输入捕获测试。
3.5.1 基本定时功能测试
基本定时功能测试,就是验证定时器能不能在设定的时间后产生中断。测试方法:
- 配置定时器,设置一个较短的定时周期(比如1ms)
- 启动定时器
- 在主循环里轮询一个全局计数器,看它是否按预期递增
更精确的测试,可以用另一个定时器来测量时间。比如用Timer1产生1ms中断,用Timer2(配置为输入捕获模式)来测量Timer1中断引脚上的脉冲宽度。如果测出来的脉冲宽度是1ms ± 几个微秒,说明定时精度没问题。
3.5.2 PWM输出测试
PWM测试,主要验证频率和占空比。这个测试离不开示波器。我一般会写一个测试用例,依次设置不同的占空比(10%、50%、90%),然后用示波器测量实际波形。
这里有个容易忽略的点——PWM的初始电平。有些HAL库在初始化PWM时,输出引脚会有一个短暂的毛刺。我在项目中遇到过,PWM初始化时引脚先输出高电平,然后才变成低电平,导致连接的电机突然抖动了一下。后来我在测试用例里加了一条「初始化后引脚电平是否与预期一致」的断言。
3.5.3 输入捕获测试
输入捕获测试,就是验证定时器能不能准确测量外部信号的频率和脉宽。测试方法:用另一个定时器产生一个已知频率的方波,然后接到输入捕获引脚上,看测量结果是否一致。
比如用Timer1产生1kHz的方波,接到Timer2的输入捕获引脚上。如果Timer2测量到的频率是1kHz ± 1Hz,说明输入捕获功能正常。如果偏差太大,可能是时钟源配置有问题,或者捕获中断的响应延迟太高。
3.6 本章知识体系
下面这张图,把HAL层测试的整体框架和本章讲到的测试方法串起来了。你可以把它当作一个测试清单,做项目时对着这张图一项一项过。
嗯,这一章的内容就到这里。HAL层测试做扎实了,后面应用层的调试会轻松很多。你想想看,如果连寄存器读写都不可靠,那上层代码写得再漂亮也是白搭。所以,别嫌麻烦,把这一层的测试用例写全、跑透,后面你会感谢自己的。