CnPack 开源软件项目 - Delphi下如何实现32位和64位都能用的Inline Hook?
  网站首页 下载中心 每日构建 文档中心 捐助我们 开发论坛 关于我们 致谢名单 English


 微信扫一扫关注我们的公众号


 最新下载


 
CnWizards 1.6.0.1246
[2025-03-28]

 
CnVCL 组件包 20250328
[2025-03-28]

 
CnPack 密码算法库 20250328
[2025-03-28]
  每日构建版下载
  专家包时间线
 项目相关链接


 
CnPack GitHub 首页
GIT 使用说明
申请加入 CnPack
CnPack 成员名单
 网站访问量

今日首页访问: 317
今日页面流量: 1891
全部首页访问: 5400169
全部页面流量: 21908756
建站日期: 2003-09-01

 
Delphi下如何实现32位和64位都能用的Inline Hook?
 
CnPack 开源软件项目 2025-02-24 01:41:33


======================================================================
1. 讲什么:什么是Inline Hook?
======================================================================

Hook在计算机术语中无论是直译为“钩子”,还是意译为“挂接”,均指的是一种对于现有机制的拦截。这个拦截实现后,会在运行期拦住现有某个流程,拐进自己的流程,自己做完特定事儿后,再按需要决定是否走回原流程。CnPack开发组的CnVcl组件包中有针对菜单的CnMenuHook、有针对组件事件的CnEventHook、也有针对控件消息处理过程的CnControlHook,这里要介绍的,则是专家包中常用的CnMethodHook:一个较为通用的、在Delphi运行期针对本进程内的函数或方法实现Inline Hook也即“内联”挂接的工具类。

所谓Inline Hook(内联挂接),主要针对函数或方法挂接而言,具体来说这里的内联两字,指的是从二进制的函数或方法内部动手拦截,而不是像Windows的消息钩子、或CnVcl的菜单、事件等的Hook那样利用操作系统或组件本身提供的机制进行协商拦截,说白了就是暴力拦截。

怎么个暴力法呢?我们先回忆一下Windows下的进程空间。自80386推出保护模式以来,每个进程都占据了虚拟的完整地址空间,32位x86下有4GB(64位x64下更大)。这个地址空间被分成多个区域,我们的EXE代码、所依赖加载的DLL或BPL,以及系统自带的DLL,全部平铺在其中。

进程空间中各EXE和DLL的代码可以互相进行调用。函数调用的机制用一句话概括:使用汇编指令CALL到函数入口进行调用,函数尾部用RET指令返回。当然,CALL之前涉及到调用方将各参数以约定方式放寄存器或堆栈里,然后CALL指令本身将返回地址也塞进堆栈,RET时按约定按需清理堆栈参数,然后从堆栈里找到返回地址跳回。

32位下一个典型的调用如下:

  008C56CF | E8 340A0000  | call <vcl70.@Forms@TApplication@Idle
  ...
  008C6108 | 55           | push ebp                            
  008C6109 | 8BEC         | mov ebp,esp                        
  008C610B | 83C4 F0      | add esp,FFFFFFF0
  ...                    

这里E8是CALL指令操作符,后面的00000A34是一个相对当前EIP的偏移量,当前EIP是CALL指令的下一条,也就是008C56CF+5=008C56D4,再加上偏移量,008C56D4+00000A34=008C6108,正好是其下方列出的入口。

64位下的也类似,同样有一个E8带一个四字节的相对于RIP的偏移的跳转机制:

  00007FFE57CB8B63 | E8 08250100      | call <ntdll.RtlGetCurrentServiceSessionId>
  ...
  00007FFE57CCB072 | 8B0425 60000000  | mov eax,dword ptr ds:[60]            
  00007FFE57CCB079 | 48:8B80 90000000 | mov rax,qword ptr ds:[rax+90]
  00007FFE57CCB080 | 48:85C0          | test rax,rax                
  00007FFE57CCB083 | 75 02            | jne ntdll.7FFE57CCB087      
  00007FFE57CCB085 | C3               | ret
  
我们要做的Inline Hook,便是拦截这类CALL。

正常情况下,二进制代码中的CALL是不会显式提供什么机制让你轻易实施拦截的,我们要真正拦截它,采取的方法一般是找到函数体的入口内部,在此处的内存里强行写进我们的自定义汇编代码,该自定义代码会通过JMP或其他跳转指令跳转至我们的代码里以执行我们的逻辑。当不需要拦截时,再把入口代码复原成开始的模样,我们玩儿消失。

由于要手工动态改写内存中不属于自己的可执行代码,还要精心计算让其执行起来,因此不确定性很多,一着不慎就崩溃。怎么写、写哪里、写什么、怎么跳、怎么复原、怎么跳回,32位和64位有什么不同,每一个问题都要认真解决。

下面我们一步一步抽丝剥茧来。

======================================================================
2. 怎么写:动态修改运行期的二进制代码如何操作?
======================================================================

思想深入、学习努力的程序员都了解代码是代码、数据是数据的分离设计思想。一般进程空间的数据区是不让执行的(DEP机制),一般进程空间的代码区也不让写。不过,Windows API提供了一个VirtualProtect函数,能够修改本进程空间中指定内存区域的标志为PAGE_EXECUTE_READWRITE,意思是让你可执行可读还可写,这就为动态修改二进制代码奠定了基础。

修改二进制代码的动作本身比较容易,直接结构赋值或挨个字节赋值就行,难点在于准备好内容以及朝哪儿赋值,这里后面会详细讲。赋值或者说内存内容修改完毕后还有个问题,现代的CPU一般会有内存指令预取机制,代码跑起来时我们修改掉的内存中的代码,但其原始内容可能早就被预取到CPU的指令缓冲区里准备先执行了,如果这时候正好跑到我们修改的地方,便会造成我们的修改起不到作用。这里正好还有一个Windows API叫FlushInstructionCache,用来强行让某处内存对应的CPU指令缓存失效,下一步如执行到此处则让其重新从内存中取指令,就能够很好地避免这个问题。

======================================================================
3. 写哪里:怎样正确找到目标函数的入口地址?
======================================================================

我们在Delphi里做函数挂接,一般有两种情况,一是对进程内的其他DLL的原始二进制函数进行动态获取与挂接,譬如Windows API中的GetWindowTextA,位于user32.dll中,或一个第三方DLL中的一个新函数PlaySomeGame(exe: PWideChar)这种;二是基于Delphi运行期,对VCL库的源码中所指的一些函数或类的方法进行挂接(包括已经在Windows.pas中声明了的可直接使用的Windows API函数)。这两种情况都涉及到如何正确找到待挂接的函数入口。

对于DLL中的已知名称的函数,要动态获得其入口地址比较好办,LoadLibrary后,用指定函数名调用GetProcAddress就行,32位和64位都通用。

但VCL源码直接引用方式的地址就有些复杂,假设我们要挂接TImageList类的BeginUpdate方法,我们用@TImageList.BeginUpdate本该得到该方法的入口(注意:@后面是类名,也就是说挂接的方法是针对类的,不能是针对某个特定对象的方法,因为特定对象的方法是通过Self参数区分的,实质上的代码实现还是所在的类中的那唯一的一个),但这种“@类名.方法名”的函数地址获取方式,在Delphi的带包编译模式下却有大问题。原来,Delphi的带包编译模式下,TImageList.BeginUpdate方法实现在vclxx.bpl中,但当我们在自己的EXE或DLL代码中直接使用TImageList.BeginUpdate方法时,无论是通过@取地址,还是通过对象进行调用,编译器都会玩一个小技巧,先到一处本模块中的跳转指令,再往下查个跳转表执行,才会跳到vclxx.bpl中的真正入口。

哪怕不是VCL的对象方法,而是Windows.pas中声明了的API函数,如果我们用类似于@GetWindowTextA的方式去拿该API的入口地址,拿到的,仍然是那个本模块的跳转指令,仍然要再跳一次才能进入user32.dll的代码。

推测这种机制是Delphi编译器为了让BPL链接时的函数调用地址都归集在一起更有利于管理而设计的。

举例,VCL70里对GetCursorPos这个Windows API的调用,call时,先行进入的是第二行的jmp dword ptr指令:

  008C6099 | E8 C2BDF5FF    | call <JMP.&GetCursorPos>
  ...
  00821E60 | FF25 28888F00  | jmp dword ptr ds:[<&GetCursorPos>]

第二次被跳入的user32.dll里才是正经代码:

  75F61218 | 8BFF        | mov edi,edi                    
  75F6121A | 55          | push ebp                          
  75F6121B | 8BEC        | mov ebp,esp                            
  75F6121D | 6A 69       | push 69                                
  75F6121F | 6A 01       | push 1                                  
  75F61221 | FF75 08     | push dword ptr ss:[ebp+8]              
  75F61224 | E8 FD61FFFF | call user32.75F57426                    
  75F61229 | 5D          | pop ebp                                
  75F6122A | C2 0400     | ret 4

这种Delphi的特殊机制给我们带来了困扰。我们如果拿到的只是中间跳转,改写了这个跳转后,只能控制本EXE或DLL模块的该调用被拦截,而其他模块依然能通过其他办法进入原始入口,那就没法达到全面拦截的目的。

要全面拦截,只能顺藤摸瓜一下:如果拿到的入口处的代码是个简单的跳转,则模拟跳转过去,拿到真正的入口。CnMethodHook.pas单元中的CnGetBplMethodAddress函数就是起的这个作用。

经过分析,Delphi编译进模块里的跳转指令有固定格式,不是E9那种直接根据当前EIP/RIP的偏移量跳转,而是指定地址的间接跳转,指令操作符是25FF。在这条指令里同样会提供一个四字节数据,32位下,这个四字节数据是一个地址,CPU会取出这个地址内的四字节内容;64位下,这个四字节数据则是相对于当前RIP的四字节偏移量,CPU根据这个偏移量计算所指的内存再取出其中的八字节内容,最终把这四字节或八字节内容作为真正的跳转目标。

简而言之,32位跳转指令对25FF后的四字节Addr如此解释:

  JMP DWORD PTR [Addr]

64位跳转则如此解释:

  JMP QWORD PTR [RIP + Addr]

注意有个RIP+,因此64位该指令中的四字节数据和32位下的含义并不等同。

详细的可执行代码,可参考CnMethodHook.pas中的函数的实现,同时支持32位和64位的上述两种情况:

  function CnGetBplMethodAddress(Method: Pointer): Pointer;

我们只要跟着该指令的处理逻辑走,拿到25FF后的四字节内容,32位下直接访存再取四字节内容,64位下加RIP偏移访再存取八字节内容,就能拿到正确的目标地址,为我们的挂接做好充分的准备了。

======================================================================
4. 写什么:选择何种跳转指令能满足要求?
======================================================================

内联挂接,要在函数真正的入口处,写上一条汇编语言跳转指令,跳到我们的代码里。注意,由于x86/x64的指令集的复杂性,各指令长短不一,这条强行写进去的跳转指令大概率会破坏它后面的指令排布,不过不要紧,如果我们还想跳回来执行原始函数的话,只要将函数入口处的被我们的写动作覆盖的那几个字节还原回来,再调用此函数,就能达到目的。
也就是说,如果我们的需求是要让被挂接的函数先跳到我们这边,我们处理后再执行原函数,那么我们要做的事儿的顺序就如下所示:
先挂接,也就是写跳转指令。
等执行流程跳进我们的函数后,我们做我们的事。
需要调用原函数时,先恢复现场,再调用原函数。
等调用返回到我们后,重新挂接,也就是再写跳转指令,等下一次跳入。

有细心的读者或许已经看出,如果这个函数被多线程交叉调用怎么办?
解决办法是在2和4处加入临界区保护,将恢复现场、调用原函数、再写指令挂接的三个动作变成串行的,这样虽然降低了运行效率,但至少能保证不会混乱出错。
32位系统里,面对4GB的地址空间,有一条直接相对跳转指令E9能够满足要求。这条指令和之前分析过的25FF的间接跳转不同,它后面跟四字节偏移量,形式是:

  JMP REL32

我们要写这么一条指令到函数入口,于是得用一个这样的结构先行表示它:

  TCnLongJump = packed record
    JmpOp: Byte;        // Jmp 相对跳转指令,为 $E9
    Addr: Pointer;      // 跳转到的 32 位相对地址偏移
  end;
  
Addr如何计算?这是本节的核心问题。

挂接时,我们脑袋里要想象到这条指令被CPU执行时,我们挂接的函数入口在哪、我们要跳去的新地方在哪、这个JMP的Addr是怎样被CPU拿来参与计算的、这几个问题都要搞明白,才能计算出正确的Addr值来。

假设我们拿到了待挂接的函数目标入口地址,搁一个Pointer型的变量OldFunc中,我们自己要跳入的新函数地址搁在另一个Pointer型的变量NewFunc中(它可能直接被我们的新函数地址@OurNewFunc赋值)。

这时候,我们要想象OldFunc处的内存,要被我们写一个5字节的TCnLongJump结构进去,或者说,要把OldFunc的值当作一个PCnLongJump指针,我们用这个指针访问OldFunc处的5字节。

首先,写E9这个跳转指令码,这个动作比较好理解:

  PCnLongJump(OldFunc)^.JmpOp := $E9;

接下来计算偏移量。注意,当OldFunc处搁好这条指令,碰上被CPU执行、从而马上要进行跳转时,此刻CPU的EIP指向的是OldFunc后的指令,也就是说,EIP的值是OldFunc + SizeOf(TCnLongJump),或者直接说是OldFunc + 5也行。

E9的跳转指令执行时,会把当前EIP的值加上四字节偏移量,我们要让这个加的结果正好抵达NewFunc的话,只要提前做一个减法就行了。四字节偏移量值 = NewFunc - EIP = NewFunc - (OldFunc + 5) = NewFunc -OldFunc - 5。

加上一些必要的类型转换,代码就可以写成:

  PCnLongJump(OldFunc)^.Addr := Pointer(Integer(NewFunc) -
    Integer(OldFunc) - SizeOf(TCnLongJump));
  
Inline Hook的写入就这么完成了,是不是很简单?

当然,写之前要将这5个字节先保存,以备恢复用:

  var
    FSaveData: TCnLongJump;
  begin
    FSaveData := PCnLongJump(OldFunc)^;

恢复时也好办:

  PCnLongJump(OldFunc)^ := FSaveData;

就行了。

如果有读者要问,OldFunc处的函数体,如果它太短怎么办?

譬如如果它短于5字节,极端情况下就一个RET,那么我们写入5个字节进去,就势必会破坏函数体之外的其他数据,如果不巧正好改写掉了其他函数,那么当其他函数被调用时,CPU就拿不到完整指令,引发崩溃等后果。

很遗憾,CnMethodHook目前还无法做这种函数体大小的检测,如果真碰上挂接极小型函数,就只能自求多福了。

好在Delphi编译器往往会在函数体之间按对齐规则插一些CC也就是INT 3指令,这些指令可以一定程度上作为缓冲以补充函数体的大小,不至于产生严重后果。

另外,E9这条相对偏移跳转指令,在64位下也是通用的,因此我们的这一项Inline Hook机制也同时适用于32位和64位。

======================================================================
5. 怎么访问:函数的参数及返回值如何处理?
======================================================================

我们做函数的内联挂接,不会只是为了简单地跳过来跳回去,大部分情况下都是要拿到函数的参数及返回值进行处理。这就涉及到了OldFunc的函数声明分析,和NewFunc的新函数如何写这两个问题了。

首先说准则:

第一、OldFunc的函数声明我们一定得弄准确。

第二、NewFunc的声明一定得和OldFunc严格对应。

否则,后果不堪设想。

这里举个例子,我们专家包要挂接Delphi的coreide120.bpl中的一个输出函数:

  @Editorcontrol@TCustomEditControl@LineIsElided$qqri

从名字上分析来看,它的声明是:

  type
    TCustomEditControl = class
      function LineIsElided(AInt: Integer): Boolean;

调用约定是Delphi的32位下默认的fastcall,省略不写。
由于它实质上是对象的方法,因而有个Self的隐藏参数,所以我们如果要写一个NewLineIsElided函数,我们的新函数声明就必须是:

  function NewLineIsElided(ASelf: TObject; AInt: Integer): Boolean;

调用约定同样是省略不写的fastcall(如果是Windows API,就得按其文档写上stdcall了)。
获取地址,及挂接的动作就得写成:

  var
    OldFunc, NewFunc: Pointer;
    Hook: TCnMethodHook;
  begin
    FCorIdeModule := LoadLibrary('coreide120.bpl');
    OldFunc := CnGetBplMethodAddress(FCorIdeModule, '@Editorcontrol@TCustomEditControl@LineIsElided$qqri');
    NewFunc := @NewLineIsElided;
    Hook := TCnMethodHook.Create(OldFunc, NewFunc);
    ...

假设我们的NewLineIsElided函数里,要拿到旧函数LineIsElided的返回值,求一个反,再返回,以达到捣乱的目的。那么我们就要这么写。

先声明一个旧函数的类型:

  type
    TLineIsElidedProc = function (ASelf: TObject; AInt: Integer): Boolean;
  
再在新函数里这么写:

  function NewLineIsElided(ASelf: TObject; AInt: Integer): Boolean;
  begin
    Hook.UnhookMethod; // 恢复旧函数入口
    Result := not TLineIsElidedProc(OldFunc)(ASelf, AInt); // 调用旧函数获取结果并修改
    Hook.HookMethod;  // 再度挂接备下次使用
  end;

我们看到,原来的对象方法,转换成了加一个ASelf参数的普通函数,然后我们的新函数和原普通函数声明一模一样,这样才能成功地实施内联挂接并正确传递参数、获取结果,和上面的原则相符合。

32位下的Inline Hook,至此基本说明白了。

======================================================================
6. 新问题:64位下为何崩溃?
======================================================================

以上Inline Hook的机制,网上有不少文章也同样说得比较清楚了,我们专家包已将其整合并以Delphi工具类的方式封装实现,在Delphi的IDE里大行其道,但这“道”均是32位的。而我们的CnMethodHook作为一个通用工具类,自然是32位和64位都支持才比较好。

我们经过一定时间的研究,发现E9这条JMP四字节偏移的相对跳转指令,在32位下和64位下都通用,于是花点时间测试了一下,在Windows 7的64位版本下写了一个简单的EXE,以64位方式编译,挂接内部函数,以及挂接DLL函数,的确能够成功,于是就想当然地认为64位移植完毕,可以开放给广大用户使用了。

但是,在Windows 11上一跑,发现了崩溃问题!

崩溃不是在我们挂接或者说写入JMP指令时发生的,说明写的动作本身在64位下没问题,问题发生在执行时。

调试过程中也没走什么弯路,一看地址,脑袋里忽然灵光一闪,原来,的确是我们考虑不周而太想当然了。

E9这条JMP指令后带四字节偏移量,这个偏移量在32位里能覆盖4GB的进程虚拟地址空间,因而无论如何跳都不会不够用。可64位呢?

64位的进程空间,可是足足有16EB,相当于百万TB。

我们的NewFunc,和OldFunc,如果在内存中相隔实在太远,超过了4GB的距离呢?E9这条JMP指令后的四字节偏移量,岂不就不够用了?

还记得我们计算这条四字节偏移量的代码,在64位下先适配成:

  PCnLongJump(OldFunc)^.Addr := Pointer(DWORD(NativeUInt(NewFunc) -
    NativeUInt(OldFunc) - SizeOf(TCnLongJump)));

虽然我们将32位下的Integer改成了64位下的NativeUInt,避免NewFunc和OldFunc的地址本身出现截断。但这还远远不够。

当NewFunc离OldFunc实在太远时,减出来的值会超过四字节所能表示的数字的上限,填进去的Addr,就成了个被截断的值,真正跳转时,就不知道跳到哪里去了。

之前在64位的Windows 7上测试没出问题,是因为Windows 7在64位中仍然保守地沿用之前4GB内的布局,很多DLL都挤在低4GB范围内,因而跳转没问题。

但Windows 10或11放开了手脚,很多DLL包括系统的和Delphi自己写的BPL都被映射到了更广的地址,譬如00007FF8F34B8000这种远超32位的那种。

那除了我们EXE内部的函数之间,只要随便找个本进程内的其他DLL或BPL的函数,它和我们EXE内部的新函数之间的距离差,是大概率超过32位偏移所能表示的上限的。

难怪我们崩溃了。

======================================================================
7. 新想法:64位下如何判断超界问题?
======================================================================

我们既然找到了问题根源,那么处理思路就有了。我们先在CnNative.pas里写了一个判断函数,判断两个64位整数之间的差A-B是否超出32位上下限:

  function IsUInt64SubOverflowInt32(A: UInt64; B: UInt64): Boolean;
    
具体实现有点长,这里就不完整贴出来了。思路是分A大于等于B和A小于B两种情况处理,然后用较大者减较小者,分别判断得到的UInt64的差值是否超过$7FFFFFFF和$80000000。

接下来在TCnMethodHook类的构造函数中先进行NewFunc和OldFunc的距离判断,并用一个成员变量FFar记录下来:

  FFar := IsUInt64SubOverflowInt32(UInt64(NewFunc), UInt64(OldFunc));

当FFar为False时,使用E9那个32位相对JMP进行挂接,岁月安好。

当FFar为True时,整个挂接机制,就要大改了!

======================================================================
8. 新思路:64位下如何实施超界跳转?
======================================================================

不能用四字节偏移JMP,我们得另外寻找64位汇编中的更远距离的跳转机制。

经过反复研读x64汇编指令,很遗憾没出现一个幻想中的JMP REL64的指令来满足我们的要求,我们没法通过另外一个比如EA加八字节偏移量的方式来实现我们的长距离跳转。

经过搜索以及询问AI,我们终于找到了一种可行的变相的方式(假设Addr是我们要跳转的新函数地址,长度是64位):

  PUSH Addr.Low32
  MOV DWORD [rsp+4], Addr.High32
  RET

在这段代码里,我们先把目标地址的低32位推入堆栈,再把目标地址的高32位放入堆栈处拼成一个完整的64位返回地址,然后调用RET实施返回,这样就通过一次返回动作,变相实现了64位地址下的远距离跳转。

补充:这里要手动操作两次堆栈的原因是,x64里没有PUSH 64位地址直接入栈的显式指令,而是由CALL指令直接封装了。

相应的操作结构定义如下:

  TCnLongJump64 = packed record
    PushOp: Byte;         // $68
    AddrLow32: DWORD;
    MovOp: DWORD;         // $042444C7
    AddrHigh32: DWORD;
    RetOp: Byte;          // $C3
  end;

其中,PUSH、MOV和RET三个指令,是固定的几个操作数,AddrLow32和AddrHigh32,则是我们要动态填写的、拆分后的跳转地址。

依然拿之前的NewFunc和OldFunc举例,假设它俩相隔太远,导致FFar判断为True,因而必须要用上面这种长距离跳转的模式。

先声明保存变量和中间变量:

  var
    FSaveData64: TCnLongJump64;
    NewAddr: UInt64;

挂接前先保存这片区域的内容:

  FSaveData64 := PCnLongJump64(OldFunc)^;

挂接时,先初始化几个操作码,再将64位目标地址拆分后赋值:

  PCnLongJump64(OldFunc)^.PushOp := $68;
  PCnLongJump64(OldFunc)^.MovOp := $042444C7;
  PCnLongJump64(OldFunc)^.RetOp := $C3;
  
  NewAddr := UInt64(NewFunc); // 64 位跳转地址拆成高低两部分分别塞入堆栈
  PCnLongJump64(OldFunc)^.AddrLow32 := DWORD(NewAddr and $FFFFFFFF);
  PCnLongJump64(OldFunc)^.AddrHigh32 := DWORD(NewAddr shr 32);

恢复时依然是:

  PCnLongJump64(OldFunc)^ := FSaveData64;

经过实践,这套方法在64位下的确管用,缺点就是要替换的函数体远超过5字节,达到了惊人的14字节。

这就要求这个远距离挂接的原函数体内容不能太短,哪怕同样有一堆填补对齐的CC护航,也不太保险。

如果64位原函数体内容偏短,那么覆盖超界的严重问题,出现概率比32位下是要大一些的。

======================================================================
9. 他山之石:第三方库DDetours
======================================================================

最后,说说他山之石。

其实Delphi下有一个比较成熟的第三方Inline Hook库DDetours,它功能比我们复杂,挂接时会将旧函数的前面一批指令进行语义分析,并部分挪移到动态分配的一块内存中,再露出一个叫Trampoline的指针。这个指针代表旧函数,用户可以通过这个指针强制转换类型并直接调用其旧函数,而不用像我们的CnMethodHook一样恢复现场才能挂接,效率看上去比我们的高点儿。



本文已阅读 425 次
来自: CnPack 开源软件项目

上一主题 | 返回上级下一主题

相关主题:
在Delphi XE中用coroutine的方式修改delphi自带的Threads例子
在Delphi XE中使用go语言的defer方法
在Delphi XE中使用go语言的并发编程方法之Demo3
在Delphi XE中使用go语言的并发编程方法之Demo2
在Delphi XE中使用go语言的并发编程方法
翻译:现有 Delphi 项目迁移到 Tiburon 中的注意事项
Yock.W 原创《Api Hook 细析(一)》
如何为 CnPack 组件包捐献及移植组件代码
如何获得 CnPack 的最新源码?


版权所有(C) 2001-2025 CnPack 开发组 网站编写:Zhou Jinyu