[转自:《深入理解计算机系统》,Randal E.Bryant & David O' Hallaron]
10.11 C程序中常见的与存储器有关的错误
对C程序员来说,管理和使用虚拟存储器可能是个困难的、容易出错的任务。与存储器有关的错误属于那些最令人惊恐的错误,因为它们经常在时间和空间上,都在距错误源一段距离之后,才表现出来。将错误的数据编写到错误的位置,你的程序可能在最终失败之前运行了好几个小时,且使程序中止的位置距离错误的位置已经很远了。
在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常终止我们的程序。而且,虚拟存储器的某些区域是只读的。试图写这些区域将造成以保护异常终止这个程序。
间接引用坏指针的一个常见示例是经典的scanf错误。假设我们想要使用scanf从stdin读一个整数到一个变量。做这件事情正确的方法是传递给scanf一个格式串和变量的地址:
scanf("%d", &val);
然而,对于C程序员初学者而言(对有经验者也是如此!),很容易传递val的内容,而不是它的地址:
scanf("%d", val);
在这种情况下,scanf将把val的内容解释为一个地址,并试图将一个字写到这个位置。在最好的情况下,程序立即以异常终止。在最糟糕的情况下,val的内容对应于虚拟存储器的某个合法的读/写区域,于是我们就覆盖了存储器,这通常会在相当一段时间后才造成灾难性的、令人困惑的后果。
10.11.2读未初始化的存储器
虽然.bss存储器位置(诸如未初始化的全局C变量)问题被加载器初始化为零,但是对于堆存储器却并不是这样的。一个常见的错误就是假设堆存储器被初始化为零:int *matvec(int **A, int *x, int n) { int i, j; int *y = (int *) malloc(n *sizeof(int)); for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { y[i] += A[i][j] * x[j]; } } return y; }
在这个示例中,程序员不正确地假设向量y被初始化为零。正确的实现方式是在第二个for之前将y[i]设置为零,或者使用calloc。
如果一个程序不检查输入串的大小就写入栈中目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)。例如,下面的函数就有缓冲区错误,因为gets函数拷贝一个任意长度的串到缓冲区。为了纠正这个错误,我们必须使用fgets函数,这个函数限制了输入串的大小。
void bufoverflow()
{
char buf[64];
gets(buf); /* here is the stack buffer overflow bug */
return;
}
一个常见的错误是假设指向对象的指针和它们所指向的对象是相同大小的:
/* Createan n * m array */ int **makeArray1(int n, int m) { int i; int **A = (int **) malloc(n * sizeof(int)); for (i = 0; i < n; i++) A[i] = (int *)malloc(m * sizeof(int)); return A; }这里的目的是创建一个由n个指针组成的数组,每个指针都指向一个包含m个int的数组。然而,因为程序员在第5行将sizeof(int *)写成了sizeof(int),代码实际创建的是一个int的数组。这段代码只有在int和指向int的指针大小相同的机器上运行良好。
但是,如果我们在像Alpha这样的机器上运行这段代码,其中指针大于int,那么第7行和第8行的循环将写到超出A数组末端的地方。因为这么些字中的一个很可能是已分配块的边界标记脚部,所以我们可能不会发现这个错误,直到在这个程序执行很长一段时间后,我们释放这个块,此时,分配器中的合并代码会戏剧性地失败,而没有任何明显的原因。这是“在远处起作用(action at distance)”的一个阴险示例,这类“在远处起作用”是与存储器有关的编程错误的典型情况。
错位(Off-by-one)错误是另一种很常见的覆盖错误发生的原因:
/* Createan n * m array */ int**makeArray2(int n, int m) { int i; int **A = (int **) malloc(n *sizeof(int *)); for (i = 0; i <= n; i++) A[i] = (int *) malloc(m * sizeof(int)); return A; }
这是前面一节中程序的另一个版本。这里我们在第5行创建了一个n个元素的指针数组,但是随后在第7行和第8行试图初始化这个数组的n+1个元素,在这个过程中覆盖了A数组后面的某个存储器。
如果我们不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是期望操作指针所指向的对象。比如,考虑下面的函数,其目的是删除一个有*size项的二叉堆里的第一项,然后对剩下的*size-1项重新建堆。
int*binheapDelete(int **binheap, int *size) { int *packet = binheap[0]; binheap[0] = binheap[*size - 1]; *size--; /*this should be (*size)-- */ heapipfy(binheap, *size, 0); return (packet); }在第6行,目的是减少size指针指向的整数的值(也就是说是(*size)--)。然而,因为一元--和*运行符优先级,从右向左结合,所以第6行中的代码实际减少的是指针自己的值,而不是它所指向的整数的值。如果我们幸运地话,程序会立即失败,但是更有可能发生的是,当程序在它执行过程的很后面产生出一个不正确的结果,我们只能在那里抓破脑袋了。这里的原则是如果你对优先级和结合性有疑问,就使用括号。比如,在第6行,我们可以清晰地表示我们的目的,使用表达式(*size)--。
另一种常见的错误是忘记了指针的算术操作是以它们指向的对象的大小为单位来进行的,而这种大小单位并不一定是字节。例如,下面函数的目的是扫描一个int数组,并返回一个指针,指向val的首次出现:
int*search(int *p, int val) { while (*p && *p != val) p += sizeof(int); /* should be p++ */ return p; }
10.11.8引用不存在的变量
没有太多经验的C程序员不理解栈的规则,有时会引用不再合法的本地变量,如下列所示:
int*stackref() { int val; return &val; }这个函数返回一个指针(比如说是p),指向栈里的一个局部变量,然后弹出它的栈帧。尽管p仍然指向一个合法的存储器地址,但是它已经不再指向一个合法的变量了。当以后在程序中调用其它函数时,存储器将重用它们的栈帧。后来,如果程序分配某个值给*p,那么它可能实际正在修改另一个函数的栈帧中的一个条目,从而带来潜在地灾难性的、令人困惑的后果。
一个相似的错误是引用已经被释放了的堆块中的数据。例如,考虑下面的示例,这个示例在第6行分配了一个整数数组x,在第10行先释放了块,然后在第14行又引用了它。
int *heapref(int n, int m) { int i; int *x, *y; x = (int *) malloc(n *sizeof(int)); /* ... */ /* other calls to malloc and free go here */ free(x); y = (int *) malloc m * sizeof(int)); for (i = 0; i < m, i++) y[i] = x[i]++; /*oops! x[i] is a word in a free block */ return y; }
取决于在第6行和第10行发生的malloc和free的调用模式,当程序在第14行引用x[i]时,数组x可能是某个其他已分配块的一部分了,因此其内容被重写了。当其他许多与存储器有关的错误一样,这个错误只会在程序执行的后面,当我们注意到y中的值被破坏了时,才会显现出来。
存储器泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放已分配块,而在堆里创建了垃圾时,会发生这种问题。例如,下面的函数分配了一个堆块x,然后不释放它就返回。
voidleak(int n) { int *x = (int *)malloc(n *sizeof(int)); return; /* x is garbage at this point */ }如果leak经常被调用,那么渐渐地,堆里就会充满了垃圾,最糟糕的情况下,会占有整个虚拟地址空间。对于像守护进程和服务器这样的程序来说,存储器泄漏是特别严重的,根据定义这些程序是不会终止的。