在哪些情况下,动态 CRT 尚未在调用用户提供的 DllMain 时初始化? [英] In which cases is the dynamic CRT not already initialized on call to user supplied DllMain?

查看:18
本文介绍了在哪些情况下,动态 CRT 尚未在调用用户提供的 DllMain 时初始化?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

序言: 这个问题特别关注,并且只关注通过/MD 使用的动态 CRT 的行为.它不质疑任何其他建议的有效性.DllMain.

<小时>

正如 我们被告知:(参考:动态链接库最佳实践,MSDN,2006 年 5 月 17 日)

<块引用>

您不应在 DllMain 中执行以下任务:

  • ...
  • 使用动态 C 运行时 (CRT) 中的内存管理功能.如果 CRT DLL 未初始化,则调用这些函数可能会导致进程崩溃.
  • ...

其他人已经对此提出质疑(例如:质疑论点的有效性)并且因为我们得到了帮助在那里,我们可以清楚地看到一个相当简单的情况,这可能可能导致麻烦::><块引用>

您假设 DLL 的入口点始终是 _DllMainCRTStartup.事实并非如此,这只是链接器的默认设置.它可以是程序员想要的任何东西,可以使用链接器的/ENTRYPOINT 选项快速轻松地更改.Microsoft 无法阻止这种情况发生.

所以这些是这个问题的要素:

  • 链接任何其他情况"nofollow noreferrer">/MD 并且提供自定义/ENTRYPOINT,其中动态CRT 不应该完全初始化吗?

    • 具体来说,如果所有 DLL 加载仅通过静态依赖项"完成,即根本没有显式的 LoadLibrary 调用,只需链接时 DLL 依赖项.
  • 奖励:MS 文档专门调用了内存管理功能",但据我所知,如果 CRT 未初始化,则可能任何 CRT 功能应该是不安全的.为什么要这样调用内存管理函数?

  • 第三:

    写.到自定义 ENTRYPOINT:我不太明白这是一个如此重要的场景,以至于需要在没有进一步限定的情况下将其包含在 not-do-in-DllMain 列表中.IFF 我提供了一个自定义入口点,我负责正确初始化 CRT,否则 CRT 在我的程序中的任何地方都无法正常工作,而不仅仅是 DllMain.为什么要专门调用 DllMain 部分?

    这让我回到了 Q.1,即如果这是 动态 CRT 出现问题的唯一情况.澄清或大开眼界,为什么这对 DllMain 比对 DLL 的其他部分更重要,或者我在这里可能会错过的内容,将不胜感激.

<小时>

奖励链接:

<小时>

理由:我觉得我应该为上下文添加这个:我问这个是因为我们有大量的代码通过全局 C++ 对象构造函数来做事.多年来,实际破坏的东西已经被审查出来(例如并发 LoadLibrary、线程同步等),但所有代码都充满了 std C++ 和 CRT 函数,很高兴在 Windows XP、7 和 Windows 10 上工作多年,没有任何已知的问题.虽然我不是会哭但它确实有效",但我必须在这里对尝试解决"这个问题是否有任何中短期价值进行工程判断.因此,如果肥皂盒的答案可以留在他们的盒子里,我将不胜感激.

解决方案

链接 /MD 并且不提供自定义 /ENTRYPOINT,其中动态 CRT 不应该完全初始化了吗?

首先一些符号:

  • X 有静态导入(取决于)YZ : X[ Y, Z]
  • X 入口点:X_DllMain
  • X_DllMain 调用 LoadLibrary(Y) : X

当我们使用 /MD - 我们在单独的 DLL 中使用 crt.在此上下文中初始化意味着已调用 crt DLL 的入口点.所以问题可以更笼统和清晰:

来自 X[Y] => Y_DllMainX_DllMain 之前调用?

一般情况下没有.因为可以是循环依赖,当 Y[X]Y[Z[X]].

最著名的例子 user32[gdi32]gdi32[user32] 或在 win10 中依赖于 gdi32[gdi32full[user32]] .所以必须先调用 user32_DllMaingdi32_DllMain 吗?但是很明显,任何 crt DLL 都不依赖于我们的自定义 DLL.所以让我们排除循环依赖的情况.

当加载器加载模块 X - 它加载所有依赖模块(以及它的依赖 - 这是递归过程),如果它已经不在内存中,则加载器构建调用图,并开始调用模块入口点.很明显,如果 A[B],加载器总是在 A_DllMain 之前尝试调用 B_DllMain(除非调用顺序未定义时的循环依赖).但是哪些模块将在调用图中?所有 X 依赖模块?当然不.当我们开始加载 X 时,其中一些模块可能已经在内存中(已加载).所以它的入口点已经用 DLL_PROCESS_ATTACH 调用,现在不能第二次调用.这个策略用在xp、vista、win7:

当我们加载 X 时:

  1. 在内存中加载或定位所有依赖模块
  2. 仅调用加载(X之后)模块的入口点.
  3. if A[B] - 在 A_DllMain
  4. 之前调用 B_DllMain

示例:加载X[Y[W[Z]], Z]

//++开始加载XZ_DllMainW_DllMainY_DllMainX_DllMain//--end 加载 X

但是这种情况没有考虑到下一种情况 - 某些模块可能已经在内存中,但它的入口点尚未被调用.这怎么会发生?这可能发生在某些模块入口点调用 LoadLibrary 的情况下.

示例 - 加载 X[Y, Z]

//++开始加载XY_DllMain//++开始加载WW_DllMain//--结束加载WZ_DllMainX_DllMain//--end 加载 X

所以 W_DllMain 将在 Z_DllMain 之前被调用,尽管 W[Z].正是因为这不推荐从 DLL 入口点调用 LoadLibrary.

<小时>

但来自动态链接库最佳实践

<块引用>

这可能会导致死锁或崩溃.

关于deadlock not true的话——当然任何僵局基本上都不能.在哪里 ?如何 ?我们已经在 DLL 入口点中持有加载器锁,并且可以递归获取此锁.崩溃真的可以(在win8之前).

或另一个:

<块引用>

调用ExitThread.在 DLL 分离期间退出线程会导致再次获取加载器锁,导致死锁或崩溃.

  • 可以导致再次获取加载器锁 - 不是可以而是总是
  • 导致死锁 - false - 我们已经持有这个锁
  • 崩溃 - 不会有任何崩溃,否则会

但这确实是 - 没有免费加载程序锁的线程退出.它变得永远忙碌.结果是任何新线程的创建或退出,任何新的 DLL 加载或卸载,或者只是 ExitProcess 调用 - 在尝试获取加载器锁时挂起.所以这里真的会死锁,但不会在调用 ExitThread 期间 - 后者.

当然还有有趣的注意事项 - 窗口本身从 DllMain 调用 LoadLibrary - user32.dll 总是调用 LoadLibrary> 用于 imm32.dll 从它的入口点(在 win10 上仍然如此)

<小时>

但是从win8(或win8.1)开始,加载器在处理依赖模块上变得更加智能.现在2改变了

2. 调用 加载(X 之后)模块的入口点,或者模块尚未初始化.

所以在现代 Windows (8+) 中用于加载 X[Y, Z]

//++开始加载XY_DllMain//++开始加载WZ_DllMainW_DllMain//--结束加载WX_DllMain//-- 结束加载 X

Z 初始化将移至 W 加载调用图.结果现在一切都是正确的.

为了测试这个,我们可以构建下一个解决方案:test.exe[ kernel32, D1<;D2[kernel32, msvcrt] >, msvcrt ]

  • D2 仅从 kernel32msvcrt 导入并导出 SomeFunc
  • D1 仅从 kernel32 导入并从它的入口点调用 LoadLibraryW(L"D2"),然后调用 D2.SomeFunc
  • test.exekernel32D1msvcrt
  • 导入

(完全按照这个顺序!这很重要 - D1 必须在导入时之前 msvcrt,为此需要设置 D1 在链接器命令行中 msvcrt 之前)

结果 D1 入口点将在 msvcrt 之前被调用.这是正常的 - D1 不依赖于 msvcrt但是当 D1 从它的入口点加载 D2 时,就变得有趣了

D2.dll 的代码(/NODEFAULTLIB kernel32.lib msvcrt.lib)

#include 外部C"{__declspec(dllimport) int __cdecl sprintf(PSTR buf, PCSTR 格式, ...);}BOOLEAN WINAPI MyEp( HMODULE , DWORD ul_reason_for_call, PVOID ){如果(ul_reason_for_call == DLL_PROCESS_ATTACH){OutputDebugStringA("D2.DllMain\n");}返回真;}INT_PTR WINAPI SomeFunc(){__pragma(message(__FUNCDNAME__))字符缓冲区[32];//这仅用于链接到 msvcrt.dllsprintf(buf, "D2.SomeFunc\n");输出调试字符串A(buf);返回0;}#ifdef _WIN64#define FuncName "?SomeFunc@@YA_JXZ"#别的#define FuncName "?SomeFunc@@YGHXZ"#万一__pragma(comment(comment(linker, "/export:" FuncName ",@1,NONAME,PRIVATE"))

D1.dll 的代码(/NODEFAULTLIB kernel32.lib)

#include #pragma 警告(禁用:4706)BOOLEAN WINAPI MyEp( HMODULE hmod, DWORD ul_reason_for_call, PVOID ){如果(ul_reason_for_call == DLL_PROCESS_ATTACH){OutputDebugStringA("D1.DllMain\n");if (hmod = LoadLibraryW(L"D2")){if (FARPROC fp = GetProcAddress(hmod, (PCSTR)1)){fp();}}}返回真;}INT_PTR WINAPI SomeFunc(){__pragma(message(__FUNCDNAME__))OutputDebugStringA("D1.SomeFunc\n");返回0;}#ifdef _WIN64#define FuncName "?SomeFunc@@YA_JXZ"#别的#define FuncName "?SomeFunc@@YGHXZ"#万一__pragma(comment(comment(linker, "/export:" FuncName ",@1,NONAME"))

exe 的代码(/NODEFAULTLIB kernel32.lib D1.lib msvcrt.lib)

#include 外部C"{__declspec(dllimport) int __cdecl sprintf(PSTR buf, PCSTR 格式, ...);}__declspec(dllimport) INT_PTR WINAPI SomeFunc();无效 ep(){字符缓冲区[32];//这仅用于链接到 msvcrt.dllsprintf(buf, "exe 入口\n");输出调试字符串A(buf);ExitProcess((UINT)SomeFunc());}

xp 输出:

LDR: D1.dll 加载 - 调用初始化例程D1.DllMain加载:D2.dllLDR:D2.dll 加载 - 调用初始化例程D2.dll主D2.SomeFuncLDR: msvcrt.dll 加载 - 调用初始化例程exe入口D1.SomeFunc

win7:

LdrpRunInitializeRoutines - 信息:调用 DLLD1.dll"的初始化例程D1.DllMain加载:D2.dllLdrpRunInitializeRoutines - 信息:调用 DLLD2.DLL"的初始化例程D2.dll主D2.SomeFuncLdrpRunInitializeRoutines - msvcrt.dll"exe入口D1.SomeFunc

在这两种情况下调用流程是相同的 - D2.DllMain 调用 before msvcrt 入口点,尽管 D2[msvcrt]

但在 win8.1 和 win10 上 - 调用流程是另一个:

LdrpInitializeNode - 信息:调用 DLLD1.dll"的初始化例程D1.DllMainLdrpInitializeNode - 信息:调用 DLLmsvcrt.dll"的初始化例程LdrpInitializeNode - 信息:调用 DLLD2.DLL"的初始化例程D2.dll主D2.SomeFuncexe入口D1.SomeFunc

D2 入口点在 msvcrt 初始化之后调用.

什么是结论?

如果当模块X[Y]被加载并且内存中没有未初始化的Y - Y_DllMain 将被调用before X_DllMain.或者换句话说 - 如果没有人从 DLL 入口点调用 LoadLibrary(X) (或 LoadLibrary(Z[X]) ).因此,如果您的 DLL 将以正常"方式加载(不是通过从 DllMain 调用 LoadLibrary 或在某些 dll 加载事件上从驱动程序注入) - 您可以确定 crt 条目点已调用(crt 已初始化)

more - 如果您在 win8.1+ 上运行 - 并且 X[Y] 已加载 - Y_DllMain 将始终被调用 before <代码>X_DllMain.

<小时>

现在关于在您的 dll 中自定义 /ENTRYPOINT.

即使您在单独的 DLL 中使用 crt - 一些小的 crt 代码将静态链接到您的模块 DllMainCRTStartup - 调用您的函数 DllMain(这不是条目点)的名字.因此,如果动态 crt - 我们真的有 2 个 crt 部分 - 在单独的 DLL 中的主要部分,它将被初始化之前您的 DLL 入口点被调用(如果不是我描述更高和win7、vista、xp).和小的静态部分(模块内的代码).当这个静态部分被称为已经完全取决于你.这部分 DllMainCRTStartup 做一些内部初始化,在你的代码中初始化全局对象(initterm)并在它返回后调用 DllMain(在 dll 分离时)为全局变量调用析构函数..

如果您在 DLL 中设置自定义入口点 - 此时已初始化的单独 DLL 中的 crt 已初始化,但您的静态 crt 没有(作为和全局对象).从这个自定义入口点你需要调用 DllMainCRTStartup

Preamble: This question is specifically concerned with, and only with, the behavior of the dynamic CRT used through /MD. It does not question the validity of any other recommendations wrt. DllMain.


As we've been told: (ref: Dynamic-Link Library Best Practices, MSDN, May 17, 2006)

You should never perform the following tasks from within DllMain:

  • ...
  • Use the memory management function from the dynamic C Run-Time (CRT). If the CRT DLL is not initialized, calls to these functions can cause the process to crash.
  • ...

Others have questioned this already (as in: questioned the validity of the argument) and since we helpfully get an answer there, we can clearly see one rather simple case where this could potentially cause troubles:

You are working from the assumption that the entrypoint for a DLL is always _DllMainCRTStartup. This is not the case, it is merely the linker's default. It can be anything a programmer wants it to be, swiftly and easily changed with the linker's /ENTRYPOINT option. There is nothing that Microsoft can do to prevent this.

So these are the elements of this question:

  • Is there any other situation when linking /MD and not supplying a custom /ENTRYPOINT, where the dynamic CRT ought to not be fully initialized?

    • Specifically, if all DLL loading only done through "static dependencies", i.e. no explicit LoadLibrarycalls at all, just link time DLL dependencies.
  • Bonus: The MS docs specifically call out "memory management function", but as far as I can tell, if the CRT is not initialized, potentially any CRT function should be unsafe. Why call out memory management functions in this way?

  • No.3:

    Wrt. to the custom ENTRYPOINT: I don't quite see how this can be such an important scenario that it need be included in the not-do-in-DllMain list without further qualification. IFF I supply a custom entry point, I'm responsible for correctly initializing the CRT, or the CRT will not work properly anywhere in my program, not just DllMain. Why call out the DllMain part specifically?

    This leads me back to Q.1, namely if this is the only scenario where this is problematic for the dynamic CRT. A clarification or eye-opener why this would be more important for DllMain that for other parts of the DLL, or what I might miss here, would be appreciated.


Bonus links:


Rationale: I feel I should add this for context: I am asking this because we have massive amounts of code doing things via global C++ object constructors. Things that actually broke have been vetted out over the years (like concurrent LoadLibrary, thread sync, etc.), but all the code is full of std C++ and CRT functions, that happily have been working for years on Windows XP, 7 and Windows 10 without any known hiccups. While I'm not one to cry "but it just works", I have to do an engineering judgment here on whether there is any short-to-medium value in trying to "fix" this. Therefore, I would appreciate if the soapbox answers could be left in their boxes.

解决方案

Is there any other situation when linking /MD and not supplying a custom /ENTRYPOINT, where the dynamic CRT ought to not be fully initialized?

first some notation:

  • X have static import (depends on) Y and Z : X[ Y, Z]
  • X entry point : X_DllMain
  • X_DllMain call LoadLibrary(Y) : X<Y>

when we use /MD - we use crt in separate DLL(s). initialized in this context mean that entry point(s) of crt DLL(s) already called. so question can be more general and clear:

are from X[Y] => Y_DllMain called before X_DllMain ?

in general case no. because can be circular dependency, when Y[X] or Y[Z[X]].

most known example user32[gdi32], and gdi32[user32] or in win10 depends on gdi32[gdi32full[user32]] . so user32_DllMain or gdi32_DllMain must be called first ? however obvious that any crt DLL(s) not depends on our custom DLL. so let exclude circular dependency case.

when loader load module X - it load all it dependency modules (and it dependency - this is recursive process), if it already not in memory, then loader build call graph, and begin call modules entry points. obvious if A[B], loader always try call B_DllMain before A_DllMain (except circular dependency when order of calls is undefined). but which modules will be in call graph ? all X dependency modules ? of course no. some of this modules can already be in memory (loaded) when we begin load X. so it entry points already called, with DLL_PROCESS_ATTACH and must not be called second time now. this strategy used in xp, vista, win7:

when we load X:

  1. load or locate in memory all it dependency modules
  2. call entry points of new loaded (after X) modules only.
  3. if A[B] - call B_DllMain before A_DllMain

example: loaded X[Y[W[Z]], Z]

//++begin load X
Z_DllMain
W_DllMain
Y_DllMain
X_DllMain
// --end load X

but this scenario not take in account next case - some module can be already in memory, but it entry point yet not called. how this can happen ? this can happen in case some module entry point call LoadLibrary.

example - loaded X[Y<W[ Z]>, Z]

//++begin load X
Y_DllMain
  //++begin load W
  W_DllMain
  //--end load W
Z_DllMain
X_DllMain
// --end load X

so W_DllMain will be called before Z_DllMain, despite W[Z]. exactly because this not recommended call LoadLibrary from DLL entry point.


but from Dynamic-Link Library Best Practices

This can cause a deadlock or a crash.

the words about deadlock not true - of course any deadlock can not be basically. where ? how ? we already hold loader lock inside DLL entry point and this lock can be acquired recursively. crash really can be (before win8).

or another false:

Call ExitThread. Exiting a thread during DLL detach can cause the loader lock to be acquired again, causing a deadlock or a crash.

  • can cause the loader lock to be acquired again - not can but always
  • causing a deadlock - false - we already hold this lock
  • a crash - no any crash will be, else one false

but which is really will be - thread exit without free loader lock. it became busy forever. as result any new thread creation or exit, any new DLL load or unload, or just ExitProcess call - hung, when try acquire loader lock. so deadlock here really will be, but not during Call ExitThread - latter.

and of course interesting note - the windows itself call LoadLibrary from DllMain - user32.dll always call LoadLibrary for imm32.dll from it entry point (still true and on win10)


but begin from win8 (or win8.1) loader became more smart on handle dependency modules. now 2 is changed

2. call entry points of new loaded (after X) modules or if module yet not initialized.

so in modern windows (8+) for load X[Y<W[Z]>, Z]

//++begin load X
Y_DllMain
  //++begin load W
  Z_DllMain
  W_DllMain
  //--end load W
X_DllMain
// -- end load X

the Z initialization will be moved to W load call graph. as result all will be correct now.

for test this we can build next solution: test.exe[ kernel32, D1< D2[kernel32, msvcrt] >, msvcrt ]

  • D2 import from kernel32 and msvcrt only and export SomeFunc
  • D1 import only from kernel32 and call LoadLibraryW(L"D2") from it entry point, and then call D2.SomeFunc
  • test.exe import from kernel32, D1 and msvcrt

(exactly in this order ! this is critical important - D1 must be before msvcrt in import, for this need set D1 before msvcrt in linker command line)

as result D1 entry point will be called before msvcrt. this is normal - D1 not depends on msvcrt but when D1 load D2 from it entry point, became interesting

code for D2.dll ( /NODEFAULTLIB kernel32.lib msvcrt.lib )

#include <Windows.h>

extern "C"
{
    __declspec(dllimport) int __cdecl sprintf(PSTR buf, PCSTR format, ...);
}

BOOLEAN WINAPI MyEp( HMODULE , DWORD ul_reason_for_call, PVOID )
{
    if (ul_reason_for_call == DLL_PROCESS_ATTACH)
    {
        OutputDebugStringA("D2.DllMain\n");
    }

    return TRUE;
}

INT_PTR WINAPI SomeFunc()
{
    __pragma(message(__FUNCDNAME__))
    char buf[32];
    // this is only for link to msvcrt.dll
    sprintf(buf, "D2.SomeFunc\n");
    OutputDebugStringA(buf);
    return 0;
}

#ifdef _WIN64
#define FuncName "?SomeFunc@@YA_JXZ"
#else
#define FuncName "?SomeFunc@@YGHXZ"
#endif

__pragma(comment(linker, "/export:" FuncName ",@1,NONAME,PRIVATE"))

code for D1.dll ( /NODEFAULTLIB kernel32.lib )

#include <Windows.h>

#pragma warning(disable : 4706)

BOOLEAN WINAPI MyEp( HMODULE hmod, DWORD ul_reason_for_call, PVOID )
{
    if (ul_reason_for_call == DLL_PROCESS_ATTACH)
    {
        OutputDebugStringA("D1.DllMain\n");
        if (hmod = LoadLibraryW(L"D2"))
        {
            if (FARPROC fp = GetProcAddress(hmod, (PCSTR)1))
            {
                fp();
            }
        }
    }

    return TRUE;
}

INT_PTR WINAPI SomeFunc()
{
    __pragma(message(__FUNCDNAME__))
    OutputDebugStringA("D1.SomeFunc\n");
    return 0;
}

#ifdef _WIN64
#define FuncName "?SomeFunc@@YA_JXZ"
#else
#define FuncName "?SomeFunc@@YGHXZ"
#endif

__pragma(comment(linker, "/export:" FuncName ",@1,NONAME"))

code for exe ( /NODEFAULTLIB kernel32.lib D1.lib msvcrt.lib )

#include <Windows.h>

extern "C"
{
    __declspec(dllimport) int __cdecl sprintf(PSTR buf, PCSTR format, ...);
}

__declspec(dllimport) INT_PTR WINAPI SomeFunc();

void ep()
{
    char buf[32];
    // this is only for link to msvcrt.dll
    sprintf(buf, "exe entry\n");
    OutputDebugStringA(buf);
    ExitProcess((UINT)SomeFunc());
}

output for xp:

LDR: D1.dll loaded - Calling init routine
D1.DllMain
Load: D2.dll
LDR: D2.dll loaded - Calling init routine
D2.DllMain
D2.SomeFunc
LDR: msvcrt.dll loaded - Calling init routine
exe entry
D1.SomeFunc

for win7:

LdrpRunInitializeRoutines - INFO: Calling init routine for DLL "D1.dll"
D1.DllMain
Load: D2.dll
LdrpRunInitializeRoutines - INFO: Calling init routine for DLL "D2.DLL"
D2.DllMain
D2.SomeFunc
LdrpRunInitializeRoutines - "msvcrt.dll"
exe entry
D1.SomeFunc

in both case call flow is the same - D2.DllMain called before msvcrt entry point, despite D2[msvcrt]

but on win8.1 and win10 - call flow is another:

LdrpInitializeNode - INFO: Calling init routine for DLL "D1.dll"
D1.DllMain
LdrpInitializeNode - INFO: Calling init routine for DLL "msvcrt.dll"
LdrpInitializeNode - INFO: Calling init routine for DLL "D2.DLL"
D2.DllMain
D2.SomeFunc
exe entry
D1.SomeFunc

the D2 entry point called after msvcrt initialization.

so what is conclusion?

if when module X[Y] is loaded and no not initialized Y in memory - Y_DllMain will be called before X_DllMain. or in another words - if nobody call LoadLibrary(X) (or LoadLibrary(Z[X]) ) from DLL entry point. so if your DLL will be loaded "normal" way (not by call LoadLibrary from DllMain or injected from driver on some dll load event) - you can be sure that crt entry point already called (crt initialized)

more - if you run on win8.1+ - and X[Y] is loaded - Y_DllMain will be always called before X_DllMain.


now about custom /ENTRYPOINT in your dll.

even if you use crt in separate DLLs - some small crt code will be statically linked to your module DllMainCRTStartup - which call your function DllMain (this is not a entry point) by name. so in case dynamic crt - we really have 2 crt parts - main part in separate DLLs and it will be initialized before your DLL entry point is called (if not special case which i describe higher and win7,vista,xp). and small static part (code inside your module). when this static part will be called already full depend from you. this part DllMainCRTStartup do some internal initializations, initialize global objects in your code (initterm) and call DllMain, after it return (on dll detach) call destructors for globals..

if you set custom entry point in DLL - at this point crt in separate DLLs already initialized, but your static crt no (as and global objects). from this custom entry point you will be need call DllMainCRTStartup

这篇关于在哪些情况下,动态 CRT 尚未在调用用户提供的 DllMain 时初始化?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆