在嵌入式應用開發過程中,踩內存的問題常常讓人束手無策。使用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的協助,纔得到定位。需要注意的是,該方案有幾點要說明一下:
- 需要對基類增加頁大小整數倍的area緩存成員變量,且需要是第一個成員變量(這樣在area與this之間,不存在其他成員變量);
- 由子類的構造函數和析構函數調用memProtect,分別設置area(及其之前的虛函數表指針)的只讀、可讀可寫屬性;
- 若修改源碼,每次編譯生成的可執行文件或動態庫,都需重新構造hed指令,修改子類析構函數,在調用memProtect之後對虛函數表進行寫操作。