【读书笔记】C程序中常见的内存操作有关的典型编程错误

         对C/C++程序员来说,内存管理是个不小的挑战,绝对值得慎之又慎,否则让由上万行代码构成的模块跑起来后才出现内存崩溃,是很让人痛苦的。因为崩溃的位置在时间和空间上,通常是在距真正的错误源一段距离之后才表现出来。前几天线上模块因堆内存写越界1个字节引起各种诡异崩溃,定位问题过程中的折腾仍历历在目,今天读到《深入理解计算机系统》第9章-虚拟存储器,发现书中总结了C程序中常见的内存操作有关的10种典型编程错误,总结的比较全面。故作为笔记,记录于此。

1. 间接引用无效指针
        进程虚拟地址空间的某些地址范围可能没有映射到任何有意义的数据,如果我们试图间接引用一个指向这些地址的指针,则操作系统会以Segment Fault终止进程。而且,虚拟存储器的某些区域是只读的(如.text或.rodata),试图写这些区域会以保护异常中止当前进程。
        如从stdin读取一个int变量时,scanf("%d", &val)是正确用法,若误写为scanf("%d", val)时,val的值会被解释为一个地址,并试图向该地址写数据。在最好的情况下,进程立即异常中止。在最坏的情况下,val的值恰好对应于虚拟存储器的某个合法的具有读/写权限的内存区域,于是该内存单元会被改写,而这通常会在相当长的一段时间后造成灾难性的、令人困惑的后果。

2. 读未初始化的存储器
        C语言的malloc并不负责初始化申请到的内存区域,因此,常见的错误是假设堆存储器被初始化为0,例如:

    int * foo(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被初始化为0。正确的实现方式是显式将y[i]置为0或者使用calloc。

3. 栈缓冲区溢出
         例如:

    char buf[5];
    sprintf(buf, "%s", "hello world");
            上面的代码导致栈缓冲区溢出,安全的做法是:1)根据需求定义合适的buffer;2)采用snprintf(buf, sizeof(buf), "%s", "hello world")来及时截断。

4. 误认为指针与其指向的对象是相同大小的
        例如:

    int **makeArray(int n, int m)
    {
         int i;
         int **A = (int **)Malloc(n*sizeof(int));   // 这里错误地认为int *与int两种变量类型具有相同的size
         for(i = 0; i < n; i++) {
             A[i] = (int *)Malloc(m * sizeof(int));
         }
         return A;
    }

        上述代码目的是创建一个由n个指针构成的数组,每个指针均指向一个包含m个int的数组,但误将sizeof(int *)写成sizeof(int)。这段代码只有在int和int *的size相同的机器上运行良好。如果在像Core i7这样的机器上运行这段代码,由于指针变量的size大于sizeof(int),则会引发代码中的for循环写越界。因为这些字中的一个很可能是已分配块的边界标记脚部,所以我们可能不会立即发现这个错误,直到进程运行很久释放这个内存块时,此时,分配器中的合并代码会戏剧性地失败,而没有任何明显的原因。这是"在远处起作用"(action at distance)的一个隐秘示例,这类"在远处起作用"是与存储器有关的编程错误的典型情况。

5. 造成错位错误
         错位(Off-by-one)错误是另一种常见的覆盖错误来源:

    int ** makeArray(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;
    }
          很明显,for循环次数不合预期,导致写越界。幸运的话,进程会立即崩溃;不幸的话,运行很长时间才抛出各种诡异问题。

6. 引用指针,而不是它所指向的对象
        如果不注意C操作符的优先级和结合性,就会错误地操作指针,而不是指针所指向的对象。
        比如下面的函数,其目的是删除一个有*size项的二叉堆里的第一项,然后对剩下的*size-1项重建堆:

    int * binheapDelete(int **binheap, int *size)
    {
        int *packet = binheap[0];
        binheap[0] = binheap[*size - 1];
        *size--;  // 此处应该为(*size)--
        heapify(binheap, *size, 0);
        return (packet);
    }

       上述代码中,由于--和*优先级相同,从右向左结合,所以*size--其实减少的是指针自己的值,而非其指向的整数的值。因此,谨记:当你对优先级和结合性有疑问时,就应该使用括号。

7. 误解指针运算
        在C/C++中,指针的算术操作是以它们指向的对象的大小为单位来进行的。例如下面函数的功能是扫描一个int的数组,并返回一个指针,指向val的首次出现:

    int * search(int *p, int val)
    {
        while(*p && *p != val) {
            p += sizeof(int); // 此处应该为p++,否则p += 4会导致大部分元素被跳过
        }
    }

8. 引用不存在的变量
            C/C++新手不理解栈的规则时,可能会引用不再合法的本地变量,例如:
    int * stackref()
    {
        int val;
        return &val;
    }

        函数返回的指针(假设为p)指向栈中的局部变量,但该变量在函数返回后随着stackref栈帧的销毁已经不再有效。也即:尽管函数返回的指针p仍然指向一个合法的存储器地址,但它已经不再指向一个合法的变量了。当程序后续调用其它函数时,存储器将重用刚才销毁栈帧处的存储器区域。再后来,如果程序分配某个值给*p,那么它可能实际上正在修改另一个函数栈帧中的数据,从而潜在地带来灾难性的、令人困惑的后果。

9. 引用空闲堆块中的数据
        典型的错误为:引用已经被释放了的堆块中的数据,例如:

   int * heapref(int n, int m)
    {
         int i;
         int *x, *y;
         x = (int *)Malloc(n * sizeof(int));
         /*  各种操作 */
         free(x);
         
         y = (int *)Malloc(m * sizeof(int));
         for(i = 0; i < m; i++) {
             y[i] = x[i]++;  // 此处的x之前已经被释放了!
         }
    }

10. 内存泄露
       内存泄露是缓慢、隐性的杀手,当程序员忘记释放已分配块时会发生这种问题,例如:

    void leak(int n)
    {
         int *x = (int *)Malloc(n * sizeof(int));
         return;
    }

       如果leak在程序整个生命周期内只调用数次,则问题还不是很严重(但还是会浪费存储器空间),因为随着进程结束,操作系统会回收这些内存空间。但如果leak()被经常调用,那就会发生严重的内存泄露,最坏的情况下,会占用整个虚拟地址空间。对于像守护进程和服务器这样的程序来说,内存泄露是严重的bug,必须加以重视。

【参考资料】
《深入理解计算机系统》第9章 — 虚拟存储器

============== EOF ==================


你可能感兴趣的:(c)