一種踩內存的定位方法(C++)

    在嵌入式應用開發過程中,踩內存的問題常常讓人束手無策。使用gdb調試工具,可以大幅加快問題的定位。不過,對於某些踩內存的問題,它的表現是間接的,應用崩潰的位置也是不固定的,這就給問題定位帶來了更大的困難。

    筆者見過帶有虛函數C++的類對象在試圖調用虛函數時,因指向虛函數的表指針被踩了,導致獲取虛函數的地址是錯識的,從而應用崩潰。此問題的表現就是間接的:在踩內存發生時,應用沒有崩潰;當應用崩潰時,執行的代碼是踩內存的“歷史遺蹟”。爲了讓應用在踩內存時就發生崩潰(這樣可以使用gdb調試,或分析其coredump),一種方法是將C++類對象配置成只讀屬性;可用的系統調用爲mprotect,它可以配置一段對頁對齊的內存區域內存的讀寫屬性。

下面筆者對此問題進行了抽象和簡化,完整的代碼如下(memory-test.cpp):

 

#include <new>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <malloc.h>

#define MEM_PAGESIZE   4096

class MemBase {
public:
        MemBase(int x_ = 1, int y_ = 9);
        virtual void testFunc0();
        virtual void testFunc1();
        virtual ~MemBase();

protected:
        void memProtect(bool readonly);

protected:
        unsigned char area[MEM_PAGESIZE * 3];
        int x, y;
};

class MemDeriv : public MemBase {
public:
        MemDeriv(int x_ = 2, int y_ = 8);
        virtual void testFunc0();
        virtual void testFunc1();
        virtual ~MemDeriv();

protected:
        int z;
};

int main(int argc, char *argv[])
{
        MemDeriv * obj0;

        obj0 = new MemDeriv(3, 6);
        obj0->testFunc0();
        obj0->testFunc1();

        delete obj0;
        obj0 = NULL;
        return 0;
}

void MemBase::memProtect(bool readonly)
{
        int ret;
        size_t msize;
        unsigned long pa;

        pa  = (unsigned long) area;
        pa -= sizeof(void *); /* sizeof the TAB, what ever it is */
        if (pa & (MEM_PAGESIZE - 1)) {
                pa &= ~(MEM_PAGESIZE - 1);
                pa += MEM_PAGESIZE;
                msize = MEM_PAGESIZE * 2;
        } else {
                msize = MEM_PAGESIZE * 3;
        }

        if (readonly) {
                /* initialize the memory */
                memset(area, 0xA5, sizeof(area));
        }

        ret = mprotect((void *) pa, msize,
                readonly ? PROT_READ /* or PROT_NONE */ : PROT_READ | PROT_WRITE);
        if (ret != 0) {
                fprintf(stderr, "Error, failed to mprotect(%p): %s\n",
                        (void *) pa, strerror(errno));
                fflush(stderr);
        }
}

MemBase::MemBase(int x_, int y_)
{
        x = x_;
        y = y_;
        fprintf(stdout, "Constructing MemBase, this: %p, area: %p, x: %d (%p), y: %d (%p)\n",
                (void *) this, (void *) area, x, &x, y, &y);
        fflush(stdout);
}

void MemBase::testFunc0()
{
        fprintf(stdout, "In function [%s], x: %d\n", __FUNCTION__, x);
        fflush(stdout);
}

void MemBase::testFunc1()
{
        fprintf(stdout, "In function [%s], y: %d\n", __FUNCTION__, y);
        fflush(stdout);
}

MemBase::~MemBase()
{
        fprintf(stdout, "Deconstructing MemBase, this: %p\n", (void *) this);
        fflush(stdout);
}

MemDeriv::MemDeriv(int x_, int y_) : MemBase(x_, y_)
{
        z = x + y;
        fprintf(stdout, "Construction MemDeriv, this: %p, z: %d (%p)\n",
                (void *) this, z, &z);
        fflush(stdout);
        memProtect(true);
}

void MemDeriv::testFunc0()
{
        fprintf(stdout, "In function [%s], z: %d\n", __FUNCTION__, z);
        fflush(stdout);
}

void MemDeriv::testFunc1()
{
        fprintf(stdout, "In function [%s], z: %d\n", __FUNCTION__, z);
        fflush(stdout);
}

MemDeriv::~MemDeriv()
{
        memProtect(false);
        fprintf(stdout, "Deconstructing MemDeriv, this: %p\n", (void *) this);
        fflush(stdout);
}

    由於mprotect系統調用的限制,我們只能對基類(MemBase)中增加的3個頁大小的內存區域area(中的兩個頁大小的內存區域)設置只讀屬性(假設設備的內存頁大小爲4096字節)。不過,在某些情況下,我們也希望對函數表指針進行保護,於是就有了第60行代碼:

pa -= sizeof(void *); /* sizeof the TAB, what ever it is */

    這樣做是有原因的。當創建C++類對象時,大多數情況下,area緩存的起始地址並不是頁對齊的;當類對象的起始地址(即this指針)是頁對齊的,那麼area就偏移了sizeof(void *)字節,這樣一來被配置爲只讀屬性的起始地址就是在this指針偏移了4096字節之後:有時候,被踩內存的大小不足一個頁的大小,就不會發生踩內存時的崩潰問題了。這是加入第60行代碼的原因。

試着運行一下,此方法確定可行的,測試應用可以正常運行:

    見上圖,這裏沒有測試踩內的異常情況,因此應用可以正常退出。下面讓我們來測試一下上面假設的情況,即創建的C++類對象恰好在頁對齊的地址上。我們將完整的代碼重命名爲memory-test1.cpp,將重寫main函數,確保創建的類對像this指針是頁對齊的,這樣就可以對虛函數表指針進行mprotect保護了:

int main(int argc, char *argv[])
{
        void * palign;
        MemDeriv * obj0;

        palign = memalign(MEM_PAGESIZE, sizeof(MemDeriv));
        if (palign == NULL) {
                fprintf(stderr, "malloc(%#x) has failed: %s\n",
                        (unsigned int) sizeof(MemDeriv), strerror(errno));
                fflush(stderr);
                exit(1);
        }

        obj0 = new(palign) MemDeriv(4, 5);
        obj0->testFunc0();
        obj0->testFunc1();
        obj0->~MemDeriv();

        free(obj0);
        obj0 = NULL;
        return 0;
}

    如果一切順利,memory-test1.cpp編譯得到的test1就能夠正常運行:

    結果卻是應用崩潰了!使用gdb試一下,發現應用崩潰發生在子類的析構函數中,在調用memProtect成員函數前,就會對虛函數表指針進行寫操作。一方面,我們得知mprotect確實能夠正常工作,將一段內存設置爲只讀;另一方面,我們知道,在子類析構函數中,會操作虛函數表,因此該定位踩內存的方法存在嚴重缺陷——所有的工作都浪費了:

    這樣的結果是不可接受的。我們不希望浪費這些工作,需要繼續改進此方法;而改進此方法的手段,就是讓C++子類的析構函數在調用memProtect成員函數之後再對虛函數表指針進行寫操作。這樣在memory-test1.cpp的基礎上,改成了memory-test2.cpp,對MemDeriv的析構函數增加了很多nop指令:

MemDeriv::~MemDeriv()
{
        asm volatile (
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n" : : : "memory");
        memProtect(false);
        asm volatile (
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n"
                "\tnop\n" : : : "memory");
        fprintf(stdout, "Deconstructing MemDeriv, this: %p\n", (void *) this);
        fflush(stdout);
}

相應的,析構函數的反彙編如下(反彙編test2):

    有了足夠的nop指令填充代碼段的空間,就可以對test2進行修改了,編寫簡單hed操作指令,對test2進行修改(hed的源碼在下載頁的壓縮包中)。修改完成後,對析構函數的反彙編就變成了:

可以看到,test2在修改前後,文件大小未改變,但MD5較驗值不同:

    接下來最後一搏,對test2進行測試,就能夠正常運行了:

至此,我們的調試C++類被踩內存的方法就成功了:它將我們從踩內存的第二現場帶到了案發第一現場。不過在應用崩潰時,還是需要gdb的協助,纔得到定位。需要注意的是,該方案有幾點要說明一下:

  1. 需要對基類增加頁大小整數倍的area緩存成員變量,且需要是第一個成員變量(這樣在area與this之間,不存在其他成員變量);
  2. 由子類的構造函數和析構函數調用memProtect,分別設置area(及其之前的虛函數表指針)的只讀、可讀可寫屬性;
  3. 若修改源碼,每次編譯生成的可執行文件或動態庫,都需重新構造hed指令,修改子類析構函數,在調用memProtect之後對虛函數表進行寫操作。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章