25、代码调试、测试与分析

代码调试、测试与分析

在软件开发过程中,调试、测试和分析是确保代码质量和可靠性的关键环节。本文将详细介绍一些重要的编译器选项、调试方法、单元测试框架以及静态分析的相关知识。

编译器选项

在编译代码时,有许多有用的编译器选项可以帮助我们提高代码的安全性、正确性和可移植性。以下是一些常见的编译器选项:
- /guard:cf :此选项必须同时传递给编译器和链接器,用于启用控制流防护(Control Flow Guard)功能,增强代码的安全性。
- /analyze :启用静态分析,提供有关代码中可能存在的缺陷的信息。静态分析将在后续详细讨论。
- /sdl :启用额外的安全功能,包括将与安全相关的额外警告视为错误,并提供额外的安全代码生成功能。此选项还启用了微软安全开发生命周期(SDL)中的其他安全功能,建议在所有关注安全的生产构建中使用。
- /permissive- :帮助识别和修复代码中的一致性问题,从而提高代码的正确性和可移植性。该选项禁用宽松行为,并设置 /Zc 编译器选项以实现严格一致性。在集成开发环境(IDE)中,此选项还会对不符合规范的代码进行下划线标记。
- /std:clatest :启用所有目前已实现的为 C23 提议的编译器和标准库功能。在撰写本文时,还没有 /std:c23 选项,但一旦可用,就可以使用它来构建 C23 代码。

调试实践

即使是经验丰富的程序员,也很难保证编写的代码一次编译运行就完全正确,调试是软件开发中不可或缺的环节。下面通过一个具体的示例来演示调试的过程。

以下是一个早期版本的 vstrcat 函数的示例代码:

#include <stdarg.h>
#include <string.h>
#include <stdio.h>
#include <stddef.h>
#define name_size 20U
char *vstrcat(char *buff, size_t buff_length, ...) {
  char *ret = buff;
  va_list list;
  va_start(list, buff_length);
  const char *part = nullptr;
  size_t offset = 0;
  while ((part = va_arg(list, const char *))) {
   buff = (char *)memccpy(buff, part, '\0', buff_length - offset) - 1;
   if (buff == nullptr) {
     ret[0] = '\0';
     break;
   }
   offset = buff - ret;
  }
  va_end(list);
  return ret;
}
int main() {
  char name[name_size] = "";
  char first[] = "Robert";
  char middle[] = "C.";
  char last[] = "Seacord";
  puts(
    vstrcat(
      name, sizeof(name), first, " ",
      middle, " ", last, nullptr
    )
  );
}

当运行这个程序时,它会按预期输出 “Robert C. Seacord”。但我们还需要确保这个使用固定大小数组存储姓名的程序,能够正确处理全名长度超过数组大小的情况。为此,我们将数组大小改为一个较小的值:

#define name_size 10U

再次运行程序时,会得到 “Segmentation fault” 错误。为了找出问题所在,我们使用 Linux 上的 Visual Studio Code 进行调试。

在调试器中运行程序后,从调用栈(CALL STACK)面板可以看到,程序在 libc 库的 __memmove_avx_unaligned_erms 函数中崩溃。同时,我们发现段错误发生在调用 memccpy 函数的那一行。由于这一行没有太多其他操作,我们推测可能是传递给 memccpy 函数的参数无效。

接下来,我们查看 memccpy 函数的标准定义:

#include <string.h>
void *memccpy(void * restrict s1, const void * restrict s2, int c, size_t n);

memccpy 函数将字符从 s2 指向的对象复制到 s1 指向的对象,在复制字符 c (转换为无符号字符)之后停止,或者在复制了 n 个字符之后停止,以先发生者为准。如果在重叠的对象之间进行复制,行为是未定义的。

从调试器的变量面板中,我们可以看到 part ret 的值都符合预期,但 buff 的值看起来很奇怪,它的值为 0xffffffffffffffff ,这与 EOF(-1)相同,并且无法访问该地址的内存。

buff 参数是一个字符指针,它被赋值为 memccpy 函数的返回值。根据 C 标准, memccpy 函数只能返回一个空指针或指向 s1 中字符的指针,但 buff 的值无法用这些情况来解释,问题变得更加复杂。

为了更深入地研究 vstrcat 函数的行为,我们在函数开始附近的第 12 行设置断点并开始调试。通过点击标题栏左侧的按钮,我们可以继续、单步执行、进入函数、跳出函数、重启和停止调试。从第 12 行开始,我们可以通过点击 “Step Over” 按钮单步执行程序。 vstrcat 函数会循环多次,我们需要逐步执行几次循环,观察变量面板中的值。仔细操作后,我们会发现,在调用 memccpy 函数后的第 18 行, buff 被设置为 0xffffffffffffffff ,并且这个错误没有被空指针检查检测到,导致在下一次迭代时发生段错误。

经过分析,我们发现问题的根源在于 memccpy 函数返回空指针表示在 part 的前 buff_length - offset 个字符中未找到 '\0' ,但我们在返回值上减去 1,当字符未找到时,这相当于从空指针减去 1,这在 C 语言中是未定义行为。在这个实现中,空指针用 0 表示,从 0 减去 1 会产生 0xffffffffffffffff 的值,从而导致后续调用 memccpy 函数时发生段错误。

修复这个问题的方法是将减 1 操作移到空指针检查之后,这样就可以避免这个错误。

单元测试

单元测试是验证代码正确性的重要手段,它可以增加我们对代码无缺陷的信心。单元测试是一些小型程序,用于测试代码的各个单元。在 C 语言中,单元通常是单个函数或数据抽象。

我们可以编写简单的测试代码,类似于普通的应用程序代码,但使用单元测试框架会更有优势。有许多可用的单元测试框架,如 Google Test、CUnit、CppUnit、Unity 等。根据 JetBrains 最近对 C 开发生态系统的调查,我们将重点介绍最受欢迎的 Google Test。

Google Test 适用于 Linux、Windows 和 macOS 系统。测试代码使用 C++ 编写,因此我们可以学习一种相关的编程语言来进行测试。如果想将测试限制在纯 C 语言中,CUnit 和 Unity 是不错的选择。

在 Google Test 中,我们通过编写断言来验证被测试代码的行为。Google Test 断言是类似函数的宏,是测试的核心语言。如果测试崩溃或断言失败,则测试失败;否则,测试成功。断言的结果可以是成功、非致命失败或致命失败。如果发生致命失败,当前函数将被中止;否则,程序将正常继续执行。

下面我们将使用 Google Test 在 Ubuntu Linux 上为 get_error 函数设置单元测试。首先,我们需要安装 Google Test,按照 Google Test GitHub 页面(https://github.com/google/googletest/tree/main/googletest)上的说明进行操作。

get_error 函数的代码如下:

char *get_error(errno_t errnum) {
  rsize_t size = strerrorlen_s(errnum) + 1;
  char* msg = malloc(size);
  if (msg != nullptr) {
    errno_t status = strerror_s(msg, size, errnum);
    if (status != 0) {
      strncpy_s(msg, size, "unknown error", size - 1);
    }
  }
  return msg;
}

这个函数调用了 strerrorlen_s strerror_s 函数,它们定义在规范性但可选的附录 K “边界检查接口” 中。不幸的是,GCC 和 Clang 都没有实现附录 K,因此我们将使用 Reini Urban 开发的 Safeclib 实现,可从 GitHub(https://github.com/rurban/safeclib)获取。

在 Ubuntu 上安装 libsafec-dev 可以使用以下命令:

% sudo apt install libsafec-dev

接下来是 get_error 函数的单元测试代码:

#include <gtest/gtest.h>
#include <errno.h>
#define errno_t int
// implemented in a C source file
extern "C" char* get_error(errno_t errnum);
namespace {
TEST(GetErrorTest, KnownError) {
    EXPECT_STREQ(get_error(ENOMEM), "Cannot allocate memory");
    EXPECT_STREQ(get_error(ENOTSOCK), "Socket operation on non-socket");
    EXPECT_STREQ(get_error(EPIPE), "Broken pipe");
  }
  TEST(GetErrorTest, UnknownError) {
    EXPECT_STREQ(get_error(-1), "Unknown error -1");
  }
} // namespace
int main(int argc, char** argv) {
  ::testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

大部分 C++ 代码是样板代码,可以直接复制,其中两个不是样板代码的部分是 extern "C" 声明和测试部分。 extern "C" 声明用于改变链接要求,确保 C++ 编译器链接器不会对函数名进行修饰。只有在使用 C 语言编译但使用 C++ 链接时,才需要这个声明。

两个测试用例使用 TEST 宏指定,该宏接受两个参数,第一个参数是测试套件的名称,第二个参数是测试用例的名称。在函数体中插入 Google Test 断言以及任何额外的 C++ 语句。在上述代码中,我们使用了 EXPECT_STREQ 断言来验证两个字符串的内容是否相同。

以下是一个简单的 CMakeLists.txt 文件,用于构建测试:

cmake_minimum_required(VERSION 3.21)
cmake_policy(SET CMP0135 NEW)
project(chapter-11)
# GoogleTest requires at least C++14
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 23)
include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip
)
FetchContent_MakeAvailable(googletest)
include(ExternalProject)
ExternalProject_Add(
  libsafec
  BUILD_IN_SOURCE 1
  URL https://github.com/rurban/safeclib/releases/download/v3.7.1/safeclib-3.7.1.tar.gz
  CONFIGURE_COMMAND autoreconf --install
  COMMAND ./configure --prefix=${CMAKE_BINARY_DIR}/libsafec
)
ExternalProject_Get_Property(libsafec install_dir)
include_directories(${install_dir}/src/libsafec/include)
link_directories(${install_dir}/src/libsafec/src/.libs/)
enable_testing()
add_library(error error.c)
add_dependencies(error libsafec)
add_executable(tests tests.cc)
target_link_libraries(
  tests
  error
  safec
  GTest::gtest_main
)
include(GoogleTest)
gtest_discover_tests(tests)

如果选择使用 apt install 命令安装 libsafec-dev ,则可以删除与安装 libsafec 相关的行。

使用以下命令构建并运行测试:

$ cmake -S . -B build
$ cmake --build build
$ ./build/tests

测试用例针对 <errno.h> 中的几个错误号进行测试。具体测试多少个错误号取决于我们的目标。理想情况下,测试应该全面覆盖所有错误号,但这可能会很繁琐。一旦确定代码正常工作,我们主要是在测试底层 C 标准库函数的实现是否正确。因此,我们可以选择测试可能会用到的错误号,但这也需要识别程序中调用的所有函数以及它们可能返回的错误代码。在这个示例中,我们选择对列表中不同位置的几个随机选择的错误号进行了一些抽查测试。

运行测试后,我们得到以下结果:

$ ./build/tests
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from GetErrorTest
[ RUN      ] GetErrorTest.KnownError
[       OK ] GetErrorTest.KnownError (0 ms)
[ RUN      ] GetErrorTest.UnknownError
/home/rcs/tests.cc:19: Failure
Expected equality of these values:
  get_error(-1)
    Which is: "Unknown error -1"
  "unknown error"
[  FAILED  ] GetErrorTest.UnknownError (0 ms)
[----------] 2 tests from GetErrorTest (0 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (0 ms total)
[  PASSED  ] 1 test.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] GetErrorTest.UnknownError
 1 FAILED TEST

从测试输出中可以看出, KnownError 测试用例通过,而 UnknownError 测试用例失败。 UnknownError 测试失败是因为 EXPECT_STREQ(get_error(-1), "unknown error"); 这个断言返回了 false 。测试假设 get_error 函数中的错误路径会执行并返回字符串 "unknown error" ,但实际上 strerror_s 函数返回了 "Unknown error -1" 。查看 strerror_s 函数的源代码(https://github.com/rurban/safeclib/blob/master/src/str/strerror_s.c),我们发现该函数确实会返回错误代码。因此, strerror_s 函数的实现是正确的,但我们对其行为的假设是错误的。

get_error 函数的实现存在一个缺陷,当 strerror_s 函数失败时,它返回 "unknown error" ,但根据标准, strerror_s 函数在所需字符串的长度小于 maxsize 且没有运行时约束违规时返回零,否则返回非零值。因此,如果 strerror_s 函数返回非零值,说明发生了严重错误,需要重新考虑该函数的设计。更好的做法是在错误条件下返回空指针或以与系统整体错误处理策略一致的方式处理错误。以下是更新后的 get_error 函数:

char *get_error(errno_t errnum) {
  rsize_t size = strerrorlen_s(errnum) + 1;
  char* msg = malloc(size);
  if (msg != nullptr) {
    errno_t status = strerror_s(msg, size, errnum);
    if (status != 0) return nullptr;
  }
  return msg;
}

我们还需要修复测试,以检查 get_error(-1) 返回的正确字符串:

EXPECT_STREQ(get_error(-1), "Unknown error -1");

修改后重新构建并运行测试,两个测试用例都成功通过:

$ ./build/tests
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from GetErrorTest
[ RUN      ] GetErrorTest.KnownError
[       OK ] GetErrorTest.KnownError (0 ms)
[ RUN      ] GetErrorTest.UnknownError
[       OK ] GetErrorTest.UnknownError (0 ms)
[----------] 2 tests from GetErrorTest (0 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (0 ms total)
[  PASSED  ] 2 tests.

通过这次测试,我们不仅发现了设计错误,还意识到测试并不完整,因为我们没有测试错误情况。我们应该添加更多测试来确保错误情况得到正确处理。

静态分析

静态分析是一种在不执行代码的情况下评估代码的过程(ISO/IEC TS 17961:2013),用于提供有关可能存在的软件缺陷的信息。

然而,静态分析存在实际限制,因为软件的正确性在计算上是不可判定的。例如,计算机科学中的停机定理表明,有些程序的精确控制流无法通过静态分析确定。因此,任何依赖于控制流的属性(如停机)对于某些程序可能无法判定。这导致静态分析可能无法报告实际存在的缺陷,或者报告实际上不存在的缺陷。

未能报告代码中实际存在的缺陷称为假阴性(False Negative),这是严重的分析错误,可能会让我们产生错误的安全感。大多数工具会采取谨慎的策略,因此会产生假阳性(False Positive)。假阳性是指测试结果错误地表明存在缺陷。工具可能会报告一些高风险的缺陷,但由于避免用大量假阳性信息淹没用户,可能会遗漏其他缺陷。当代码过于复杂而无法完全分析时,也可能会出现假阳性。函数指针和库的使用会增加假阳性的可能性。

理想情况下,分析工具应该在分析中既完整又可靠。如果一个分析器不会给出假阴性结果,则认为它是可靠的;如果它不会产生假阳性,则认为它是完整的。对于给定规则的可能性如下表所示:
| 假阳性 | 假阴性 | 分析器状态 |
| ---- | ---- | ---- |
| 是 | 是 | 不完整且不可靠 |
| 否 | 是 | 完整但不可靠 |
| 是 | 否 | 不完整但可靠 |
| 否 | 否 | 完整且可靠 |

编译器会进行有限的分析,提供关于代码中高度局部化问题的诊断信息,这些问题不需要太多推理。例如,当比较有符号值和无符号值时,编译器可能会发出类型不匹配的诊断信息,因为识别这种错误不需要额外的信息。如前文所述,有许多编译器标志可以控制编译器的诊断信息,例如 Visual C++ 的 /W4 和 GCC、Clang 的 -Wall

通过调试、单元测试和静态分析等手段,我们可以有效地提高代码的质量和可靠性,减少潜在的缺陷和错误。在实际开发中,应充分利用这些工具和方法,确保代码的正确性和稳定性。

代码调试、测试与分析

调试、测试与分析的流程总结

为了更清晰地展示调试、测试与分析的整体流程,我们可以用 mermaid 流程图来表示:

graph LR
    A[编写代码] --> B[编译代码]
    B --> C{是否有编译错误}
    C -- 是 --> D[修复编译错误]
    D --> B
    C -- 否 --> E[运行程序]
    E --> F{是否有运行时错误}
    F -- 是 --> G[调试程序]
    G --> H[定位问题]
    H --> I[修复问题]
    I --> B
    F -- 否 --> J[进行单元测试]
    J --> K{单元测试是否通过}
    K -- 否 --> L[修复测试失败问题]
    L --> B
    K -- 是 --> M[进行静态分析]
    M --> N{静态分析是否有警告}
    N -- 是 --> O[处理警告]
    O --> B
    N -- 否 --> P[代码质量达标]

从这个流程图中可以看出,软件开发是一个迭代的过程。首先编写代码,然后进行编译,如果有编译错误则修复后重新编译。编译通过后运行程序,若出现运行时错误则进行调试,定位并修复问题。程序能正常运行后进行单元测试,测试不通过则再次修复代码。单元测试通过后进行静态分析,处理静态分析的警告,直到代码质量达标。

不同工具和方法的对比
工具/方法 优点 缺点 适用场景
编译器选项(如 /analyze、/sdl 等) 能在编译阶段发现一些潜在问题,使用方便,无需额外配置复杂环境 只能发现一些较为基础和局部的问题,对复杂逻辑和运行时问题检测能力有限 日常代码开发过程中,作为基本的代码检查手段
调试器(如 Visual Studio Code 调试器) 可以实时观察程序运行状态,精确定位问题所在,对于复杂的运行时错误有很好的调试效果 需要手动设置断点、单步执行等操作,调试过程可能比较耗时 当程序出现运行时错误,如段错误、内存访问错误等情况
单元测试框架(如 Google Test) 可以对代码的各个单元进行全面测试,提高代码的可维护性和可靠性,能及时发现代码修改带来的问题 需要编写额外的测试代码,增加开发工作量,对于一些复杂的业务逻辑,测试用例编写难度较大 在代码开发完成后,对关键函数和模块进行功能验证
静态分析工具 可以在不运行代码的情况下发现潜在的软件缺陷,能发现一些不易察觉的问题,如内存泄漏、未初始化变量等 可能会产生大量的假阳性结果,增加开发人员的排查工作量,且对于一些依赖运行时环境的问题无法检测 在代码开发过程中,定期对代码进行全面检查,尤其是大型项目
实际应用中的注意事项
  • 编译器选项的使用 :在不同的开发环境和项目需求下,合理选择编译器选项。例如,在安全要求较高的项目中,应启用 /sdl 选项;在追求代码严格符合标准时,使用 /permissive- 选项。同时,要注意不同编译器对同一选项的支持可能存在差异。
  • 调试的技巧 :在调试过程中,要善于利用调试器的各种功能,如设置断点、观察变量值、查看调用栈等。对于复杂的程序,可以采用逐步缩小问题范围的方法,先确定大致的问题区域,再深入细节进行调试。另外,记录调试过程中的关键信息,有助于后续的问题排查和总结经验。
  • 单元测试的编写 :编写单元测试时,要遵循测试用例的独立性原则,每个测试用例应该相互独立,互不影响。测试用例要覆盖各种可能的输入情况,包括正常情况和边界情况。同时,定期更新和维护测试用例,确保其与代码的变化保持同步。
  • 静态分析的处理 :对于静态分析工具给出的警告,要认真对待,但也不要被大量的假阳性结果所迷惑。可以根据警告的严重程度和实际情况,有选择地处理警告。对于确实存在问题的警告,要及时修复;对于一些不确定的警告,可以进一步分析或通过注释等方式进行标记。
总结与展望

通过调试、测试和分析等手段,我们能够有效地提高代码的质量和可靠性,减少软件中的潜在缺陷。在实际开发过程中,要综合运用这些方法,形成一个完整的质量保障体系。

未来,随着软件开发技术的不断发展,调试、测试和分析工具也将不断完善。例如,调试器可能会更加智能化,能够自动分析程序运行状态并给出可能的问题解决方案;单元测试框架可能会支持更多的编程语言和开发环境,提供更便捷的测试编写和执行方式;静态分析工具可能会提高分析的准确性和效率,减少假阳性结果的产生。

作为开发者,我们要不断学习和掌握这些工具和方法,跟上技术发展的步伐,以更好地应对日益复杂的软件开发挑战,开发出高质量、可靠的软件产品。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值