不使用全局函数如何编写线程类

Posted 计算机科学家的世界

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了不使用全局函数如何编写线程类相关的知识,希望对你有一定的参考价值。

以下讨论内容仅限于Window系统和X86硬件架构。
         从汇编角度看程序,只能看到mov,sub,div,xcmpchg等指令,函数,结构体,指针,数组等中级编程语言中的语法糖均不存在,至于C++,Java等高级语言中的对象,类,虚函数等更是不复存在,那么我们在高级语言中建议的语法糖在哪呢,它们是怎么被转化到汇编的呢?......
1,汇编之于函数调用         先看以下函数调用代码
int func(int x, int y) return x + y;
int main() int a = 10, b = 100; int c = func(a, b);
        在这上代码中,我们调用了func,参数是a(10),b(100),返回值存放在c中,现在问题来了,参数a,b是怎么传进来的,函数返回值是怎么返回的呢?在Visual Studio中我们使用cl -c -FA x.c(假设代码文件为x.c)命令来汇编以上代码,输出的即是以上代码对应的汇编信息:
; Listing generated by Microsoft (R) Optimizing Compiler Version 18.00.40629.0 

TITLE E:\\tmp\\x.c
.686P
.XMM
include listing.inc
.model flat

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC _func
PUBLIC _main
; Function compile flags: /Odtp
_TEXT SEGMENT
_c$ = -12 ; size = 4
_a$ = -8 ; size = 4
_b$ = -4 ; size = 4
_main PROC
; File e:\\tmp\\x.c
; Line 6
push ebp
mov ebp, esp
sub esp, 12 ; 0000000cH
; Line 7
mov DWORD PTR _a$[ebp], 10 ; 0000000aH
mov DWORD PTR _b$[ebp], 100 ; 00000064H
; Line 8
mov eax, DWORD PTR _b$[ebp]
push eax
mov ecx, DWORD PTR _a$[ebp]
push ecx
call _func
add esp, 8
mov DWORD PTR _c$[ebp], eax
; Line 9
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
; Function compile flags: /Odtp
_TEXT SEGMENT
_x$ = 8 ; size = 4
_y$ = 12 ; size = 4
_func PROC
; File e:\\tmp\\x.c
; Line 2
push ebp
mov ebp, esp
; Line 3
mov eax, DWORD PTR _x$[ebp]
add eax, DWORD PTR _y$[ebp]
; Line 4
pop ebp
ret 0
_func ENDP
_TEXT ENDS
END
        从以上汇编代码中可以看出,调用函数时,默认情况下参数从右到左入栈,同时,函数返回值存放在eax中,我们可以使用以下代码测试:(在VS上输出110)
int func(int x, int y)
        return x + y;


int main()
        int a = 10, b = 100, c;
        int (*fptr)(int,int) =func;
        __asm
                push b       //b入stack
                push a       //a入stack
                call fptr      //jmp到func
                mov c, eax //eax是返回值,用c存储
                add esp, 8 //平衡stack
         
        printf("%d\\n",c);
        以上汇编代码和实际测试代码表明在Win32上,函数调用是使用eax作返回值的,现在问题来了,如果需要返回的对象特别大,超过4个字节(eax大小为4字节,rax为8),那怎么处理呢?在这种情况下,主调函数会在堆栈上多分配一块内存,然后被调函数将返回值存放在这块内存中,最后返回这块内存的首地址---放在eax中。我们使用以下代码作测试:


typedef struct Record
        int ary[100];
        int x;
Record;

Record func()
        Record rc;
        rc.x = 100;
        return rc;


int main()
        Record r = func();
        printf("%d", r.x);
将文件存为x.c,然后使用cl -c -FA x.c汇编代码,将会生成x.asm,打开之后内容如下:
; Listing generated by Microsoft (R) Optimizing Compiler Version 18.00.40629.0 

TITLE E:\\tmp\\x.c
.686P
.XMM
include listing.inc
.model flat

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

_DATA SEGMENT
$SG1335 DB '%d', 00H
_DATA ENDS
PUBLIC _func
PUBLIC _main
EXTRN _printf:PROC
EXTRN @__security_check_cookie@4:PROC
EXTRN ___security_cookie:DWORD
; Function compile flags: /Odtp
_TEXT SEGMENT
$T1 = -812 ; size = 404
_r$ = -408 ; size = 404
__$ArrayPad$ = -4 ; size = 4
_main PROC
; File e:\\tmp\\x.c
; Line 14
push ebp
mov ebp, esp
sub esp, 812 ; 0000032cH
mov eax, DWORD PTR ___security_cookie
xor eax, ebp
mov DWORD PTR __$ArrayPad$[ebp], eax
push esi
push edi
; Line 15
lea eax, DWORD PTR $T1[ebp]
push eax
call _func
add esp, 4
mov ecx, 101 ; 00000065H
mov esi, eax
lea edi, DWORD PTR _r$[ebp]
rep movsd

; Line 16
mov ecx, DWORD PTR _r$[ebp+400]
push ecx
push OFFSET $SG1335
call _printf
add esp, 8
; Line 17
xor eax, eax
pop edi
pop esi
mov ecx, DWORD PTR __$ArrayPad$[ebp]
xor ecx, ebp
call @__security_check_cookie@4
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
; Function compile flags: /Odtp
_TEXT SEGMENT
_rc$ = -408 ; size = 404
__$ArrayPad$ = -4 ; size = 4
$T1 = 8 ; size = 4
_func PROC
; File e:\\tmp\\x.c
; Line 8
push ebp
mov ebp, esp
sub esp, 408 ; 00000198H
mov eax, DWORD PTR ___security_cookie
xor eax, ebp
mov DWORD PTR __$ArrayPad$[ebp], eax
push esi
push edi
; Line 10
mov DWORD PTR _rc$[ebp+400], 100 ; 00000064H
; Line 11
mov ecx, 101 ; 00000065H
lea esi, DWORD PTR _rc$[ebp]
mov edi, DWORD PTR $T1[ebp]
rep movsd
mov eax, DWORD PTR $T1[ebp]

; Line 12
pop edi
pop esi
mov ecx, DWORD PTR __$ArrayPad$[ebp]
xor ecx, ebp
call @__security_check_cookie@4
mov esp, ebp
pop ebp
ret 0
_func ENDP
_TEXT ENDS
END
        在x.asm中,有三块被标红,第一块是一条栈分配指令sub esp,这条指令用于存放两个record结构体,可能有人要问,我明明在main里面只定义了一个Record对象,为什么main函数要在栈上分配这么大一块内存,其实之前也已经说明了,在函数调用过程中,eax往往用来保存函数返回值,但是,如果返回值过大,主调函数就会在进入被调函数之前,在自己的栈上挖出一块内存,被调函数将待返回的对象写入这块地址,然后使用eax返回这个块内存的地址。在x.asm中,最后一块标红的汇编代码就是被调函数(func)将待返回的对象写入main所在的栈上预分配好的一块内存(ecx是循环计数器,101就是表明要复制101个int,而Record刚好就是这么大)。第二块标红的汇编代码就是将func返回的对象首地址的内容复制到r。为了验证以上猜想,我们可以使用以下代码验证:(代码毫无疑问肯定输出100)


typedef struct Record
        int ary[100];
        int x;
Record;

Record func()
        Record rc;
        rc.x = 100;
        return rc;


int main()
        Record* r;   
        func();                 //调用函数
        __asm        
                mov r, eax  //返回值就是func中返回的对象的首地址,在此取出
       
        printf("%d", r->x);
        以上讨论我们明白了函数调用过程中,返回值的传递与保存过程,以下再看面向对象中类的成员函数调用过程。
2,面向对象程序设计中成员函数调用过程与原理         面向对象程序设计中,我们经常使用类,而事实上,面向对象的三大特性就是封装、继承、多态,虚函数的使用极大方便程序的编写,麻烦了程序的调试(面向对象程序比C语言程序难调)。在这一节,将主要讨论类的成员函数调用过程,上一节中主要讨论了普通函数的调用(从语义上讲,类的静态函数就是外部普通函数),与普通函数比,类的成员函数多了一个this指针(有的语言称为self),先以以下代码为例子:

class CK
public:
        CK()
                m_iVal = 10;
       
public:
        void Show()
                m_iVal += 10;
       
private:
        int m_iVal;
;


int main()
        CK obj;
        obj.Show();
使用cl -FA -c x.cpp汇编以上代码,得到x.asm,内容如下:
; Listing generated by Microsoft (R) Optimizing Compiler Version 18.00.40629.0 

TITLE E:\\tmp\\x.cpp
.686P
.XMM
include listing.inc
.model flat

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC ??0CK@@QAE@XZ ; CK::CK
PUBLIC ?Show@CK@@QAEXXZ ; CK::Show
PUBLIC _main
; Function compile flags: /Odtp
_TEXT SEGMENT
_obj$ = -4 ; size = 4
_main PROC
; File e:\\tmp\\x.cpp
; Line 16
push ebp
mov ebp, esp
push ecx
; Line 17
lea ecx, DWORD PTR _obj$[ebp]
call ??0CK@@QAE@XZ ; CK::CK

; Line 18
lea ecx, DWORD PTR _obj$[ebp]
call ?Show@CK@@QAEXXZ ; CK::Show

; Line 19
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
; Function compile flags: /Odtp
; COMDAT ?Show@CK@@QAEXXZ
_TEXT SEGMENT
_this$ = -4 ; size = 4
?Show@CK@@QAEXXZ PROC ; CK::Show, COMDAT
; _this$ = ecx
; File e:\\tmp\\x.cpp
; Line 8
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _this$[ebp], ecx
; Line 9
mov eax, DWORD PTR _this$[ebp]
mov ecx, DWORD PTR [eax]
add ecx, 10 ; 0000000aH
mov edx, DWORD PTR _this$[ebp]
mov DWORD PTR [edx], ecx
; Line 10
mov esp, ebp
pop ebp
ret 0
?Show@CK@@QAEXXZ ENDP ; CK::Show
_TEXT ENDS
; Function compile flags: /Odtp
; COMDAT ??0CK@@QAE@XZ
_TEXT SEGMENT
_this$ = -4 ; size = 4
??0CK@@QAE@XZ PROC ; CK::CK, COMDAT
; _this$ = ecx
; File e:\\tmp\\x.cpp
; Line 4
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _this$[ebp], ecx
; Line 5
mov eax, DWORD PTR _this$[ebp]
mov DWORD PTR [eax], 10 ; 0000000aH
; Line 6
mov eax, DWORD PTR _this$[ebp]
mov esp, ebp
pop ebp
ret 0
??0CK@@QAE@XZ ENDP ; CK::CK
_TEXT ENDS
END
        以上代码中有三块标红,第一块是调用CK的构造,第二块是调用Show,第三块是Show的部分汇编代码,在调用Show之前,可以看到一个lea  ecx, DWORD PTR _obj$[ebp],lea是X86中的取址指令,这条指令的意思是取对象的地址(this指针),在Show的汇编代码中,把ecx取出来,再基于ecx取对象的m_iVal值,以上一些汇编代码给人一种 类的成员函数调用与普通成员函数调用完全一样,只是使用ecx传递this指针的感觉,事实上这种感觉是对的。 我们使用以下代码去验证:
#include <stdio.h>
class Base
public:
        virtual void Show(int x, int y) = 0;
;

class Derived: public Base
public:
        Derived()
                m_iValue = 10;
       
public:
        virtual void Show(int x, int y)
                printf("%d", x + y + m_iValue);
       
private:
        int m_iValue;
;

int main()
        Derived * obj = new Derived;
        auto pMemFunc =&Derived::Show;
        __asm
                mov ecx, obj       //传递this
                push 10              //y
                push 100           //x
                call pMemFunc;  //调用成员函数
                //  add esp, 8         //平衡stack,并不需要。因为类的成员函数是__thiscall方式,会自己解决参数造成的堆栈不平衡
       
3,构造自己的线程类         线程是操作系统中重要的概念,是操作系统中可以异步执行的执行体,在Win32中,如果要创建一个线程,我们需要使用CreateThread或者__beginthreadex等API,这些API有个特点,即需要传递一个非类成员函数(静态函数或普通C函数),在公司的AngelicaES引擎中,线程的创建使用的就是全局静态函数,然而,在Java中,创建线程只需要一个Thread对象或一个Thread对象加一个实现了Runable的接口,在Java中,使用线程,往往构造一个Thread对象,然后调用Start函数,线程就起来了,并没有我们看到的全局函数或者静态成员函数,那么Java等高级语言是怎么实现的呢?在前两节中,已经讲清楚了普通函数调用和类的成员函数调用的过程与原理,事实上,只需要使用这个原理就可以实现一个类似Java的线程函数(不使用任何全局函数或static函数),如果想将一个类的成员函数作为线程函数来执行,在语法层次我们无法逃脱this指针的束缚。但是,事实上,我们可以编写一段奇怪的二进制指令(可直接执行的机器码),在字节码里面设置好this指针等信息并跳转到类的成员函数中去,最后将这段字节码作为线程函数去执行(强制转换成CreateThread需要的线程函数类型),但是,我们并没有使用使用全局函数或static函数,先看以下一段字节码:
const static unsigned char g_thread_proc[]=  
       //------------parameter----------------- 
       0x8B,0x44,0x24,0x04, // mov eax,dword ptr [esp+10h] 
        0x50, // push eax 

        //-----------this pointer------------- 
        0xB9,0x00,0x00,0x00,0x00, // mov ecx,0x12FF5C 

         //-----------call back function------------- 
        0xB8,0x00,0x00,0x00,0x00, // mov eax,0 
        0xFF,0xD0, // call eax 

       //return 
       0xC2,0x10,0x00 // ret 10h 
;
         在以上一段字节码中,我们会设置好this指针和真正的线程函数地址,然后使用call跳转过去,所有的代码如下所示:
//core.h
#ifndef __ZX_CORE_H__
#define __ZX_CORE_H__
#include <windows.h>

#ifndef interface
#define interface struct
#endif

#ifndef implement
#define implement :public
#endif

const static unsigned char g_thread_proc[]=
        //------------parameter-----------------
        0x8B,0x44,0x24,0x04, // mov eax,dword ptr [esp+10h] 
        0x50, // push eax 

       //-----------this pointer-------------
       0xB9,0x00,0x00,0x00,0x00, // mov ecx,0x12FF5C 

       //-----------call back function-------------
       0xB8,0x00,0x00,0x00,0x00, // mov eax,0 
       0xFF,0xD0, // call eax

       //return
       0xC2,0x10,0x00 // ret 10h 
;


#endif
//runnable.h

#ifndef __ZX_RUNNABLE_H__
#define __ZX_RUNNABLE_H__
#include "core.h"
interface ZXRunnable
        virtual void run(void* lpParameter)= 0;
;
#endif
//thread.h
#ifndef __ZX_THREAD_H__
#define __ZX_THREAD_H__
#include "core.h"
#include "runnable.h"

class ZXThread
public:
        ZXThread();
        ZXThread(ZXRunnable* runnable);
        virtual ~ZXThread();
public:
        void Start();
        void Wait();
        void SetRunnable(ZXRunnable* runnable);
        ZXRunnable* GetRunnable();
private:
        ZXRunnable* m_pRunnable;
        HANDLE m_hThread;
        unsigned char m_thread_proc[sizeof(g_thread_proc)];
;



#endif


//thread.cpp
#include "thread.h"

ZXThread::ZXThread(): m_pRunnable(NULL), m_hThread(NULL)

ZXThread::ZXThread(ZXRunnable* runnable): m_pRunnable(runnable), m_hThread(NULL)

ZXThread::~ZXThread()
        delete m_pRunnable;


void ZXThread::SetRunnable(ZXRunnable* runnable)
         m_pRunnable= runnable;



ZXRunnable* ZXThread::GetRunnable()
        return(m_pRunnable);



void ZXThread::Start()
        CopyMemory(m_thread_proc, g_thread_proc, sizeof(g_thread_proc));
        *(int*)(&m_thread_proc[6])= (int)m_pRunnable;
        void (ZXRunnable::*func)(void* lpParameter)= &ZXRunnable::run;

        int addr;
        __asm
                mov eax, func
                mov addr, eax

       
       *(int*)(&m_thread_proc[11])= addr;
       m_hThread= ::CreateThread(NULL, 0,  (LPTHREAD_START_ROUTINE)(void*)m_thread_proc, 
                                        NULL, 0, NULL);



void ZXThread::Wait()
        ::WaitForSingleObject(m_hThread, INFINITE);


测试代码:
#include <iostream>
#include "thread.h"
using namespace std;

class ZXRun implement ZXRunnable
public:
        virtual void run(void* lpParameter)
                cout<<"Hello,World!"<<endl;
       
;

int main()
        ZXThread boss(new ZXRun);
        boss.Start();
        boss.Wait();
        mark:直接运行以上程序会崩溃,因为DEP,解决方法有两种:1)在编译器里面关掉DEP;2)最靠谱的作法是使用VirtualProtect将 ZXThread中的 m_thread_proc对应的内存块设置为可执行即可。(缓冲区溢出攻击经常使用 m_thread_proc字节码的手段)。

4,总结         上面实际给出了解决一类难题的思路,即如果某个地方需要一个全局的函数(或类的static函数)--- Thunk技术 ,而我们想要完全面向对象(即我们不想使用全局函数或非static类函数),解决方法就是使用机器码,在机器码内完全跳转(Thunk技术---跟Knuth有点像,以前看过国内一位大牛仅使用4个字节就实现封装Windows窗口消息函数的代码,而只要百度那4个字节,就可以搜索出那位牛人,貌似是金山的一位大牛,佩服)。

以上是关于不使用全局函数如何编写线程类的主要内容,如果未能解决你的问题,请参考以下文章

JAVA问题整理

线程安全性

如何在django视图函数中使用全局变量,对所有线程有效。

什么是线程安全性?如何线程安全

Java 什么是线程安全

《Java并发编程实战》学习笔记