1. 程式人生 > >ATL之thunk技術

ATL之thunk技術

由於 C++ 成員函式的呼叫機制問題,對C語言回撥函式的 C++ 封裝是件比較棘手的事。為了保持C++物件的獨立性,理想情況是將回調函式設定到成員函式,而一般的回撥函式格式通常是普通的C函式,尤其是 Windows API 中的。好在有些回撥函式中留出了一個額外引數,這樣便可以由這個通道將 this 指標傳入。比如執行緒函式的定義為:

typedef DWORD (WINAPI *PTHREAD_START_ROUTINE)(
    LPVOID lpThreadParameter
    );
typedef PTHREAD_START_ROUTINE LPTHREAD_START_ROUTINE;

這樣,當我們實現執行緒類的時候,就可以:

class Thread
{
private:
    HANDLE m_hThread;

public:
    BOOL Create()
    {
        m_hThread = CreateThread(NULL, 0, StaticThreadProc, (LPVOID)this, 0, NULL);
        return m_hThread != NULL;
    }

private:
    DWORD WINAPI ThreadProc()
    {
        // TODO
        return 0;
    }

private:
    static DWORD WINAPI StaticThreadProc(LPVOID lpThreadParameter)
    {
        ((Thread *)lpThreadParameter)->ThreadProc();
    }
};

不過,這樣,成員函式 ThreadProc() 便喪失了一個引數,這通常無傷大雅,任何原本需要從引數傳入的資訊都可以作為成員變數讓 ThreadProc 來讀寫。如果一定有些什麼是非從引數傳入不可的,那也可以,一種做法,建立執行緒的時候傳入一個包含 this 指標資訊的結構。第二種做法,對該 class 作單例限制——如果現實情況允許的話。

所以,有額外引數的回撥函式都好處理。不幸的是,Windows 的視窗回撥函式沒有這樣一個額外引數:

typedef LRESULT (CALLBACK* WNDPROC)(HWND, UINT, WPARAM, LPARAM);

這使得對視窗的 C++ 封裝變得困難。為了解決這個問題,一個很自然的想法是,維護一份全域性的視窗控制代碼到視窗類的對應關係,如:

#include <map>

class Window
{
public:
    Window();
    ~Window();
    
public:
    BOOL Create();

protected:
    LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam);

protected:
    HWND m_hWnd;

protected:
    static LRESULT CALLBACK StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
    static std::map<HWND, Window *> m_sWindows;
};

在 Create 的時候,指定 StaticWndProc 為視窗回撥函式,並將 hWnd 與 this 存入 m_sWindows:

BOOL Window::Create()
{
    LPCTSTR lpszClassName = _T("ClassName");
    HINSTANCE hInstance = GetModuleHandle(NULL);

    WNDCLASSEX wcex    = { sizeof(WNDCLASSEX) };
    wcex.lpfnWndProc   = StaticWndProc;
    wcex.hInstance     = hInstance;
    wcex.lpszClassName = lpszClassName;

    RegisterClassEx(&wcex);

    m_hWnd = CreateWindow(lpszClassName, NULL, WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, NULL, NULL, hInstance, NULL);

    if (m_hWnd == NULL)
    {
        return FALSE;
    }

    m_sWindows.insert(std::make_pair(m_hWnd, this));

    ShowWindow(m_hWnd, SW_SHOW);
    UpdateWindow(m_hWnd);

    return TRUE;
}

在 StaticWindowProc 中,由 hWnd 找到 this,然後轉發給成員函式:

LRESULT CALLBACK Window::StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    std::map<HWND, Window *>::iterator it = m_sWindows.find(hWnd);
    assert(it != m_sWindows.end() && it->second != NULL);

    return it->second->WndProc(message, wParam, lParam);
}

(m_sWindows 的多執行緒保護略過,下同)

據說 MFC 採用的就是類似的做法。缺點是,每次 StaticWndProc 都要從 m_sWindows 中去找 this。由於視窗類一般會儲存視窗控制代碼,回撥函式裡的 hWnd 就沒多大作用了,如果這個 hWnd 能夠被用來存 this 指標就好了,那麼就能寫成這樣:

LRESULT CALLBACK Window::StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    return ((Window *)hWnd)->WndProc(message, wParam, lParam);
}

這樣看上去就爽多了。傳說中 WTL 所採取的 thunk 技術就是這麼幹的。之前,只是聽過這遙遠的傳說,今天,終於有機會走進這個傳說去看一看。參考資料是一篇不知原始出處的文章《深入剖析WTL—WTL框架視窗分析》,以及部分 WTL 8.0 程式碼,還有其他亂七八糟的文章。

WTL 的思路是,每次在系統呼叫 WndProc 的時候,讓它鬼使神差地先走到我們的另一處程式碼,讓我們有機會修改堆疊中的 hWnd。這處程式碼可能是類似這樣的:

__asm
{
    mov dword ptr [esp+4], pThis  ;呼叫 WndProc 時,堆疊結構為:RetAddr, hWnd, message, wParam, lParam, ... 故 [esp+4]
    jmp WndProc
}

由於 pThis 和 WndProc 需要被事先修改(但又無法在編譯前定好),所以我們需要執行的時候去修改這部分程式碼。先弄一個小程式探測下這兩行語句的機器碼:

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    return 0;
}

int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
    MessageBox(NULL, NULL, NULL, MB_OK);

    __asm
    {
        mov dword ptr [esp+4], 1
        jmp WndProc
    }

    return 0;
}

最前面的 MessageBox 是為了等下除錯的時候容易找到進入點。

然後使用 OllyDbg,在 MessageBoxW 上設定斷點,執行到該函式返回:

image

這裡我們看到,mov dword ptr [esp+4] 的機器碼為 C7 44 24 04,後面緊接著的一個 DWORD 是 mov 的第二個運算元。jmp 的機器碼是 e9,後面緊接著的一個 DWORD 是跳轉的相對地址。其中 00061000h - 0006102Bh = FFFFFFD5h。

於是定義這樣一個結構:

#pragma pack(push,1)
typedef struct _StdCallThunk
{
    DWORD   m_mov;          // = 0x042444C7
    DWORD   m_this;         // = this
    BYTE    m_jmp;          // = 0xe9
    DWORD   m_relproc;      // = relative distance
} StdCallThunk;
#pragma pack(pop)

這個結構可以作為視窗類的成員變數存在。我們的視窗類現在變成了這樣子:

class Window
{
public:
    Window();
    ~Window();

public:
    BOOL Create();

protected:
    LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam);

protected:
    HWND         m_hWnd;
    StdCallThunk m_thunk;

protected:
    static LRESULT CALLBACK StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
};

似乎少了點什麼……建立視窗的時候,我們是不能直接把回撥函式設到 StaticWndPorc 中去的,因為這個函式是希望被寫成這樣子的:

LRESULT CALLBACK Window::StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    return ((Window *)hWnd)->WndProc(message, wParam, lParam);
}

那麼至少需要一個臨時的回撥函式,在這個函式裡去設定新的回撥函式(設到 m_thunk 上),再由 m_thunk 來呼叫 StaticWndProc,StaticWndProc 再去呼叫 WndProc,這樣整個過程就通了。

但是,臨時回撥函式還是需要知道從 hWnd 到 this 的對應關係。可是現在我們不能照搬用剛才的 m_sWindows 了。因為視窗在建立過程中就會呼叫到回撥函式,需要使用到 m_sWindows 裡的 this,而視窗被成功建立之前,我們沒法提前拿到 HWND 存入 m_sWindows。現在,換個方法,存當前執行緒 ID 與 this 的對應關係。這樣,這個類變成了:

#include <map>

class Window
{
public:
    Window();
    ~Window();

public:
    BOOL Create();

protected:
    LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam);

protected:
    HWND         m_hWnd;
    StdCallThunk m_thunk;

protected:
    static LRESULT CALLBACK TempWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);

    static LRESULT CALLBACK StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
    static std::map<DWORD, Window *> m_sWindows;
};

然後實現 Create 和 TempWndProc:

BOOL Window::Create()
{
    LPCTSTR lpszClassName = _T("ClassName");
    HINSTANCE hInstance = GetModuleHandle(NULL);

    WNDCLASSEX wcex    = { sizeof(WNDCLASSEX) };
    wcex.lpfnWndProc   = TempWndProc;
    wcex.hInstance     = hInstance;
    wcex.lpszClassName = lpszClassName;

    RegisterClassEx(&wcex);

    DWORD dwThreadId = GetCurrentThreadId();
    m_sWindows.insert(std::make_pair(dwThreadId, this));

    m_thunk.m_mov = 0x042444c7;
    m_thunk.m_jmp = 0xe9;

    m_hWnd = CreateWindow(lpszClassName, NULL, WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, NULL, NULL, hInstance, NULL);

    if (m_hWnd == NULL)
    {
        return FALSE;
    }
    
    ShowWindow(m_hWnd, SW_SHOW);
    UpdateWindow(m_hWnd);

    return TRUE;
}

LRESULT CALLBACK Window::TempWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    std::map<DWORD, Window *>::iterator it = m_sWindows.find(GetCurrentThreadId());
    assert(it != m_sWindows.end() && it->second != NULL);

    Window *pThis = it->second;
    m_sWindows.erase(it);

    WNDPROC pWndProc = (WNDPROC)&pThis->m_thunk;

    pThis->m_thunk.m_this = (DWORD)pThis;
    pThis->m_thunk.m_relproc = (DWORD)&Window::StaticWndProc - ((DWORD)&pThis->m_thunk + sizeof(StdCallThunk));

    m_hWnd = hWnd;
    SetWindowLong(hWnd, GWL_WNDPROC, (LONG)pWndProc);

    return pWndProc(hWnd, message, wParam, lParam);
}

差不多可以了,除錯一下。結果,在 thunk 的第一行出錯了。我原以為地址算錯了神馬的,嘗試把 thunk.m_mov 改為 0x90909090,再執行,還是出錯。於是傻掉了……過了好一會兒才意識到,可能是因為 thunk 在資料段,無法被執行。可是,很久很久以前偶滴一個敬愛的老師在 TC 中鼓搗程式執行時改變自身程式碼時,貌似無此問題啊。。。然後查呀查,原來是 Windows 在的資料執行保護搞的鬼。於是,需要用 VirtualAlloc 來申請一段有執行許可權的記憶體。WTL 裡面也是這麼做的,不過它似乎維護了一塊較大的可執行記憶體區作為 thunk 記憶體池,我們這裡從簡。最後,整個流程終於跑通了。最終程式碼清單如下:

#include <Windows.h>
#include <assert.h>
#include <map> 
#include <tchar.h>

#pragma pack(push,1)
typedef struct _StdCallThunk
{
    DWORD   m_mov;
    DWORD   m_this;
    BYTE    m_jmp;
    DWORD   m_relproc;

} StdCallThunk;
#pragma pack(pop)

class Window
{
public:
    Window();
    ~Window();

public:
    BOOL Create();

protected:
    LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam);

protected:
    HWND          m_hWnd;
    StdCallThunk *m_pThunk;

protected:
    static LRESULT CALLBACK TempWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
    static LRESULT CALLBACK StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
    static std::map<DWORD, Window *> m_sWindows;
};

std::map<DWORD, Window *> Window::m_sWindows;

Window::Window()
{

}

Window::~Window()
{
    VirtualFree(m_pThunk, sizeof(StdCallThunk), MEM_RELEASE);
}

BOOL Window::Create()
{
    LPCTSTR lpszClassName = _T("ClassName");
    HINSTANCE hInstance = GetModuleHandle(NULL);

    WNDCLASSEX wcex    = { sizeof(WNDCLASSEX) };
    wcex.lpfnWndProc   = TempWndProc;
    wcex.hInstance     = hInstance;
    wcex.lpszClassName = lpszClassName;
    wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);

    RegisterClassEx(&wcex);

    DWORD dwThreadId = GetCurrentThreadId();
    m_sWindows.insert(std::make_pair(dwThreadId, this));

    m_pThunk = (StdCallThunk *)VirtualAlloc(NULL, sizeof(StdCallThunk), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    m_pThunk->m_mov = 0x042444c7;
    m_pThunk->m_jmp = 0xe9;

    m_hWnd = CreateWindow(lpszClassName, NULL, WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, NULL, NULL, hInstance, NULL);

    if (m_hWnd == NULL)
    {
        return FALSE;
    }
    
    ShowWindow(m_hWnd, SW_SHOW);
    UpdateWindow(m_hWnd);

    return TRUE;
}

LRESULT Window::WndProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_LBUTTONUP:
        MessageBox(m_hWnd, _T("LButtonUp"), _T("Message"), MB_OK | MB_ICONINFORMATION);
        break;
    case WM_RBUTTONUP:
        MessageBox(m_hWnd, _T("RButtonUp"), _T("Message"), MB_OK | MB_ICONINFORMATION);
        break;
    case WM_DESTROY:
        PostQuitMessage(0);
        break;
    default:
        break;
    }

    return DefWindowProc(m_hWnd, message, wParam, lParam);
}

LRESULT CALLBACK Window::TempWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    std::map<DWORD, Window *>::iterator it = m_sWindows.find(GetCurrentThreadId());
    assert(it != m_sWindows.end() && it->second != NULL);

    Window *pThis = it->second;
    m_sWindows.erase(it);

    WNDPROC pWndProc = (WNDPROC)pThis->m_pThunk;

    pThis->m_pThunk->m_this = (DWORD)pThis;
    pThis->m_pThunk->m_relproc = (DWORD)&Window::StaticWndProc - ((DWORD)pThis->m_pThunk + sizeof(StdCallThunk));

    pThis->m_hWnd = hWnd;
    SetWindowLong(hWnd, GWL_WNDPROC, (LONG)pWndProc);

    return pWndProc(hWnd, message, wParam, lParam);
}

LRESULT CALLBACK Window::StaticWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    return ((Window *)hWnd)->WndProc(message, wParam, lParam);
}

int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
    Window wnd;
    wnd.Create();

    MSG msg;

    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    return (int)msg.wParam;
}

剛才有一處,存 this 指標的時候,我很武斷地把它與當前執行緒 ID 關聯起來了,其實這正是 WTL 本身的做法。它用 CAtlWinModule::AddCreateWndData 存的 this,最終會把當前執行緒 ID 和 this 作關聯。我是這麼理解的吧,同一執行緒不可能同時有兩處在呼叫 CreateWindow,所以這樣取回來的 this 是可靠的。

好了,到此為止,邊試驗邊記錄的,不知道理解是否正確。歡迎指出不當之處,也歡迎提出相關的問題來考我,歡迎介紹有關此問題的新方法、新思路,等等,總之,請各位看官多指教哈