【问题标题】:Why does Windows require DLL data to be imported?为什么 Windows 需要导入 DLL 数据?
【发布时间】:2019-04-13 02:41:21
【问题描述】:

在 Windows 上可以从 DLL 加载数据,但它需要通过导入地址表中的指针进行间接寻址。因此,编译器必须知道是否正在使用 __declspec(dllimport) 类型说明符从 DLL 导入正在访问的对象。

这很不幸,因为这意味着设计用作静态库或动态库的 Windows 库的标头需要知道程序链接到哪个版本的库。此要求不适用于函数,这些函数是透明模拟的,具有调用真实函数的存根函数的 DLL,其地址存储在导入地址表中。

在 Linux 上,动态链接器 (ld.so) 将所有链接数据对象的值从共享对象复制到每个进程的私有映射区域。这不需要间接寻址,因为私有映射区域的地址对于模块来说是本地的,所以它的地址是在程序链接时决定的(在位置无关的可执行文件的情况下,使用相对寻址)。

为什么 Windows 不这样做?是否存在 DLL 可能被多次加载的情况,因此需要链接数据的多个副本?即使是这样,它也不适用于只读数据。

似乎 MSVCRT 通过在针对动态 C 运行时库(使用 /MD 或 /MDd 标志)时定义 _DLL 宏来处理此问题,然后在所有标准头文件中使用它来有条件地声明所有导出的带有__declspec(dllimport) 的符号。如果您只在使用静态 C 运行时时支持静态链接并在使用动态 C 运行时时支持动态链接,我想您可以重用此宏。

参考资料:

LNK4217 - Russ Keldorph's WebLog(强调我的)

__declspec(dllimport) 可用于代码和数据,两者的语义略有不同。当应用于例程调用时,它纯粹是一种性能优化。对于数据,它是正确性所必需的。

[...]

导入数据

如果从 DLL 导出数据项,则必须在访问它的代码中使用 __declspec(dllimport) 声明它。在这种情况下,编译器不会从内存直接加载,而是通过指针生成加载,从而产生一个额外的间接访问。与调用不同,在调用中,无论例程是否声明为__declspec(dllimport),链接器都会正确修复代码,访问导入的数据需要__declspec(dllimport)。 如果省略,代码最终会访问 IAT 条目而不是 DLL 中的数据,可能会导致意外行为。

Importing into an Application Using __declspec(dllimport)

在函数声明中使用__declspec(dllimport) 是可选的,但如果使用此关键字,编译器会生成更高效的代码。但是,您必须使用 `__declspec(dllimport) 来导入可执行文件以访问 DLL 的公共数据符号和对象。

Importing Data Using __declspec(dllimport)

当您将数据标记为 __declspec(dllimport) 时,编译器会自动为您生成间接代码。

Importing Using DEF Files(关于直接访问 IAT 的有趣历史记录)

How do I share data in my DLL with an application or with other DLLs?

默认情况下,每个使用 DLL 的进程都有自己的所有 DLL 全局和静态变量的实例。

Linker Tools Warning LNK4217

What happens when you get dllimport wrong?(好像不知道数据语义)

How do I export data from a DLL?

CRT Library Features(记录 _DLL 宏)

【问题讨论】:

  • 为什么 Windows 不这样做呢?是否存在 DLL 可能被多次加载的情况,因此需要链接数据的多个副本? 我想我在 window 的早期读到过“性能改进”。
  • @movcmpret Windows 方式的效率非常低。它是间接访问(通过 IAT),而不是直接访问链接时已知的地址(其内容在加载时初始化)。
  • @JAlan - 在链接时无法知道外部模块内部的地址。通过指针访问外部模块中的数据是唯一的方法。窗户是有效的。但是你的问题对我来说还不清楚
  • 当目标 DLL 无法在其链接时基地址加载时,通过 IAT 或 dllimport 指针的间接跳转可防止修改代码页。当它必须在 16MB 的内存中运行时,这很重要。还是不疼。
  • 不,没有任何内容被复制,您正在处理 DLL 中的数据。但是数据是不可重定位的,所以它必须通过指针来完成。这就是为什么 dllimport 是导出数据的硬性要求,它告诉编译器始终使用指针,即使您的代码不使用。

标签: c windows dll dllimport


【解决方案1】:

Linux 和 Windows 使用不同的策略来访问存储在动态库中的数据。

在 Linux 上,对对象的未定义引用在链接时解析为库。链接器查找对象的大小并在可执行文件的.bss 或.rdata 段中为其保留空间。执行时,动态链接器 (ld.so) 将符号解析为动态库(再次),并将对象从动态库复制到进程的内存。

在 Windows 上,对对象的未定义引用在链接时解析为导入库,并且没有为其保留空间。执行模块时,动态链接器将符号解析为动态库,并在进程中创建一个写入内存映射副本,由动态库中的共享数据段支持。

copy on write memory map 的优点是如果链接的数据没有改变,那么它可以与其他进程共享。在实践中,这是一个微不足道的好处,它大大增加了工具链和使用动态库的程序的复杂性。对于实际编写的对象,这总是效率较低。

我怀疑,虽然我没有证据,但这个决定是针对一个特定的并且现在已经过时的用例做出的。在 16 位 Windows 上(在官方 Microsoft 程序或其他程序中)使用动态库中的大型(当时)只读对象可能是一种常见的做法。无论哪种方式,我怀疑 Microsoft 的任何人现在都有专业知识和时间来改变它。

为了调查这个问题,我创建了一个从动态库写入对象的程序。它在对象中每页写入一个字节(4096 字节),然后写入整个对象,然后重试每页写入的初始一个字节。如果在调用main 之前为进程保留对象,则第一个和第三个循环的时间应该大致相同,而第二个循环的时间应该比两者都长。如果对象是写入映射到动态库的副本,则第一个循环所用的时间至少应与第二个一样长,而第三个循环所用的时间应少于两者。

结果与我的假设一致,反汇编分析证实 Linux 在链接时间地址访问动态库数据,相对于程序计数器。令人惊讶的是,Windows 不仅间接访问数据,每次循环迭代都会从导入地址表中重新加载指向数据的指针及其长度,并启用优化。这是在 Windows XP 上使用 Visual Studio 2010 进行测试的,所以也许事情已经改变了,虽然我不认为它已经改变了。

以下是 Linux 的结果:

$ dd bs=1M count=16 if=/dev/urandom of=libdat.dat
$ xxd -i libdat.dat libdat.c
$ gcc -O3 -g -shared -fPIC libdat.c -o libdat.so
$ gcc -O3 -g -no-pie -L. -ldat dat.c -o dat
$ LD_LIBRARY_PATH=. ./dat
local          =          0x1601060
libdat_dat     =           0x601040
libdat_dat_len =           0x601020
dirty=      461us write=    12184us retry=      456us
$ nm dat
[...]
0000000000601040 B libdat_dat
0000000000601020 B libdat_dat_len
0000000001601060 B local
[...]
$ objdump -d -j.text dat
[...]
  400693:   8b 35 87 09 20 00       mov    0x200987(%rip),%esi        # 601020 <libdat_dat_len>
[...]
  4006a3:   31 c0                   xor    %eax,%eax                  # zero loop counter
  4006a5:   48 8d 15 94 09 20 00    lea    0x200994(%rip),%rdx        # 601040 <libdat_dat>
  4006ac:   0f 1f 40 00             nopl   0x0(%rax)                  # align loop for efficiency
  4006b0:   89 c1                   mov    %eax,%ecx                  # store data offset in ecx
  4006b2:   05 00 10 00 00          add    $0x1000,%eax               # add PAGESIZE to data offset
  4006b7:   c6 04 0a 00             movb   $0x0,(%rdx,%rcx,1)         # write a zero byte to data
  4006bb:   39 f0                   cmp    %esi,%eax                  # test loop condition
  4006bd:   72 f1                   jb     4006b0 <main+0x30>         # continue loop if data is left
[...]

以下是 Windows 的结果:

$ cl /Ox /Zi /LD libdat.c /link /EXPORT:libdat_dat /EXPORT:libdat_dat_len
[...]
$ cl /Ox /Zi dat.c libdat.lib
[...]
$ dat.exe # note low resolution timer means retry is too small to measure
local          =           0041EEA0
libdat_dat     =           1000E000
libdat_dat_len =           1100E000
dirty=    20312us write=     3125us retry=        0us
$ dumpbin /symbols dat.exe
[...]
        9000 .data
        1000 .idata
        5000 .rdata
        1000 .reloc
       17000 .text
[...]
$ dumpbin /disasm dat.exe
[...]
  004010BA: 33 C0              xor         eax,eax # zero loop counter
[...]
  004010C0: 8B 15 8C 63 42 00  mov         edx,dword ptr [__imp__libdat_dat] # store data pointer in edx
  004010C6: C6 04 02 00        mov         byte ptr [edx+eax],0 # write a zero byte to data
  004010CA: 8B 0D 88 63 42 00  mov         ecx,dword ptr [__imp__libdat_dat_len] # store data length in ecx
  004010D0: 05 00 10 00 00     add         eax,1000h # add PAGESIZE to data offset
  004010D5: 3B 01              cmp         eax,dword ptr [ecx] # test loop condition
  004010D7: 72 E7              jb          004010C0 # continue loop if data is left
[...]

这是用于两个测试的源代码:

#include <stdio.h>
#ifdef _WIN32
#include <windows.h>

typedef FILETIME time_l;

time_l time_get(void) {
    FILETIME ret; GetSystemTimeAsFileTime(&ret); return ret;
}

long long int time_diff(time_l const *c1, time_l const *c2) {
    return 1LL*c2->dwLowDateTime/100-c1->dwLowDateTime/100+c2->dwHighDateTime*100000-c1->dwHighDateTime*100000;
}
#else
#include <unistd.h>
#include <time.h>
#include <stdlib.h>

typedef struct timespec time_l;

time_l time_get(void) {
    time_l ret; clock_gettime(CLOCK_MONOTONIC, &ret); return ret;
}

long long int time_diff(time_l const *c1, time_l const *c2) {
    return 1LL*c2->tv_nsec/1000-c1->tv_nsec/1000+c2->tv_sec*1000000-c1->tv_sec*1000000;
}
#endif

#ifndef PAGESIZE
#define PAGESIZE 4096
#endif

#ifdef _WIN32
#define DLLIMPORT __declspec(dllimport)
#else
#define DLLIMPORT
#endif

extern DLLIMPORT unsigned char volatile libdat_dat[];
extern DLLIMPORT unsigned int libdat_dat_len;
unsigned int local[4096];

int main(void) {
    unsigned int i;
    time_l t1, t2, t3, t4;
    long long int d1, d2, d3;

    t1 = time_get();

    for(i=0; i < libdat_dat_len; i+=PAGESIZE) {
        libdat_dat[i] = 0;
    }

    t2 = time_get();

    for(i=0; i < libdat_dat_len; i++) {
        libdat_dat[i] = 0xFF;
    }

    t3 = time_get();

    for(i=0; i < libdat_dat_len; i+=PAGESIZE) {
        libdat_dat[i] = 0;
    }

    t4 = time_get();

    d1 = time_diff(&t1, &t2);
    d2 = time_diff(&t2, &t3);
    d3 = time_diff(&t3, &t4);

    printf("%-15s= %18p\n%-15s= %18p\n%-15s= %18p\n", "local", local, "libdat_dat", libdat_dat, "libdat_dat_len", &libdat_dat_len);
    printf("dirty=%9lldus write=%9lldus retry=%9lldus\n", d1, d2, d3);

    return 0;
}

我真诚地希望其他人能从我的研究中受益。感谢阅读!

【讨论】:

  • Windows 设计意味着如果进程中的两个模块链接到同一个 DLL,则 DLL 数据是共享的。例如,A.DLL 和 B.DLL 都使用 LIBC.DLL。 A 设置 errno = 3。B 读取 errno 并得到 3。Linux 版本为 A.DLL 和 B.DLL 提供了它们自己单独的 errno 副本。
  • 这正是我一直在寻找但没有找到的差异。实际上,在发布此内容之前,我一直在寻找与您联系或给您这个主题作为建议的方法,但我找不到方法。感谢您的回复!
  • 如果Linux动态库中的一个函数想要访问自己的全局变量,它是怎么做的呢?假设您的库还有一个函数reset_libdat 将libdat_dat 设置为零。 libdat_dat 有多个副本(每个客户端一个)。它怎么知道要重置哪一个?它会重置所有这些吗?它如何找到所有这些?
  • 这是否也意味着(在 Linux 上)如果 A 和 B 都链接到 C,然后 C 链接到 D,那么 A 和 B 各自获得 C 数据的单独副本,但它们共享一个D 的副本(通过 C 模块的共享副本)。 Windows 模型是一个程序及其所有 DLL 的行为就像它们都被静态链接到一个巨大的程序中一样。
  • @JAlan Aha,所以句子“链接器找到对象的大小并在模块的.bss 或.rdata 段中为其保留空间。”不正确。它存储在可执行文件的.bss/.rdata中。测试没有证明这种区别,因为导入模块是可执行文件。
猜你喜欢
  • 1970-01-01
  • 2021-07-30
  • 1970-01-01
  • 1970-01-01
  • 2016-12-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多