为了理解 Git 的工作机制,近期尝试用 C 语言编写 Git 基本功能的简单实现。一如继往 ,在调试 SHA-1 哈希码与对象文件生成的过程中也遇到了一些“怪异”的错误。最终发现是数组越界导致的。由于编译器给出的错误只是现象而不是原因,而且是站在编译的角度来看的,因此当数组存在越界时,报出的错误往往非常“怪异”,显示的出错位置也一般不是错误的发源处。例如,今天在调试 git-in-c 时,程序在退出时会报 OpenSSL CTX 的 free(str) 出现问题。若怀疑是 OpenSSL 的问题,则根本无从下手解决。又使用了 valgrind 检测内存是否有错误,结果报告中的一个错误说是:

==391667== Use of uninitialised value of size 8
==391667==    at 0x4EF0A42: _itoa_word (_itoa.c:178)
==391667==    by 0x4EFBE58: __vfprintf_internal (vfprintf-process-arg.c:164)
==391667==    by 0x4F172F6: __vsprintf_internal (iovsprintf.c:96)
==391667==    by 0x4EF7D60: sprintf (sprintf.c:30)
==391667==    by 0x499EB6C: generate_object_path_components_from_sha1_hash (cgit.c:609)
==391667==    by 0x499EC2F: write_blob_object_file (cgit.c:632)
==391667==    by 0x1093FB: main (main.c:64)
==391667==  Uninitialised value was created by a stack allocation
==391667==    at 0x109219: main (main.c:11)

但实际上,我对于传入 sprintf 的动态数组,都已经正确分配了内存,而是否对其初始化并不必要。之后,即便我增加了 memset 对这段内存初始化,也还是同样的问题。

最终发现,下面这段代码出现了数组越界:

for (size_t i = 1; i <= SHA1_BYTE_LENGTH; i++)
  sprintf((char *)(path_components[2] + (i - 1) * 2), "%02x", sha1_hash[i]);

正确的应为:

for (size_t i = 1; i < SHA1_BYTE_LENGTH; i++)
  sprintf((char *)(path_components[2] + (i - 1) * 2), "%02x", sha1_hash[i]);

这样一来,一切就说得通了:

  1. 数组越界后,访问的内存区间超出了我的程序通过 malloc 申请的范围,所以 valgrind 提示说有 8 个字节的内存未经初始化。
  2. 因为数组越界,也影响到了 OpenSSL 使用到的内存,从而产生了意想不到的效果。

Lessons learned

C/C++ 程序编译与运行过程中出现“怪异”现象时,若编译器给出的错误提示与代码位置以及程序在异常退出时产生的 coredump 堆栈信息不符合我们的直观理解,就不能紧盯着错误提示、代码位置、堆栈函数不放,而要在出错位置的周围找原因。这是由于编译器与程序给出的信息只是现象而不是原因,而且是站在编译器与程序代码的角度来看的。这就要求我们理解编译器的“潜台词”(connotation),透过表象抓住背后的本质。