《Windows PE》4.3 延迟加载导入表

延迟加载导入表(Delayed Import Table)是PE文件中的一个数据结构,用于实现延迟加载(Lazy Loading)外部函数的机制。

延迟加载是指在程序运行时,只有当需要使用某个外部函数时才进行加载和绑定,而不是在程序启动时就进行全部的动态链接。这个机制可以减少程序启动时间和内存占用。

本节必须掌握的知识点:

        延迟加载导入表数据结构

        延迟加载DLL的意义及实现

        实例分析

4.3.1 延迟加载导入表数据结构

■延迟加载版本1的结构如下:

typedef struct _IMAGE_DELAYLOAD_DESCRIPTOR {

    union {

        DWORD AllAttributes;

        struct {

            DWORD RvaBased : 1; // 如果为1,表示延迟加载版本2的thunk

            DWORD ReservedAttributes : 31; // 保留字段

        } DUMMYSTRUCTNAME;

    } Attributes;

    DWORD DllNameRVA; // 目标库名称的RVA,以NULL结尾的ASCII字符串

    DWORD ModuleHandleRVA; // HMODULE的缓存位置的RVA(相对虚拟地址)

DWORD ImportAddressTableRVA; // IAT(Import Address Table)起始位置的RVA

// 名称表起始位置的RVA,对应PIMAGE_THUNK_DATA::AddressOfData

    DWORD ImportNameTableRVA;

    DWORD BoundImportAddressTableRVA; // 可选的绑定IAT的RVA

    DWORD UnloadInformationTableRVA; // 可选的卸载信息表的RVA

    DWORD TimeDateStamp; // 如果未绑定,则为0;否则为目标DLL的日期/时间戳

} IMAGE_DELAYLOAD_DESCRIPTOR, *PIMAGE_DELAYLOAD_DESCRIPTOR;

●延迟加载版本1的thunk数据结构

延迟加载版本1(Delayed Load Version 1)的thunk数据结构是一种用于存储被延迟加载的外部函数的地址的数据结构。延迟加载版本1的thunk数据结构相对简单,下面是延迟加载版本1的thunk数据结构:

typedef struct _IMAGE_THUNK_DATA {

    union {

        DWORD ForwarderString; // 如果是转发函数,指向转发字符串的RVA

        DWORD Function; // 延迟加载函数的地址

        DWORD Ordinal; // 延迟加载函数的序号

// 对应PIMAGE_IMPORT_BY_NAME或PIMAGE_THUNK_DATA

        DWORD AddressOfData;

    } u1;

} IMAGE_THUNK_DATA, *PIMAGE_THUNK_DATA;

延迟加载版本1的thunk数据结构中的u1联合包含了几个字段,具体的含义取决于延迟加载的方式:

ForwarderString:如果外部函数是一个转发函数,则指向转发字符串的RVA(相对虚拟地址)。

Function:延迟加载函数的地址。该字段保存外部函数的地址。

Ordinal:延迟加载函数的序号。

AddressOfData:指向IMAGE_IMPORT_BY_NAME或另一个IMAGE_THUNK_DATA结构的地址。在延迟加载版本1中,这个字段用于保存函数的名称表的RVA(相对虚拟地址)。

延迟加载版本1的thunk数据结构用于在需要时获取外部函数的地址,并将其更新到IAT(Import Address Table)中,以便程序可以直接调用外部函数。

■延迟加载导入表的使用方法如下:

●在PE文件的导入表中,找到包含延迟加载导入的DLL(动态链接库)项。这通常是在导入表中找到被标记为延迟加载的DLL名称。

●当需要使用延迟加载的外部函数时,在代码中调用该函数之前,需要先检查外部函数是否已经加载和绑定。

●如果外部函数尚未加载和绑定,可以使用LoadLibrary函数来加载DLL,并通过GetProcAddress函数获取外部函数的地址。

●将获取到的外部函数地址更新到IAT(Import Address Table)中,以便程序可以直接调用外部函数。

●重复步骤3和步骤4,直到所有需要延迟加载的外部函数都被加载和绑定。

 注意

1.延迟加载导入表通常需要操作系统或动态链接库的支持来实现延迟加载的机制。在Windows环境下,可以使用delayimp库和设置相应的编译选项来实现延迟加载导入表。

2.此外,可以使用一些特定的宏和函数来简化延迟加载的操作,例如__declspec(delay_load)宏用于标记延迟加载的函数,并提供了一些辅助函数(如__FUnloadDelayLoadedDLL)来卸载延迟加载的DLL。

3.延迟加载导入表的使用可以优化程序的启动时间和内存占用,只有在实际需要使用外部函数时才进行加载和绑定。

4.请记住,延迟加载不是操作系统功能。它完全由链接器和运行时库添加的其他代码和数据实现。因此,在 WINNT.H 中找不到很多对延迟加载的引用。但是,您可以看到延迟加载数据和常规导入数据之间的明显相似之处。

5.延迟加载数据由 DataDirectory 中的 IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 条目(数据目录项第13项)指向。这是 ImgDelayDescr 结构数组的 RVA,在 Visual C++ 的 DelayImp.H 中定义。每个延迟加载导入的 DLL 都有一个 ImgDelayDescr(延迟加载导入描述符)。

6.从 ImgDelayDescr 中收集的关键是它包含 DLL 的 IAT 和 INT 的地址。这些表的格式与常规导入的格式相同,只是它们被写入运行时库代码而不是操作系统并读取。首次从延迟加载的 DLL 调用 API 时,运行时会调用 LoadLibrary(如有必要),然后调用 GetProcAddress。生成的地址存储在延迟加载 IAT 中,以便将来的调用直接转到 API。

7.延迟加载数据有点愚蠢,需要解释。在 Visual C++ 6.0 的原始化身中,所有包含地址的 ImgDelayDescr 字段都使用虚拟地址,而不是 RVA。也就是说,它们包含可以找到延迟加载数据的实际地址。这些字段是 DWORD,大小为 x86 上的指针。

8.现在快进到 IA-64 支持。突然之间,4 个字节不足以容纳一个完整的地址。在这一点上,Microsoft做了正确的事情,并将包含地址的字段更改为RVA

9.仍然存在确定 ImgDelayDescr 使用的是 RVA 还是虚拟地址的问题。该结构具有用于保存标志值的字段。当 Attributes 字段的RvaBased位处于打开状态时,结构成员应被视为 RVA。这是从 Visual Studio® .NET 和 64 位编译器开始的唯一选项。如果 Attributes中的该位处于关闭状态,则 ImgDelayDescr 字段是VA虚拟地址。

■延迟加载版本2(Delayed Load Version 2)

延迟加载版本2(Delayed Load Version 2)是一种改进的延迟加载机制,用于在程序运行时按需加载外部函数。

延迟加载版本2相对于旧的延迟加载机制有以下改进:

●延迟加载导入表中的Attributes字段的RvaBased位被用于标识是否使用延迟加载版本2的thunk。如果该位为1,表示使用延迟加载版本2的thunk。

●延迟加载版本2的thunk是一种特殊的数据结构,用于存储外部函数的地址。每个延迟加载版本2的thunk对应一个被延迟加载的外部函数。

●延迟加载版本2的thunk中包含了一个回调函数,用于在外部函数被加载和绑定后进行通知。这样可以在加载和绑定外部函数之后执行一些额外的操作,例如初始化相关数据或执行其他逻辑。

●延迟加载版本2的结构:

typedef struct _IMAGE_DELAYLOAD_DESCRIPTOR {

    union {

        DWORD AllAttributes;

        struct {

            DWORD RvaBased : 1; // 如果为1,表示延迟加载版本2的thunk

            DWORD ReservedAttributes : 31; // 保留字段

        } DUMMYSTRUCTNAME;

    } Attributes;

    DWORD DllNameRVA; // 目标库名称的RVA,以NULL结尾的ASCII字符串

    DWORD ModuleHandleRVA; // HMODULE的缓存位置的RVA

DWORD ImportAddressTableRVA; // IAT起始位置的RVA

// 名称表起始位置的RVA,对应PIMAGE_THUNK_DATA::AddressOfData

    DWORD ImportNameTableRVA;

    DWORD BoundImportAddressTableRVA; // 可选的绑定IAT的RVA

    DWORD UnloadInformationTableRVA; // 可选的卸载信息表的RVA

DWORD TimeDateStamp; // 如果未绑定,则为0;否则为目标DLL的日期/时间戳

// 延迟加载导入描述符

    PIMAGE_DELAYLOAD_IMPORT_DESCRIPTOR DelayLoadImportDescriptor;

    DWORD DelayLoadInfoSize; // 延迟加载信息的大小

    DWORD DelayLoadInfoTable; // 延迟加载信息表的RVA(相对虚拟地址)

    DWORD BoundDelayLoadTable; // 可选的绑定延迟加载信息表的RVA(相对虚拟地址)

    DWORD UnloadDelayLoadTable; // 可选的卸载延迟加载信息表的RVA(相对虚拟地址)

    DWORD Timestamp; // 时间戳,表示延迟加载导入描述符的版本

} IMAGE_DELAYLOAD_DESCRIPTOR, *PIMAGE_DELAYLOAD_DESCRIPTOR;

●延迟加载版本2的结构相对于延迟加载版本1添加了以下字段:

1.DelayLoadImportDescriptor:指向延迟加载导入描述符的指针。该描述符包含了延迟加载版本2的thunk的信息。

2.DelayLoadInfoSize:延迟加载信息的大小,即IMAGE_DELAYLOAD_IMPORT_DESCRIPTOR结构的大小。

3.DelayLoadInfoTable:延迟加载信息表的RVA(相对虚拟地址),包含了延迟加载版本2的thunk的详细信息。

4.BoundDelayLoadTable:可选的绑定延迟加载信息表的RVA(相对虚拟地址),用于绑定延迟加载版本2的thunk。

5.UnloadDelayLoadTable:可选的卸载延迟加载信息表的RVA(相对虚拟地址),用于卸载延迟加载版本2的thunk。

6.Timestamp:时间戳,表示延迟加载导入描述符的版本。

延迟加载版本2的结构中的DelayLoadImportDescriptor和DelayLoadInfoTable字段提供了更详细的延迟加载信息,使得可以更灵活地控制和管理延迟加载的外部函数。

●延迟加载版本2的thunk数据结构

延迟加载版本2(Delayed Load Version 2)的thunk数据结构是一种特殊的数据结构,用于存储被延迟加载的外部函数的地址。延迟加载版本2的thunk相对于延迟加载版本1的thunk有一些改进和扩展。下面是延迟加载版本2的thunk数据结构:

typedef struct _IMAGE_DELAYLOAD_THUNK {

    union {

        PVOID AddressOfData;// PIMAGE_IMPORT_BY_NAME或PIMAGE_THUNK_DATA

        DWORD ForwarderString; // 如果是转发函数,指向转发字符串的RVA

        DWORD Function; // 延迟加载函数的地址

        DWORD Ordinal; // 延迟加载函数的序号

        DWORD AddressTable; // IAT(Import Address Table)中的地址

// 延迟加载函数的名称表的RVA,对应PIMAGE_THUNK_DATA::AddressOfData

        DWORD NameTable;

    } u1;

    PIMAGE_DELAYLOAD_DESCRIPTOR DelayloadDescriptor; // 延迟加载导入描述符

    PVOID CallbackFunction; // 回调函数的地址

} IMAGE_DELAYLOAD_THUNK, *PIMAGE_DELAYLOAD_THUNK;

延迟加载版本2的thunk数据结构相对于延迟加载版本1的thunk添加了以下字段:

1.CallbackFunction:回调函数的地址。回调函数用于在外部函数被加载和绑定之后进行通知,可以执行一些额外的操作。

延迟加载版本2的thunk数据结构中的AddressOfData字段(在u1联合中)用于保存外部函数的地址。具体的含义取决于延迟加载的方式,可以是函数地址、函数序号、IAT中的地址、延迟加载函数的名称表等。

DelayloadDescriptor字段指向延迟加载导入描述符,该描述符包含了与该thunk相关的延迟加载信息。

使用延迟加载版本2的thunk数据结构,可以更灵活地控制和管理被延迟加载的外部函数,并在需要时执行回调函数进行额外的操作。

●使用延迟加载版本2的步骤如下:

1.在PE文件的延迟加载导入表中找到被标记为延迟加载版本2的DLL(动态链接库)项。

2.当需要使用延迟加载的外部函数时,在代码中调用该函数之前,检查相关的thunk是否已经被加载和绑定。

3.如果thunk尚未加载和绑定,会触发加载和绑定操作,并执行相关的回调函数。

4.在回调函数中,可以执行一些额外的操作,例如初始化相关数据或执行其他逻辑。

5.获取外部函数的地址,并将其更新到IAT(Import Address Table)中,以便程序可以直接调用外部函数。

6.重复步骤3至步骤5,直到所有需要延迟加载的外部函数都被加载和绑定。

延迟加载版本2相对于旧的延迟加载机制提供了更灵活的控制和更强大的功能。通过使用回调函数,可以在外部函数加载和绑定后进行额外的操作,以满足特定的需求。

       ●延迟加载导入表与导入表的区别:

延迟加载导入表和导入表是相互分离的。一个PE文件中可以同时存在这两种数据,也可以单独存在一种。

1.导入表

一个应用程序要调用动态链接库的某个函数,需要先在程序中静态引入该动态链接库,编译器在编译时会分解调用该引入函数的call指令,并将其调用最终指向IAT表。PE加载器要完成的任务就是根据导入表的描述,将IAT中的地址修正为函数在进程地址空间的真实VA地址,这样就能保证该函数被正确调用。

在以上描述中,程序要正确运行,必须保证该动态链接库能够在进程环境变量指定的 PATH中找到,并且将其加载到与程序相同的用户空间,才可以调用来自DLL中的导入函数。

2.延迟加载导入表是一种特殊类型的导入表,同导入表一样,它记录了应用程序要导入的部分或全部动态链接库及相关的函数信息。与导入表不同的是,它所记录的这些DLL动态链接库并不会被操作系统的PE加载器加载,只有等到由其登记的相关函数被应用程序调用时,PE中注册的延迟加载函数才会根据延迟加载导入表中对该函数的描述,动态加载相关链接库并修正函数的VA地址,实现对函数的调用。即首次从延迟加载的 DLL 调用 API 时,运行时会调用 LoadLibrary(如有必要),然后调用 GetProcAddress。生成的地址存储在延迟负载 IAT 中,以便将来的调用直接转到 API函数。

4.3.2 延迟加载DLL的意义及实现

Microsoft Visual C++ 6.0提供了一个出色的新特性,它能够使DLL的操作变得更加容易。这个特性称为延迟加载DLL。延迟加载的DLL是个隐含链接的DLL,它实际上要等到你的代码试图引用DLL中包含的一个符号(例如函数名或全局变量)时才进行加载。

延迟加载的DLL在下列情况下是非常有用的:

●如果一个应用程序使用若干个DLL,那么它的初始化时间就比较长,因为加载程序要将所有需要的DLL映射到进程的用户空间中。解决这个问题的方法之一是在进程运行的时候分开加载各个DLL。延迟加载的DLL能够更容易地完成这样的加载。 

●如果调用代码中的一个新函数,然后试图在老版本的系统上运行你的应用程序,而该系统中没有该函数,那么加载程序就会报告一个错误,并且不允许该应用程序运行。你需要一种方法让你的应用程序运行。如果(在运行时)发现该应用程序在老的系统上运行,那么你将不调用遗漏的函数。

例如,一个应用程序在Windows 2000上运行时想要使用PSAPI函数,而在Windows 98上运行想要使用ToolHelp函数(比如Process32Next)当该应用程序初始化时,它调用GetVersionEx函数来确定主操作系统,并正确地调用相应的其他函数。如果试图在Windows 98上运行该应用程序,就会导致加载程序显示一条错误消息,因为Windows 98上并不存在PSAPI.dll模块。同样,延迟加载的DLL能够使你非常容易地解决这个问题。

延迟加载DLL的实现

●首先象平常那样创建一个DLL。也要象平常那样创建一个可执行模块,但是必须修改两个链接程序开关,并且重新链接可执行模块。下面是需要添加的两个链接程序开关:

/Lib:DelayImp.lib

/DelayLoad:Mydll.dll

Lib开关告诉链接程序将一个特殊的函数__delayLoadHelper嵌入你的可执行模块。

DelayLoad开关将下列事情告诉链接程序:

1.从可执行模块的输入节中删除MyDll.dll,这样,当进程被初始化时,操作系统的加载程序就不会显式加载DLL。

2.将新的Delay Import(延迟导入.didata)节嵌入可执行模块,以指明哪些函数正在从MyDll.dll导入。 

3. 通过转移到对__delayLoadHelper函数的调用,转换到对延迟加载函数的调用。当应用程序运行时,对延迟加载函数的调用实际上是对__delayLoadHelper函数的调用。该函数引用特殊的Delay Import节,并且调用LoadLibrary之后再调用GetProcAddress。一旦获得延迟加载函数的地址,__delayLoadHelper就要安排好对该函数的调用(更新到IAT表中),这样,将来的调用就会直接转向对延迟加载函数的调用。注意,当第一次调用同一个DLL中的其他函数时,必须同样将它们更新到IAT表中。另外,可以多次设定/delayLoad链接程序的开关,为想要延迟加载的每个DLL设定一次开关。 

●延迟加载异常处理

1.通常情况下,当操作系统的加载程序加载可执行模块时,它将设法加载必要的DLL。如果一个DLL无法加载,那么加载程序就会显示一条错误消息。如果是延迟加载的DLL,那么在进行初始化时将不检查是否存 在DLL。如果调用延迟加载函数时无法找到该DLL, __delayLoadHelper函数就会引发一个软件异常条件。可以使用结构化异常处理(SEH)方法来跟踪该异常条件。如果不跟踪该异常条件,那么你的进程就会终止运行。

2.当__delayLoadHelper确实找到你的DLL,但是要调用的函数不在该DLL中时,将会出现另一个问题。比如,如果加载程序找到一个老的DLL版本,就会发生这种情况。在这种情况下,__delayLoadHelper也会引发一个软件异常条件,对这个软件异常条件的处理方法与上面相同。

Visual C++ 开发小组定义了两个软件异常条件代码,即VcppException(ERROR_SEVERITY_ERROR,ERROR_MOD_NOT_FOUND)和VcppException(ERROR_SEVERITY_ERROR,ERROR_PROC_NOT_FOUND)。这些代码分别用于指明DLL模块没有找到和函数没有找到。 

实验三十:延迟加载异常处理

       如果调用延迟加载的函数时无法找到DLL,函数_delayLoadHeaper就会引发一个软件异常。该异常可以使用结构化异常处理(SHE)方法捕获。以下是SHE异常处理代码示例:

/*------------------------------------------------------------------------

 FileName:Exception.cpp

 实验30:延迟加载DLL的SEH异常处理

 (c) bcdaren, 2024

-----------------------------------------------------------------------*/

#include <iostream>

#include <stdexcept>

#include <windows.h>

int main() {

    try {

        // 抛出一个std::runtime_error异常

        throw std::runtime_error("An error occurred.");

    }

    catch (const std::exception& e) {

        // 捕获std::exception及其派生类型的异常

        std::cout << "Caught exception: " << e.what() << std::endl;

    }

    catch (...) {

        // 捕获其他类型的异常

        std::cout << "Caught unknown exception." << std::endl;

    }

    DWORD errorCode1 = ERROR_MOD_NOT_FOUND;

    // 使用FormatMessage函数获取错误消息

    LPSTR errorMessage = nullptr;

    DWORD result = FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM |

FORMAT_MESSAGE_ALLOCATE_BUFFER, nullptr, errorCode1,

        0, reinterpret_cast<LPSTR>(&errorMessage), 0, nullptr);

    if (result != 0) {

        std::cout << "Error severity: " << ERROR_SEVERITY_ERROR << std::endl;

        std::cout << "Error code: " << errorCode1 << std::endl;

        std::cout << "Error message: " << errorMessage << std::endl;

        // 释放错误消息的缓冲区

        LocalFree(errorMessage);

    }

    else {

        std::cout << "Failed to retrieve error message." << std::endl;

    }

    DWORD errorCode2 = ERROR_PROC_NOT_FOUND;

    // 使用FormatMessage函数获取错误消息

    result = FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM |

FORMAT_MESSAGE_ALLOCATE_BUFFER, nullptr, errorCode2,

        0, reinterpret_cast<LPSTR>(&errorMessage), 0, nullptr);

    if (result != 0) {

        std::cout << "Error severity: " << ERROR_SEVERITY_ERROR << std::endl;

        std::cout << "Error code: " << errorCode2 << std::endl;

        std::cout << "Error message: " << errorMessage << std::endl;

        // 释放错误消息的缓冲区

        LocalFree(errorMessage);

    }

    else {

        std::cout << "Failed to retrieve error message." << std::endl;

    }

    return 0;

}

运行:

Caught exception: An error occurred.

Error severity: 3221225472

Error code: 126

Error message: 找不到指定的模块。

Error severity: 3221225472

Error code: 127

Error message: 找不到指定的程序。

下面给出一段微软MSDN给出的示例代码及说明:

https://learn.microsoft.com/zh-cn/cpp/build/reference/linker-support-for-delay-loaded-dlls?view=msvc-170

可以在 MSVC include 目录中的文件 delayhlp.cpp 中找到定义的 __HrLoadAllImportsForDll 函数指示链接器从使用 /delayload 链接器选项指定的 DLL 加载所有导入。

一次加载所有导入时,可以在一个位置集中处理错误。 可以避免围绕对导入的所有实际调用进行结构化异常处理。 这还避免了应用程序在某个过程期间失败的情况:例如,如果帮助程序代码在成功加载其他导入后无法加载某个导入。

调用 __HrLoadAllImportsForDll 不会更改挂钩和错误处理的行为。 有关详细信息,请参阅错误处理和通知。

__HrLoadAllImportsForDll 对 DLL 本身中存储的名称进行区分大小写的比较。

下面是在称为 TryDelayLoadAllImports 的函数中使用 __HrLoadAllImportsForDll 尝试加载命名 DLL 的示例。 它使用函数 CheckDelayException 来确定异常行为。

int CheckDelayException(int exception_value)

{

If (exception_value == VcppException(ERROR_SEVERITY_ERROR,

ERROR_MOD_NOT_FOUND) ||       

exception_value == VcppException(ERROR_SEVERITY_ERROR,

ERROR_PROC_NOT_FOUND))

    {

        // This example just executes the handler. (系统将控制权转给异常处理程序)

        return EXCEPTION_EXECUTE_HANDLER;

    }

    // Don't attempt to handle other errors

    return EXCEPTION_CONTINUE_SEARCH;

}

bool TryDelayLoadAllImports(LPCSTR szDll)

{

    __try

    {

        HRESULT hr = __HrLoadAllImportsForDll(szDll);

        if (FAILED(hr))

        {

            // printf_s("Failed to delay load functions from %s\n", szDll);

            return false;

        }

    }

    __except (CheckDelayException(GetExceptionCode()))

    {

        // printf_s("Delay load exception for %s\n", szDll);

        return false;

    }

    // printf_s("Delay load completed for %s\n", szDll);

    return true;

}

卸载延迟加载的DLL

假如你的应用程序需要一个特殊的DLL来打印一个文档,那么这个DLL就非常适合作为一个延迟加载的DLL,因为大部分时间它是不用的。不过,如果用户选择了Print命令,你就可以调用该DLL中的一个函数,然后它就能够自动进行DLL的加载。这确实很好,但是,当文档打印后,用户可能不会立即打印另一个文档,因此可以卸载这个DLL,释放系统的资源。如果用户决定打印另一个文档,那么DLL就可以根据用户的要求再次加载,若要卸载延迟加载的DLL,必须执行两项操:

●首先,当创建可执行文件时,必须设定另一个链接程序开关(/delay:unload)。

●其次,必须修改源代码,并且在你想要卸载DLL时调用__FunloadDelayLoaded DLL函数。

/delay:unload 链接程序开关告诉链接程序将另一个节放入文件中。该节包含了你清除已经调用的函数时需要的信息,这样它们就可以再次调用__delayLoadHelper函数。

当调用__FunloadDelayLoaded DLL时,你将想要卸载的延迟加载的DLL的名字传递给它。该函数进入文件中的未卸载节,并清除DLL的所有函数地址,然后__FunloadDelayLoaded DLL调用FreeLibrary,以便卸载该DLL。 

●有关卸载延迟加载的 DLL 的重要说明:

可以在 MSVC include 目录中的文件 delayhlp.cpp 中找到 __FUnloadDelayLoadedDLL2 函数的实现。 有关详细信息,请参阅了解延迟加载帮助程序函数。

__FUnloadDelayLoadedDLL2 函数的 name 参数必须与导入库包含的内容完全匹配(包括大小写)。 (该字符串也位于映像中的导入表中。)可以使用 DUMPBIN /DEPENDENTS 查看导入库的内容。 如果首选不区分大小写的字符串匹配,可以更新 __FUnloadDelayLoadedDLL2 以使用其中一个不区分大小写的 CRT 字符串函数或使用 Windows API 调用。

 注意

下面要指出一些重要的问题。 

1.千万不要自己调用FreeLibrary,来卸载DLL,否则函数的地址将不会被清除,这样,当下次试图调用DLL中的函数时,就会导致访问违规。手动卸载dll时,必须通过__FUnloadDelayLoadedDLL2(“xxx.dll”)卸载,不能使用FreeLibray()。

2.当调用__FunloadDelayLoaded DLL时,传递的DLL名字不应该包含路径,名字中的字母必须与你将DLL名字传递给/DelayLoad链接程序开关时使用的字母大小写相同,否则,__FUnloadDelayLoaded DLL的调用将会失败。

3.如果永远不打算卸载延迟加载的DLL,那么请不要设 定/delay:unload链接程序开关,并且你的可执行文件的长度应该比较小。

4.如果你不从用/delay:unload开关创建的模块中调用__FunloadDelayLoaded DLL,那么什么也不会发生,__FunloadDelayLoaded DLL什么操作也不执行,它将返回FALSE。 

5.延迟加载的DLL具备的另一个特性是,按照默认设置,调用的函数可以与一些内存地址相链接,在这些内存地址上,系统认为函数将位于一个进程的地址中。由于创建可链接的延迟加载的DLL节会使你的可执行文件变得比较大,因此链接程序也支持一个/Delay:nobind开关。因为人们通常都喜欢进行链接,因此大多数应用程序不应该使用这个链接开关。

6.延迟加载的DLL的最后一个特性是供高级用户使用的,它真正显示了Microsoft的注意力之 所在。当__delayLoadHelper函数执行时,它可以调用你提供的挂钩函数。这些函数将接收__delayLoadHelper函数的进度通知和错误通知。此外,这些函数可以重载DLL如何加载的方法以及如何获取函数的虚拟内存地址的方法。 

4.3.3 实例分析

在延迟加载版本1中,我们使用的是IAT(Import Address Table)来实现延迟加载。在编译和链接阶段,编译器和链接器会自动生成IAT,并将外部函数的地址填充到IAT中。在程序运行时,当第一次调用外部函数时,会触发操作系统加载和绑定外部函数,并将其更新到IAT中。

实验三十一:延迟加载版本1示例

       以下是一个使用延迟加载版本1的C语言示例代码:

dll.h

#pragma once

#include <windows.h>

#ifdef _cplusplus //如果C++模式编译

    #ifdef API_EXPORT

        #define EXPORT   extern "C" __declspec(dllexport)

    #else

        #define EXPORT    extern "C" __declspec(dllimport

    #endif

#else

    #ifdef API_EXPORT

        #define EXPORT   __declspec(dllexport

    #else

        #define EXPORT   __declspec(dllimport

    #endif

#endif

EXPORT void  CALLBACK ExampleFunction();

dll.c

/*------------------------------------------------------------------------

 FileName:dll.c

 实验31:延迟加载版本1示例(DLL)

 (c) bcdaren, 2024

-----------------------------------------------------------------------*/

#include <Windows.h>

#include <stdio.h>

#define API_EXPORT

#include "dll.h"

//入口和退出点

int WINAPI DllMain(HINSTANCE hInstance, DWORD fdwReason, PVOID pvReserved)

{

    return TRUE;

}

EXPORT void CALLBACK ExampleFunction()

{

    printf("DLL was loaded——delayload1.c!\n");

    return ;

}

delayload1.c

/*------------------------------------------------------------------------

 FileName:delayload1.c

 实验31:延迟加载版本1示例(可执行文件)

 (c) bcdaren, 2024

-----------------------------------------------------------------------*/

// link with /link /DELAYLOAD:DLL.dll /DELAY:UNLOAD

#include <windows.h>

#include <delayimp.h>

#include <stdio.h>

#include "dll.h"

#pragma comment(lib, "delayimp")

#pragma comment(lib,"DLL")

#pragma comment(linker, "/delay:unload")

#pragma comment(linker, "/Lib:Delayimp.lib")

// 使用外部函数

void UseExampleFunction()

{

    // 调用外部函数

    ExampleFunction();

}

int main()

{

    BOOL TestReturn;

    // 调用外部函数DLL.DLL will load at this point

    UseExampleFunction();

    //显式卸载MyDLL.dll will unload at this point

    TestReturn = __FUnloadDelayLoadedDLL2("DLL.dll");

    if (TestReturn)

        printf_s("\nDLL was unloaded\n");

    else

        printf_s("\nDLL was not unloaded\n");

    return 0;

}

延迟加载DLL配置方法一:

在解决方案的该项目“属性”->“配置属性”->“链接器”->“输入”->“延迟加载的Dll”, 写入DLL名。需要注意的是扩展名是 dll 不是 lib。

在解决方案的该项目“属性”->“配置属性”->“链接器”->“高级”->“卸载延迟加载的Dll”, 设置为”是”。如图4-6所示。

图4-6 延迟加载DLL配置属性

延迟加载DLL配置方法二:

       直接在代码中写入链接配置选项

#pragma comment(linker, "/delay:unload")

#pragma comment(linker, "/Lib:Delayimp.lib")

总结

在上述示例代码中,我们创建了一个DLL.dll动态链接库,包含了一个外部函数ExampleFunction。

然后,在UseExampleFunction函数中,我们调用外部函数ExampleFunction。

在main函数中,我们调用UseExampleFunction函数来使用外部函数。在第一次调用ExampleFunction时,操作系统将加载动态链接库DLL.dll,并加载和绑定外部函数,并将其更新到IAT中。

在延迟加载版本1中,使用的是IAT(Import Address Table)来实现延迟加载,操作系统会自动创建和维护IAT。

最后使用显式的方法调用__FUnloadDelayLoadedDLL2函数手动卸载DLL.dll动态链接库。

 注意

在VS编译环境中,真实的情况是,只有Debug版本才支持延迟加载DLL,而Release版本自定优化掉了延迟加载,仍然是在加载PE到内存的时刻加载了所有DLL动态链接库。

下面是Debug版本的PE文件静态分析。

将DelayLoad1_debug.exe拖入WinHex,找到数据目录项的第13项,如下所示:

00000160   00 00 00 00 10 00 00 00  00 00 00 00 00 00 00 00   ................

00000170   CC B1 01 00 50 00 00 00  00 F0 01 00 3C 04 00 00   瘫..P....?.<...

00000180   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

00000190   00 00 02 00 30 04 00 00  60 86 01 00 38 00 00 00   ....0...`?.8...

000001A0   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

000001B0   00 00 00 00 00 00 00 00  E0 7B 01 00 40 00 00 00   ........鄘..@...

000001C0   00 00 00 00 00 00 00 00  00 B0 01 00 CC 01 00 00   .........?.?..

000001D0   00 C0 01 00 40 00 00 00  00 00 00 00 00 00 00 00   .?.@...........

000001E0   00 00 00 00 00 00 00 00  2E 74 65 78 74 62 73 73   .........textbss

延迟导入表的RVA:0001C000H,大小为40H。位于.didat节区,FOA文件偏移地址为00009400H。

00009400   01 00 00 00 D0 7B 01 00  5C A1 01 00 70 C0 01 00   ....衶..\?.p?.

00009410   40 C0 01 00 B8 C1 01 00  C0 C2 01 00 00 00 00 00   @?.噶..缆......

00009420   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

00009430   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

00009440   A0 C0 01 00 00 00 00 00  00 00 00 00 00 00 00 00   犂..............

00009450   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

00009460   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

00009470   45 11 41 00 00 00 00 00  00 00 00 00 00 00 00 00   E.A.............

00009480   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

00009490   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

000094A0   00 00 5F 45 78 61 6D 70  6C 65 46 75 6E 63 74 69   .._ExampleFuncti

000094B0   6F 6E 40 30 00 00 00 00  00 00 00 00 00 00 00 00   on@0............

000094C0   00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ................

typedef struct _IMAGE_DELAYLOAD_DESCRIPTOR {

    union {

        DWORD AllAttributes;

        struct {

            DWORD RvaBased : 1; // 如果为1,表示延迟加载版本2的thunk

            DWORD ReservedAttributes : 31; // 保留字段

        } DUMMYSTRUCTNAME;

    } Attributes;

    DWORD DllNameRVA; // 目标库名称的RVA:00017BD0H

    DWORD ModuleHandleRVA; // HMODULE的缓存位置的RVA:0001A15CH

DWORD ImportAddressTableRVA; // IAT起始位置的RVA:0001C070H

// 名称表起始位置的RVA,对应PIMAGE_THUNK_DATA::AddressOfData

    DWORD ImportNameTableRVA; 延迟加载导入名称表的RVA:0001C040H

    DWORD BoundImportAddressTableRVA; // 可选的绑定IAT的RVA:0001C1B8H

    DWORD UnloadInformationTableRVA; // 可选的卸载信息表的RVA:0001C2C0H

    DWORD TimeDateStamp; // 如果未绑定,则为0;否则为目标DLL的日期/时间戳

} IMAGE_DELAYLOAD_DESCRIPTOR, *PIMAGE_DELAYLOAD_DESCRIPTOR;

IAT起始位置的RVA:0001C070H对应的FOA地址为:00009470

ImportNameTableRVA; 0001C040H对应的FOA地址为:00009440

目标库名称的RVA:00017BD0H,对应的FOA地址为00006FD0,如下所示:

00006FD0   44 4C 4C 2E 64 6C 6C 00  00 00 00 00 00 00 00 00   DLL.dll.........

将使用OD调试器打开DelayLoad1_debug.exe,单步跟踪到DLL.dll中的ExampleFunction函数地址处,如图4-7所示。0x30C070即IAT表起始位置,存储的函数地址00411145H被替换为真实的函数地址0FEA1050H

图4-7 外部导入函数的跳转过程

 注意

延迟加载 DLL 导入有几个限制。

1.不支持数据导入。 解决方法是使用 LoadLibrary(或者在知道延迟加载帮助程序已加载 DLL 后使用 GetModuleHandle)和 GetProcAddress 自行显式处理数据导入。

2.不支持延迟加载 Kernel32.dll。 必须加载此 DLL 才能使延迟加载帮助程序例程正常工作。

3.不支持绑定转发的入口点。

4.如果 DLL 加载延迟,而不是在启动时加载,则进程可能会有不同的行为。 你可以看到在延迟加载 DLL 的入口点中是否存在按进程的初始化。 其他情况包括静态 TLS(线程本地存储),它使用通过 LoadLibrary 加载 DLL 时不处理的 __declspec(thread) 来声明。 使用 TlsAlloc、TlsFree、TlsGetValue 和 TlsSetValue 的动态 TLS 仍可在静态或者延迟加载的 DLL 中使用。

5.初次调用每个函数后,应将静态全局函数指针重新初始化为导入的函数。 这是必需的,因为第一次使用函数指针会指向 thunk,而不是加载的函数。

6.目前还没有办法在使用正常导入机制时,只延迟加载 DLL 中的特定过程。

7.不支持自定义调用约定(例如在 x86 体系结构上使用条件代码)。 此外,任何平台上都不保存浮点寄存器。 如果自定义帮助程序例程或挂钩例程使用浮点类型,请注意:这些例程必须在具有浮点参数的寄存器调用约定的计算机上保存和恢复完整浮点状态。 延迟加载 CRT DLL 时请小心谨慎,尤其是在帮助程序函数中调用 CRT 函数的情况,这些 CRT 函数采用数值数据处理器 (NDP) 堆栈上的浮点参数。

实验三十二:延迟加载版本2示例

       以下是一个使用延迟加载版本2的C语言示例代码:

dll2.h:

#pragma once

#include <windows.h>

#ifdef _cplusplus //如果C++模式编译

#ifdef API_EXPORT

#define EXPORT   extern "C" __declspec(dllexport)  

#else

#define EXPORT    extern "C" __declspec(dllimport

#endif

#else

#ifdef API_EXPORT

#define EXPORT   __declspec(dllexport

#else

#define EXPORT   __declspec(dllimport

#endif

#endif

EXPORT void CALLBACK MyFunction();

DLL2.c:

/*------------------------------------------------------------------------

 FileName:DLL2.c

 实验31:延迟加载版本1示例(DLL)

 (c) bcdaren, 2024

-----------------------------------------------------------------------*/

#include <Windows.h>

#include <stdio.h>

#define API_EXPORT

#include "dll2.h"

//入口和退出点

int WINAPI DllMain(HINSTANCE hInstance, DWORD fdwReason, PVOID pvReserved)

{

    return TRUE;

}

EXPORT void CALLBACK  MyFunction()

{

    printf("DLL was loaded——delayload1.c!\n");

    return ;

}

delayload2.c

/*------------------------------------------------------------------------

 FileName:delayload2.c

 实验32:延迟加载版本2示例(可执行文件)

 (c) bcdaren, 2024

-----------------------------------------------------------------------*/

#include <windows.h>

#include <delayimp.h>

#include <stdio.h>

#pragma comment(linker, "/DelayLoad:DLL2.dll")

#pragma comment(linker, "/delay:unload")

#pragma comment(linker, "/Lib:Delayimp.lib")

// 定义回调函数类型

typedef FARPROC(WINAPI *PfnDliHook)(unsigned int dliNotify, PDelayLoadInfo pdli);

// 定义延迟加载DLL的回调函数

FARPROC WINAPI DelayLoadFailureHook(unsigned int dliNotify, PDelayLoadInfo pdli)

{

    // 检查通知类型

    if (dliNotify == dliFailLoadLib)

    {

        // 如果是无法加载DLL的通知,则可以在此处进行处理

        // 例如,输出错误信息或采取其他适当的操作

        printf("Failed to load DLL: %s\n", pdli->szDll);

    }

    else if (dliNotify == dliFailGetProc)

    {

        // 如果是无法获取DLL导出函数的通知,则可以在此处进行处理

        // 例如,输出错误信息或采取其他适当的操作

        printf("Failed to get function: %s\n", pdli->dlp.szProcName);

    }

    // 返回一个替代函数或NULL

    // 如果返回一个替代函数,可以在此处实现替代函数的逻辑

    // 如果返回NULL,则表示继续使用缺失的函数将导致运行时错误

    return NULL;

}

// 定义一个延迟加载函数指针

typedef void(*MyFunctionPtr)();

volatile MyFunctionPtr myFunction;

// 定义一个全局变量保存回调函数指针

extern PfnDliHook __pfnDliNotifyHook2 = DelayLoadFailureHook;

int main()

{

    // 延迟加载DLL并获取函数指针-VS2017无法实现(被优化掉了)

    //MyFunctionPtr myFunction = (MyFunctionPtr)GetProcAddress(GetModuleHandle(NULL), "_MyFunction@0");

    HMODULE hModule =

LoadLibrary(TEXT("D:\\code\\winpe\\ch04\\DLL2\\DLL2.dll"));

    myFunction = (MyFunctionPtr)GetProcAddress(hModule, "_MyFunction@0");

    // 检查函数指针是否有效

    if (myFunction != NULL)

    {

        // 调用延迟加载的函数

        myFunction();

    }

    else

    {

        // 处理函数加载失败的情况

        printf("Failed to get function pointer.\n");

    }

    return 0;

}

 

总结

在上述示例中,我们首先定义了一个延迟加载DLL的回调函数DelayLoadFailureHook,用于处理无法加载DLL或无法获取导出函数的情况。在回调函数中,我们可以输出相应的错误信息或采取其他适当的操作。

然后,我们定义了一个延迟加载函数指针MyFunctionPtr,用于指向延迟加载的函数。

在main函数中,我们设置了延迟加载版本2的回调函数__pfnDliFailureHook2为我们定义的回调函数DelayLoadFailureHook。

接下来,我们使用GetProcAddress函数延迟加载DLL并获取函数指针。如果成功获取函数指针,我们可以调用延迟加载的函数;如果获取函数指针失败,我们可以处理函数加载失败的情况。

       【注意】VS编译环境中虽然可以设置延迟加载DLL,但是编译时自动优化掉了延迟加载,导致延迟加载失败。替代的方法就是直接使用LoadLibrary函数手动加载DLL。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/445177.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

wms智能供应链仓储管理系统,一站式仓储管理产品溯源解决方案

几度WMS条码仓储 管理系统是公司凭借多年为制造企业信息化服务的经验积累&#xff0c;结合WMS、条码、ERP思想而设计的智能供应链仓储系统。 主要包括以下六大模块&#xff1a;库位管理、存货管理、来料管理、发料管理、成品管理、日常管理。WMS条码仓储管理系统&#xff0c;是…

Unity中搜索不到XR Interaction Toolkit包解决方法

问题&#xff1a; 针对Unity版本2020.3在中PackageManager可能搜素不到XR Interaction Toolkit包 在Package Manager中未显示XR Interaction Toolkit包 解决方法&#xff1a; Package manager左上角&#xff0c;点加号&#xff0c;选择 Add package from git URL..&#xff0c;…

21年408数据结构

第一题&#xff1a; 解析&#xff1a;q指针指向要被删除的元素&#xff0c;当这个元素是链表中唯一一个元素时&#xff0c;q指针和尾指针都指向同一个元素&#xff0c;那么在删除掉这个元素之前&#xff0c;需要将尾指针调整到指向头指针的位置&#xff0c;此时链表为空&#x…

说下SSL/TLS四次握手过程?

参考自&#xff1a;SSL/TLS四次握手过程是怎么样的&#xff1f;HTTPS、SSL、TLS三者之间的联系和区别 一.SSL/TLS 简介 SSL(Secure Socket Layer 安全套接层)是基于 HTTPS 下的一个协议加密层&#xff0c;用于解决 HTTP 在传输数据时使用明文而导致的不安全问题。 SSL 是 HT…

【数据结构】零碎知识点(易忘 / 易错)总结回顾

一、数据结构的概念 数据结构&#xff08;Data Structure&#xff09;是计算机存储、组织数据的方式&#xff0c;指相互之间存在一种或多种特定关系的数据元素的集合。 二、算法 算法&#xff08;Algorithm&#xff09;就是定义良好的计算过程&#xff0c;它取一个或一组的值为…

二分图算法总结 C++实现

总体概念 染色法 基本思路步骤 将所有的边及其相接的边用邻接表存储起来&#xff1b;遍历所有的点&#xff0c;找到未上色的点&#xff1b;用BFS将该点及其相接的点迭代上色&#xff1b;在上述染色步骤中&#xff0c;如果相邻点的颜色相同则无法形成二分图&#xff1b; 题目…

数据结构:单链表OJ题

目录 相交链表解题思路代码 环形链表&#xff08;I&#xff09;解题思路代码 环形链表&#xff08;II&#xff09;解题思路代码 随机链表的复制&#xff08;深拷贝&#xff09;解题思路代码 相交链表 题目描述&#xff1a; 案例&#xff1a; 题目链接&#xff1a;https://l…

FunASR离线文件转写服务开发指南-debian-10.13

FunASR离线文件转写服务开发指南-debian-10.13 服务器环境 debian10.13 64位 第一步 配置静态网卡 auto eth0 iface eth0 inet static address 192.168.1.100 netmask 255.255.255.0 gateway 192.168.1.1 dns-nameservers 8.8.8.8 8.8.4.4/etc/init.d/networking restart第…

【JVM】JMM

文章目录 前置的硬件知识什么是JMMJMM的三大特性JMM中定义的原子操作happens-before先行发生原则 前置的硬件知识 硬件存储体系: 运行速度从上到下依次减慢. 由于CPU的计算速度远超与内存的处理速度,所以CPU不会直接从内存中读写,而是将内存中的变量拷贝一份副本放到CPU高速…

2022年下真题(案例分析)

一、数据流图 二、数据库设计 - ER图 三、面向对象设计 - 用例图、类图 四、算法

【人工智能】AI人工智能的重要组成部分,深入解析CNN与RNN两种神经网络的异同与应用场景和区别

文章目录 一、卷积神经网络&#xff08;CNN&#xff09;详解1. 特征与结构CNN的基本结构 2. 应用场景3. 代码示例 二、循环神经网络&#xff08;RNN&#xff09;详解1. 网络结构与特点RNN的基本结构 2. 应用场景3. 代码示例 三、CNN与RNN的异同点1. 相同点2. 不同点 四、CNN与R…

基于YOLOv8-deepsort算法的智能车辆目标检测车辆跟踪和车辆计数

关于深度实战社区 我们是一个深度学习领域的独立工作室。团队成员有&#xff1a;中科大硕士、纽约大学硕士、浙江大学硕士、华东理工博士等&#xff0c;曾在腾讯、百度、德勤等担任算法工程师/产品经理。全网20多万粉丝&#xff0c;拥有2篇国家级人工智能发明专利。 社区特色…

Vue使用@别名替换后端ip地址

1. 安装 types/node types/node 包允许您在TypeScript项目中使用Node.js的核心模块和API&#xff0c;并提供了对它们的类型检查和智能提示的支持。 npm install types/node --save-dev 比如安装之后&#xff0c;就可以导入nodejs的 path模块&#xff0c;在下面代码 import path…

闪电麦昆 语音控制齿轮行进轨迹,ESP32搭配语音控制板,串口通信,附视频演示地址

演示地址 https://www.bilibili.com/video/BV1cW421d79L/?vd_sourceb8515e53f6d4c564b541d98dcc9df990 语音控制板的配置 web展示页面 esp32 程序 #include <ESP8266WiFi.h> #include <ESP8266WebServer.h> #include <LittleFS.h> #include <WebSo…

STL之set、map的使用

STL之set、map 1. 序列式容器和关联式容器2. set系列的使⽤参考文档链接&#xff1a;2.1 set的介绍&#xff08;2&#xff09;set的增删查2.2 multiset的介绍 3 map3.1 参考文档3.2 map类的介绍3.3 pair类型介绍3.4 map的构造3.6 map的数据修改3.7 multimap和map的差异 1. 序列…

openpdf

1、简介 2、示例 2.1 引入依赖 <dependency><groupId>com.github.librepdf</groupId><artifactId>openpdf</artifactId><version>1.3.34</version></dependency><dependency><groupId>com.github.librepdf</…

python+yaml+pytest+allure接口自动化框架

建议想学自动化的同学&#xff0c;先花半个月一个月的时间&#xff0c;去b站极限学习一下有关python的基础内容&#xff0c;比如各种数据类型的特点&#xff0c;创建 转换等&#xff0c;还有面向对象的一些知识&#xff0c;否则直接看自动化框架&#xff0c;很难看懂理解&#…

根据请求错误的状态码判断代理配置问题

SafeLine&#xff0c;中文名 “雷池”&#xff0c;是一款简单好用, 效果突出的 Web 应用防火墙(WAF)&#xff0c;可以保护 Web 服务不受黑客攻击。 雷池通过过滤和监控 Web 应用与互联网之间的 HTTP 流量来保护 Web 服务。可以保护 Web 服务免受 SQL 注入、XSS、 代码注入、命…

2024顶级一区idea:多模态图像融合!

在图像处理的前沿领域&#xff0c;多模态图像融合技术正成为研究的热点&#xff0c;它通过整合来自不同来源的图像数据&#xff0c;为我们提供了更丰富的信息维度&#xff0c;从而显著提升图像处理的精确度和效率。 这项技术的核心优势在于能够捕捉并融合各种图像数据中的互补…

3D渲图软件推荐:打造高质量渲染效果

在现代设计领域&#xff0c;3D渲图已经成为展示设计方案和产品外观的重要手段。无论是建筑设计、产品设计还是影视动画&#xff0c;都需要借助专业的3D渲染图软件来实现逼真的视觉效果。 本文将为您介绍几款备受好评的3D渲染图软件&#xff0c;帮助您在项目中选择合适的工具。…