15、测试边界条件:空指针、数组越界、整数溢出等典型边界测试案例
边界条件测试,说白了就是找代码的「临界点」。
我做了十几年嵌入式开发,见过太多线上崩溃的案例——十有八九都出在边界上。空指针、数组越界、整数溢出,这三个是嵌入式C语言里最要命的「三座大山」。你想想看,一个指针传进来是NULL,你直接解引用,啪,段错误。数组下标多了一个1,踩到别人的内存,查三天都查不出来。整数溢出更隐蔽,明明算出来是个负数,你当正数用,系统直接跑飞。
嗯,这一章我们就来逐个击破。
15.1 空指针测试:别让NULL成为你的噩梦
我个人习惯,写函数的第一件事就是检查指针参数。别嫌啰嗦,这是保命的习惯。
核心原则:所有外部传入的指针,在解引用之前必须判空。
来看一个典型的错误代码:
// 错误示范:直接解引用,没有判空
void process_data(int *buffer, int len) {
for (int i = 0; i < len; i++) {
buffer[i] = buffer[i] * 2; // 如果buffer为NULL,这里直接崩溃
}
}
正确的做法应该是这样:
// 正确做法:先判空,再操作
int process_data(int *buffer, int len) {
if (buffer == NULL) {
return -1; // 返回错误码,让调用方处理
}
if (len <= 0) {
return -2; // 长度也要检查
}
for (int i = 0; i < len; i++) {
buffer[i] = buffer[i] * 2;
}
return 0;
}
我的经验:我在项目中遇到过一种情况——指针在函数中间被另一个线程置空了。所以光在入口判空还不够,如果函数里有异步操作,每次使用前最好再检查一次。这叫「防御性编程」,不丢人。
单元测试怎么写?我一般会构造三个用例:
- 传入NULL指针,期望返回错误码
- 传入有效指针但长度为0,期望返回错误码
- 传入有效指针和正常长度,期望处理成功
// 单元测试示例(使用Unity框架)
void test_process_data_null_pointer(void) {
int ret = process_data(NULL, 10);
TEST_ASSERT_EQUAL(-1, ret);
}
void test_process_data_zero_length(void) {
int buffer[10];
int ret = process_data(buffer, 0);
TEST_ASSERT_EQUAL(-2, ret);
}
void test_process_data_normal(void) {
int buffer[3] = {1, 2, 3};
int ret = process_data(buffer, 3);
TEST_ASSERT_EQUAL(0, ret);
TEST_ASSERT_EQUAL(2, buffer[0]);
TEST_ASSERT_EQUAL(4, buffer[1]);
TEST_ASSERT_EQUAL(6, buffer[2]);
}
15.2 数组越界:C语言的头号杀手
C语言不检查数组边界,这是它的「原罪」。你写buffer[10],但数组只有5个元素,编译器不会报错,运行时也不会立刻崩溃——它只是悄悄地把别人的内存给改了。这种bug最难查,因为症状往往出现在千里之外。
警告:数组越界是未定义行为。未定义行为意味着编译器可以「做任何事」——包括看起来正常运行,但换个编译器就崩溃。千万不要抱有侥幸心理。
我建议在写循环的时候,养成用「小于长度」而不是「小于等于长度」的习惯。一个经典的off-by-one错误:
// 错误:越界访问
int arr[5] = {0, 1, 2, 3, 4};
for (int i = 0; i <= 5; i++) { // 注意这里是 <=,不是 <
arr[i] = arr[i] * 2; // 当i=5时,访问了arr[5],越界!
}
正确的写法:
// 正确:使用 < 而不是 <=
for (int i = 0; i < 5; i++) {
arr[i] = arr[i] * 2;
}
我曾经在一个通信协议栈里遇到过一个bug——接收缓冲区定义的是256字节,但协议允许的最大包长是257字节。就多了一个字节,导致偶尔会覆盖掉相邻的变量,整个协议栈三天两头死机。查了整整一周才定位到。从那以后,我给自己定了个规矩:缓冲区大小永远比最大需求多留一个字节。
单元测试中,数组越界的测试用例:
- 访问下标为0的元素(合法边界)
- 访问下标为size-1的元素(合法边界)
- 访问下标为size的元素(越界,期望捕获或返回错误)
- 访问下标为负数的元素(越界,期望捕获或返回错误)
void test_array_boundary(void) {
int arr[5] = {0};
// 合法边界
arr[0] = 1; // 应该成功
arr[4] = 5; // 应该成功
// 越界测试(如果函数有保护机制)
// 假设有一个安全访问函数 safe_set(arr, 5, 10)
int ret = safe_set(arr, 5, 10); // 下标5,越界
TEST_ASSERT_EQUAL(-1, ret); // 期望返回错误
}
15.3 整数溢出:看不见的陷阱
整数溢出在嵌入式系统里特别常见。你想想看,一个16位的计数器,最大值是65535,再加1就变成0了。如果这个计数器控制着电机的转速,那后果不堪设想。
来看一个典型的溢出场景:
// 错误:没有考虑溢出
uint16_t counter = 65535;
counter = counter + 1; // 结果变成了0,而不是65536
printf("counter = %u\n", counter); // 输出0
有符号整数的溢出更危险:
// 有符号整数溢出是未定义行为
int a = 2147483647; // INT_MAX
int b = a + 1; // 未定义行为!可能变成负数,也可能崩溃
printf("b = %d\n", b); // 结果不可预测
关键点:有符号整数溢出是C语言标准中明确规定的未定义行为。编译器可以优化掉你的溢出检查代码,因为标准说「溢出不会发生」。所以不要依赖有符号整数的回绕行为。
我建议的做法是:在可能溢出的地方,提前做检查。
// 安全加法:检查是否溢出
int safe_add(int a, int b, int *result) {
if (result == NULL) return -1;
// 检查正溢出
if (b > 0 && a > INT_MAX - b) {
return -2; // 正溢出
}
// 检查负溢出
if (b < 0 && a < INT_MIN - b) {
return -3; // 负溢出
}
*result = a + b;
return 0;
}
单元测试用例:
void test_safe_add_overflow(void) {
int result;
// 正常加法
int ret = safe_add(100, 200, &result);
TEST_ASSERT_EQUAL(0, ret);
TEST_ASSERT_EQUAL(300, result);
// 正溢出
ret = safe_add(INT_MAX, 1, &result);
TEST_ASSERT_EQUAL(-2, ret); // 期望溢出错误
// 负溢出
ret = safe_add(INT_MIN, -1, &result);
TEST_ASSERT_EQUAL(-3, ret); // 期望溢出错误
// NULL指针
ret = safe_add(1, 2, NULL);
TEST_ASSERT_EQUAL(-1, ret);
}
15.4 边界测试的完整策略
说了这么多,我们来总结一下边界测试的套路。我个人习惯用一张图来梳理:
这张图是我自己总结的。你看,三个分支最后都指向同一个原则:先检查,后操作。不管是空指针、数组越界还是整数溢出,本质上都是「假设输入是合法的」,但现实世界里的输入往往不合法。
15.5 避坑指南:我踩过的那些坑
最后分享几个我亲身经历过的教训:
- 空指针不一定是NULL。 在嵌入式系统里,有些平台把地址0映射成了有效内存。这时候判空要用平台提供的宏,比如
IS_NULL_PTR(),而不是直接跟0比较。 - 数组越界不一定会立刻崩溃。 我遇到过一种情况——越界写到了栈上的返回地址,函数返回时直接跳到了随机地址。这种bug查起来简直要命。
- 整数溢出在优化级别高的时候会消失。 我曾经在-O0下测试好好的,一开-O2就出问题。因为编译器认为「有符号整数不会溢出」,直接把我的溢出检查代码优化掉了。
我的建议:写单元测试的时候,边界条件用例的数量应该占到你总用例数的30%以上。正常路径的测试往往跑一遍就过了,但边界条件才是真正考验代码质量的地方。
嗯,边界测试就讲到这里。记住一句话:代码的健壮性,取决于它对异常输入的处理能力。你写的每一个判空、每一个边界检查,都是在为你的系统买保险。
公众号:蓝海资料掘金营,微信deep3321