DEV Community

Cover image for C++ 指向類別成員的指位器的實作細節
codemee
codemee

Posted on

C++ 指向類別成員的指位器的實作細節

C++ 可以定義指向成員函式的指位器, 不過因為成員函式可能是虛擬函式, 如何能夠透過指向成員函式的指位器達到呼叫正確的成員函式呢?本來就來簡單探究。(本文均以 g++ 為例, 並且只探討單純的單一繼承)。

指向非虛擬函式的指位器

首先來看個簡單的範例, 建立指向非虛擬函式的指位器:

#include <iostream>

using namespace std;

class A
{
public:
    virtual void f_v1() { cout << "A::f_v1()" << endl; }
    virtual void f_v2() { cout << "A::f_v2()" << endl; }

    void f_nv() { cout << "A::f_nv()" << endl;}
};

class B : public A
{
public:
    void f_v1() { cout << "B::f_v1()" << endl; }
};

int main(void)
{
    B b;
    A a;

    A *pa = &b;
    void (A::*pf)() = &A::f_nv;
    (pa->*pf)();

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

以下針對編譯出來的組合語言碼分開說明, 首先是個別類別的成員函式, 這裡不是我們探討的重點, 因此都略過程式碼內容:

.LC0:
        .string "A::f_v1()"
A::f_v1():
        ...()
.LC1:
        .string "A::f_v2()"
A::f_v2():
        ...()

.LC2:
        .string "A::f_nv()"
A::f_nv():
        ...()

.LC3:
        .string "B::f_v1()"
B::f_v1():
        ...()
Enter fullscreen mode Exit fullscreen mode

接著是配置區域變數, 就依照程式碼內的順序分別配置:

main:
        push    rbp 
        mov     rbp, rsp
        sub     rsp, 48                                    ; 配置區域變數空間
        mov     eax, OFFSET FLAT:vtable for B+16           ; 取得 B 的虛擬函式表位址
        mov     QWORD PTR [rbp-16], rax                    ; 放入 b 物件
        mov     eax, OFFSET FLAT:vtable for A+16           ; 取得 A 的虛擬函式表位址
        mov     QWORD PTR [rbp-24], rax                    ; 放入 a 物件
        lea     rax, [rbp-16]                              ; 取得 b 的位址
        mov     QWORD PTR [rbp-8], rax                     ; 放入 pa 指位器
Enter fullscreen mode Exit fullscreen mode

接著就是重點了, 指向成員函式的指位器佔 16 個位元組, 指向非虛擬函式時, 低的 8 位元組就是成員函式的位址, 高 8 位元組是物件的位移, 本文都不會使用到物件的位移:

        mov     QWORD PTR [rbp-48], OFFSET FLAT:A::f_nv()  ; 將 f_nv 的位址放入 pf 的低 8 位元組
        mov     QWORD PTR [rbp-40], 0                      ; 將物件位移 0 放入 pf 的高位元組
Enter fullscreen mode Exit fullscreen mode

由於指向成員函式的指位器和一般的指位器並不相同, 所以並不能隨意混用。當需要透過指向成員函式的指位器呼叫成員函式時, 第一步是判斷指向的成員函式是否為虛擬函式?這裡編譯器用了一個小技巧, 由於函式都會對齊 2 的次方的位址, 所以函式的位址最後一個位元一定會 0, 把函式的位址拿來和 1 做位元 and 運算, 就會把位址變成 0, 稍後指向虛擬函式的指位器就會依據這一點特別設計, 讓指位器的低 8 位元組與 1 進行 and 位元運算時不會得到 0, 藉此區分指位器指向的是否為虛擬函式:

        mov     rax, QWORD PTR [rbp-48]                    ; 取得虛擬函式位址
        and     eax, 1                     ; 由於函式會對齊 2 的次方位址, 所以這會 eax 變 0
        test    rax, rax                   ; 測試 rax & rax 是否為 0
        je      .L6                        ; 是的話 (非虛擬函式) 跳到 .L6 處
Enter fullscreen mode Exit fullscreen mode

以下這段是為虛擬函式設計的, 我們稍後再說明:

        mov     rax, QWORD PTR [rbp-40]
        mov     rdx, rax
        mov     rax, QWORD PTR [rbp-8]
        add     rax, rdx
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rbp-48]
        sub     rdx, 1
        add     rax, rdx
        mov     rax, QWORD PTR [rax]
        jmp     .L7
Enter fullscreen mode Exit fullscreen mode

確認指位器指向的是非虛擬函式後, 並不需要透過物件的虛擬函式表找出真正的函式位址, 就可以直接呼叫成員函式了:

.L6:
        mov     rax, QWORD PTR [rbp-48]    ; 取得非虛擬函式的位址
.L7:
        mov     rdx, QWORD PTR [rbp-40]    ; 取得物件位移 (0)
        mov     rcx, rdx                   ; 將位物件移放入 rcx
        mov     rdx, QWORD PTR [rbp-8]     ; 取得 pa 指向的位址
        add     rdx, rcx                   ; 加上位移 (0)
        mov     rdi, rdx                   ; 設定為第一個引數
        call    rax                        ; 呼叫非虛擬函式
        mov     eax, 0
        leave
        ret
vtable for B:
        .quad   0
        .quad   typeinfo for B
        .quad   B::f_v1()
        .quad   A::f_v2()
vtable for A:
        .quad   0
        .quad   typeinfo for A
        .quad   A::f_v1()
        .quad   A::f_v2()
        ...()
Enter fullscreen mode Exit fullscreen mode

指向虛擬函式的指位器

如果指位器指向的成員函式是虛擬函式, 就必須到物件的虛擬函式表中找出真正的函式位址, 請看以下範例, 它跟上一個程式幾乎一樣, 只是設定的是指向虛擬函式的指位器:

#include <iostream>

using namespace std;

class A
{
public:
    virtual void f_v1() { cout << "A::f_v1()" << endl; }
    virtual void f_v2() { cout << "A::f_v2()" << endl; }

    void f_nv() { cout << "A::f_nv()" << endl;}
};

class B : public A
{
public:
    void f_v1() { cout << "B::f_v1()" << endl; }
};

int main(void)
{
    B b;
    A a;

    A *pa = &b;
    void (A::*pf)() = &A::f_v1; // 改用虛擬函式
    (pa->*pf)();

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

編譯出來的組合語言程式碼跟剛剛幾乎一樣, 我們略過相同的部分:

main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48                            ; 配置區域變數空間
        mov     eax, OFFSET FLAT:vtable for B+16   ; B 的虛擬函式表位址
        mov     QWORD PTR [rbp-16], rax            ; 放入 b 物件
        mov     eax, OFFSET FLAT:vtable for A+16   ; A 的虛擬函式表
        mov     QWORD PTR [rbp-24], rax            ; 放入 a 物件
        lea     rax, [rbp-16]                      ; b 的位址
        mov     QWORD PTR [rbp-8], rax             ; 放入 pa 指位器
Enter fullscreen mode Exit fullscreen mode

建立指位器時你會看到現在低 8 位元組不是直接放置成員函式的位址, 而是放置 (0 + 1), 其中的 0 表示這個虛擬函式在虛擬函式表中的位移, 因為 f_v1 是第一個虛擬函式, 所以位移為 0;後面的 1 是為了讓最低位元不是 0, 以便能透過剛剛介紹的檢查機制分辨這是虛擬函式:

        mov     QWORD PTR [rbp-48], 1              ; 將虛擬函式位移 1 放入 pf 的低 8 位元組
        mov     QWORD PTR [rbp-40], 0              ; 將物件位移 0 放入 pf 的高 8 位元組
Enter fullscreen mode Exit fullscreen mode

當要透過這個指位器呼叫成員函式時, 會經過一模一樣的檢查步驟, 不過因為指向虛擬函式的指位器最低位元一定會是 1, 所以相同的位元 and 運算結果就會是 1, 而不是 0, 即可分辨這是指向虛擬函式的指位器:

        mov     rax, QWORD PTR [rbp-48]            ; 取得 pf 的低 8 位元組 (1)
        and     eax, 1                             ; 1 & 1 = 1
        test    rax, rax                           ; 1 & 1 = 1, 不會設定 zf 旗標
        je      .L5                                ; zf 旗標不是 1, 不會跳到 .L5
Enter fullscreen mode Exit fullscreen mode

下一個步驟就是到虛擬函式表中找出函式的位址, 它會以虛擬函式表的位址為準, 加上虛擬函式位移找到記錄函式位址的地方, 不過要注意虛擬函式位移有包含最低位元的 1, 所以要將它扣除:

        mov     rax, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rdx, rax                           ; 放入 rdx
        mov     rax, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址, 也就是物件的開頭位址
        add     rax, rdx                           ; 加上虛擬函式表的位移
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式表的位址
        mov     rdx, QWORD PTR [rbp-48]            ; 取得虛擬函式位移
        sub     rdx, 1                             ; 扣除用來識別是否為虛擬函式的 1
        add     rax, rdx                           ; 找到儲存虛擬函式位址的欄位位址
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式的位址
        jmp     .L6                                ; 移到 .L6
.L5:
        mov     rax, QWORD PTR [rbp-48]
Enter fullscreen mode Exit fullscreen mode

最後, 就可以依據找到的虛擬函式為只傳入物件的位址呼叫它了:

.L6:
        mov     rdx, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rcx, rdx                           ; 放次 rcx
        mov     rdx, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址
        add     rdx, rcx                           ; 加上位移
        mov     rdi, rdx                           ; 設為第一個引數
        call    rax                                ; 呼叫虛擬函式
        mov     eax, 0
        leave
        ret
Enter fullscreen mode Exit fullscreen mode

指向第二個虛擬函式的指位器

為了進一步確認指向虛擬函式的指位器運作方式, 這裡再看一個呼叫第二個虛擬函式的範例

#include <iostream>

using namespace std;

class A
{
public:
    virtual void f_v1() { cout << "A::f_v1()" << endl; }
    virtual void f_v2() { cout << "A::f_v2()" << endl; }

    void f_nv() { cout << "A::f_nv()" << endl;}
};

class B : public A
{
public:
    void f_v1() { cout << "B::f_v1()" << endl; }
};

int main(void)
{
    B b;
    A a;

    A *pa = &b;
    void (A::*pf)() = &A::f_v2; // 改成第二個虛擬函式
    (pa->*pf)();

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

編譯後的組合語言都跟剛剛幾乎一樣, 我們略過不看:

main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48                            ; 配置區域變數空間
        mov     eax, OFFSET FLAT:vtable for B+16   ; B 的虛擬函式表位址
        mov     QWORD PTR [rbp-16], rax            ; 放入 b 物件
        mov     eax, OFFSET FLAT:vtable for A+16   ; A 的虛擬函式表
        mov     QWORD PTR [rbp-24], rax            ; 放入 a 物件
        lea     rax, [rbp-16]                      ; b 的位址
        mov     QWORD PTR [rbp-8], rax             ; 放入 pa 指位器
Enter fullscreen mode Exit fullscreen mode

只有虛擬函式位移不同, 這裡因為是第二個虛擬函式, 所以從虛擬函式表開頭算起位移 8 個位元組, 加上區別虛擬函式用的 1, 所以是 9:

        mov     QWORD PTR [rbp-48], 9              ; 將虛擬函式位移 9 放入 pf 的低 8 位元組
        mov     QWORD PTR [rbp-40], 0              ; 將物件位移 0 放入 pf 的高 8 位元組
Enter fullscreen mode Exit fullscreen mode

之後的內容就跟前一個範例一樣, 可自行參考:

        mov     rax, QWORD PTR [rbp-48]            ; 取得 pf 的低 8 位元組 (1)
        and     eax, 1                             ; 9 & 1 = 1
        test    rax, rax                           ; 1 & 1 = 1, 不會設定 zf 旗標
        je      .L5                                ; zf 旗標不是 1, 不會跳到 .L5
        mov     rax, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rdx, rax                           ; 放入 rdx
        mov     rax, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址, 也就是物件的開頭位址
        add     rax, rdx                           ; 加上虛擬函式表的位移
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式表的位址
        mov     rdx, QWORD PTR [rbp-48]            ; 取得虛擬函式位移
        sub     rdx, 1                             ; 扣除用來識別是否為虛擬函式的 1
        add     rax, rdx                           ; 找到儲存虛擬函式位址的欄位位址
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式的位址
        jmp     .L6                                ; 移到 .L6
.L5:
        mov     rax, QWORD PTR [rbp-48]
.L6:
        mov     rdx, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rcx, rdx                           ; 放次 rcx
        mov     rdx, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址
        add     rdx, rcx                           ; 加上位移
        mov     rdi, rdx                           ; 設為第一個引數
        call    rax                                ; 呼叫虛擬函式
        mov     eax, 0
        leave
        ret
Enter fullscreen mode Exit fullscreen mode

依據本文的說明, 你也可以自行觀察多重繼承時的處理方式, 雖然比較複雜, 不過基本的運作原理都一樣。

Do your career a big favor. Join DEV. (The website you're on right now)

It takes one minute, it's free, and is worth it for your career.

Get started

Community matters

Top comments (2)

Collapse
 
vblover_programmer profile image
vblover programmer

hi....
I wanna convert int Value to LPCTSTR. How Can I do this at C++

LPCTSTR  TITLE = LPCTSTR(12 * 34);
HWND hWnd = CreateWindowW(szWindowClass, TITLE, WS_OVERLAPPEDWINDOW,
 CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, nullptr, nullptr, hInstance, nullptr);

Enter fullscreen mode Exit fullscreen mode

....

Collapse
 
codemee profile image
codemee

try this from ChartGTP

#include <windows.h>
#include <cstdio>

int main() {
    int value = 1234;
    static char buffer[20]; // Ensure the buffer is large enough
    sprintf_s(buffer, "%d", value); // Secure version of sprintf
    LPCTSTR lpctstr = buffer;

    // Use lpctstr as needed
    MessageBoxA(NULL, lpctstr, "Integer as LPCTSTR", MB_OK);

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More