Почему Windows требует импорта данных DLL?

В Windows данные могут быть загружены из DLL, но для этого требуется косвенный указатель в таблице адресов импорта. В результате компилятор должен знать, импортируется ли объект, к которому осуществляется доступ, из библиотеки DLL, используя спецификатор типа __declspec(dllimport).

Это прискорбно, потому что это означает, что заголовок библиотеки Windows, предназначенной для использования в качестве статической или динамической библиотеки, должен знать, с какой версией библиотеки связана программа. Это требование не применимо к функциям, которые прозрачно эмулируются для библиотек DLL с функцией-заглушкой, вызывающей реальную функцию, адрес которой хранится в таблице адресов импорта.

В Linux динамический компоновщик (ld.so) копирует значения всех связанных объектов данных из общего объекта в частную отображаемую область для каждого процесса. Это не требует косвенного обращения, поскольку адрес частной отображаемой области является локальным для модуля, поэтому его адрес определяется при компоновке программы (а в случае исполняемых файлов, не зависящих от позиции, используется относительная адресация).

Почему Windows не делает то же самое? Есть ли ситуация, когда DLL может загружаться более одного раза и, следовательно, требовать нескольких копий связанных данных? Даже если бы это было так, это было бы неприменимо к данным только для чтения.

Кажется, что MSVCRT решает эту проблему, определяя макрос _DLL при нацеливании на динамическую библиотеку времени выполнения C (с флагом /MD или /MDd), а затем используя это во всех стандартных заголовках для условного объявления всех экспортированных символов с помощью __declspec(dllimport). Я полагаю, вы могли бы повторно использовать этот макрос, если бы вы поддерживали статическое связывание только при использовании статической среды выполнения C и динамическое связывание при использовании динамической среды выполнения C.

Использованная литература:

LNK4217 - WebLog Русса Келдорфа (выделено мной)

__declspec(dllimport) can be used on both code and data, and its semantics are subtly different between the two. When applied to a routine call, it is purely a performance optimization. For data, it is required for correctness.

[...]

Importing data

If you export a data item from a DLL, you must declare it with __declspec(dllimport) in the code that accesses it. In this case, instead of generating a direct load from memory, the compiler generates a load through a pointer, resulting in one additional indirection. Unlike calls, where the linker will fix up the code correctly whether the routine was declared __declspec(dllimport) or not, accessing imported data requires __declspec(dllimport). If omitted, the code will wind up accessing the IAT entry instead of the data in the DLL, probably resulting in unexpected behavior.

Импорт в приложение с использованием __declspec (dllimport)

Using __declspec(dllimport) is optional on function declarations, but the compiler produces more efficient code if you use this keyword. However, you must use `__declspec(dllimport) for the importing executable to access the DLL's public data symbols and objects.

Импорт данных с использованием __declspec (dllimport)

When you mark the data as __declspec(dllimport), the compiler automatically generates the indirection code for you.

Импорт с использованием файлов DEF (интересные исторические заметки о прямом доступе к IAT)

Как поделиться данными в моей DLL с приложением или с другими DLL?

By default, each process using a DLL has its own instance of all the DLLs global and static variables.

Предупреждение средств компоновщика LNK4217

Что произойдет, если вы неправильно укажете dllimport? (похоже, не знает семантику данных)

Как экспортировать данные из библиотеки DLL?

Особенности библиотеки CRT (документирует макрос _DLL)

Почему Windows не делает то же самое? Есть ли ситуация, когда DLL может загружаться более одного раза и, следовательно, требовать нескольких копий связанных данных? Я думаю, что читал об "улучшениях производительности" в первые дни Windows.
movcmpret 09.11.2018 15:09

@movcmpret Способ Windows строго менее эффективен. Это косвенный доступ (через IAT) вместо прямого доступа к адресу, известному во время связывания (содержимое которого инициализируется во время загрузки).

J Alan 09.11.2018 15:13

@JAlan - адрес внутри внешнего модуля не может быть известен во время компоновки. доступ к данным во внешнем модуле через указатель на него - единственный способ. окна эффективны. однако ваш вопрос мне непонятен

RbMm 09.11.2018 15:33

Косвенный переход через указатель IAT или dllimport предотвращает изменение кодовых страниц, когда целевая DLL не может быть загружена по ее базовому адресу времени компоновки. Это имело большое значение, когда ему приходилось работать с 16 МБ оперативной памяти. Все равно не больно.

Hans Passant 09.11.2018 15:35
Переполнение стека Это интересная ветка (объясняющая, почему окна так работают. Относится к @HansPassant).
movcmpret 09.11.2018 15:40

@RbMm Данные копируются из внешнего модуля в локальный модуль при загрузке внешнего модуля. См. Цитируемую ссылку в сообщении: «По умолчанию каждый процесс, использующий DLL, имеет собственный экземпляр всех глобальных и статических переменных DLL».

J Alan 09.11.2018 15:46

Я здесь на заборе: можно ли ответить на этот вопрос? это "почему?" об этом предмете в основном к мнению? или есть технический ответ, который может дать специалист? даже если это технический вопрос, является ли Stackoverflow сайтом Лучший StackExchange для этого типа вопросов? Моя цель состоит в том, чтобы этот вопрос был перед лучшей аудиторией, которая ответит на него; HTH

landru27 09.11.2018 15:47

@HansPassant Не имеет значения, куда загружена DLL, потому что данные копируются в модуль, загружающий ее, по адресу, известному ему во время компоновки. См. Мой предыдущий комментарий.

J Alan 09.11.2018 15:50

@ landru27 Я ищу технический ответ, но я бы принял исторический ответ вместо этого. Это определенно лучший сайт для этого, потому что StackOverflow широко занимается C, Win32 API, компиляторами и операционными системами. Я также думаю, что есть смысл оставить этот вопрос, потому что я не нашел каких-либо ссылок, в которых обсуждается это конкретное ограничение.

J Alan 09.11.2018 15:52

Нет, ничего не копируется, вы обращаетесь к данным в DLL. Но данные нельзя переместить, поэтому это нужно делать с помощью указателя. Вот почему dllimport является жестким требованием для экспортируемых данных, он сообщает компилятору всегда использовать указатель, даже если ваш код этого не делает.

Hans Passant 09.11.2018 15:58

Думаю, я наконец понял. Данные из DLL сопоставляются каждому процессу с копией на карту памяти для записи, вместе с исполняемым текстовым сегментом только для чтения и т. д. Динамический компоновщик загружает всю DLL в непрерывную область пространства, поэтому он не знает ее адрес во время компоновки. Это верно? Похоже, что он может создать вторую карту по известному адресу, охватывающую только сегменты данных, с тщательной компоновкой адреса. Вы знаете, как работает Linux в этом отношении? Или где найти дополнительную информацию?

J Alan 09.11.2018 16:08
Данные копируются из внешнего модуля в локальный модуль при загрузке внешнего модуля. - конечно нет. ничего не копируется. это вообще невозможно. в какое место скопировал ?!
RbMm 09.11.2018 16:32

Обратите внимание, что даже на целевых объектах ELF (таких как Linux) существует такая же структура таблицы (называемая GOT [глобальная таблица смещения]). Он используется, когда общие объекты относятся к любым объектам внешних данных. Просто у ELF есть оптимизация для общего случая, когда исполняемый файл ссылается на объект данных, объявленный в библиотеке.

fuz 10.11.2018 13:05

Я голосую за то, чтобы закрыть этот вопрос как не по теме, потому что это вопрос разработки программного обеспечения, а не программирования, и его следует задавать на softwareengineering.stackexchange.com

Rob 10.11.2018 13:58
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
14
745
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Linux и Windows используют разные стратегии для доступа к данным, хранящимся в динамических библиотеках.

В Linux неопределенная ссылка на объект преобразуется в библиотеку во время компоновки. Компоновщик определяет размер объекта и резервирует для него место в сегменте .bss или .rdata исполняемого файла. При выполнении динамический компоновщик (ld.so) преобразовывает символ в динамическую библиотеку (снова) и копирует объект из динамической библиотеки в память процесса.

В Windows неопределенная ссылка на объект преобразуется в библиотеку импорта во время компоновки, и для нее не резервируется место. Когда модуль выполняется, динамический компоновщик преобразует символ в динамическую библиотеку и создает копию на карте памяти для записи в процессе, поддерживаемую совместно используемым сегментом данных в динамической библиотеке.

Преимущество копирования на карту памяти для записи заключается в том, что если связанные данные не изменяются, они могут использоваться другими процессами. На практике это незначительное преимущество, которое значительно увеличивает сложность как инструментальной цепочки, так и программ, использующих динамические библиотеки. Для фактически написанных объектов это всегда менее эффективно.

Я подозреваю, хотя у меня нет доказательств, что это решение было принято для конкретного и теперь уже устаревшего варианта использования. Возможно, было обычной практикой использовать большие (в то время) объекты только для чтения в динамических библиотеках на 16-битной Windows (в официальных программах Microsoft или в других случаях). В любом случае, я сомневаюсь, что у кого-то в Microsoft есть опыт и время, чтобы изменить это сейчас.

Чтобы исследовать проблему, я создал программу, которая записывает объект из динамической библиотеки. Он записывает один байт на страницу (4096 байт) в объект, затем записывает весь объект, затем повторяет начальную запись одного байта на страницу. Если объект зарезервирован для процесса до вызова main, первый и третий цикл должны занять примерно одинаковое время, а второй цикл должен занять больше времени, чем оба. Если объект является копией на карте записи в динамическую библиотеку, первый цикл должен занимать не меньше времени, чем второй, а третий должен занимать меньше времени, чем оба.

Результаты согласуются с моей гипотезой, и анализ разборки подтверждает, что Linux обращается к данным динамической библиотеки по адресу времени компоновки относительно счетчика программы. Удивительно, но Windows не только косвенно обращается к данным, указатель на данные и их длина перезагружаются из таблицы адресов импорта на каждой итерации цикла с включенной оптимизацией. Это было протестировано с Visual Studio 2010 в Windows XP, поэтому, возможно, что-то изменилось, хотя я не думаю, что это изменилось.

Вот результаты для 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.

Raymond Chen 10.11.2018 15:28

Это именно та разница, которую я искал, но не нашел. Я действительно искал способ связаться с вами или дать вам эту тему в качестве предложения, прежде чем публиковать это, но я не мог найти способа. Спасибо за ответ!

J Alan 10.11.2018 15:37

Если функция в динамической библиотеке Linux хочет получить доступ к своей собственной глобальной переменной, как она это делает? Предположим, в вашей библиотеке также есть функция reset_libdat, которая устанавливает значение libdat_dat обратно в ноль. Есть несколько копий этого libdat_dat (по одной на каждого клиента). Как он узнает, какой сбросить? Сбрасывает их все? Как найти их всех?

Raymond Chen 10.11.2018 16:26

Я проверил разборку, и функция (reset_libdat) вычисляет адрес локальной копии модуля, используя относительную адресацию программного счетчика. Дизассемблирование комментируется с помощью `<libdat_dat @@ Base-0x28>`. Я не знаю, как это работает, но для этого используются некоторые спецификации. Я попробую разобраться в этом позже, хотя это будет хороший новый вопрос.

J Alan 10.11.2018 17:03

Означает ли это (в Linux), что если A и B оба ссылаются на C, а затем C ссылаются на D, то каждый из A и B получает отдельную копию данных C, но они разделяют копию D (через общую копию модуля C). Модель Windows заключается в том, что программа и все ее библиотеки DLL действуют так, как будто все они статически связаны в одну гигантскую программу.

Raymond Chen 10.11.2018 17:24

@RaymondChen Это неверно. Конечно, оба получают одну и ту же копию errno! Общие библиотеки обращаются к глобальным переменным через таблицу GOT, которая очень похожа на структуру в Windows.

fuz 10.11.2018 22:22

Но это не то, о чем говорится в этом ответе. В этом ответе говорится, что данные копируются в каждого клиента, и asm поддерживает это. Клиент получает свою личную копию libdat_dat.

Raymond Chen 10.11.2018 22:35

@fuz правильный. Я протестировал ваш пример ABCD, и все библиотеки используют одну копию всех данных, которые хранятся в сегменте .bss исполняемого файла. Исполняемый файл обращается к нему напрямую. Каждая динамическая библиотека обращается к ней косвенно через GOT, специфичный для каждой библиотеки. Динамический компоновщик инициализирует GOT каждой динамической библиотеки, создавая карту частной памяти в процессе по адресу, по которому загружена динамическая библиотека GOT, а затем записывает в нее адреса фактических данных в сегменте .bss.

J Alan 11.11.2018 04:53

@JAlan Ага, значит предложение «Компоновщик определяет размер объекта и резервирует для него место в сегменте .bss или .rdata модуля». было неверно. Он хранится в .bss / .rdata в исполняемый файл. Тест не продемонстрировал этого различия, потому что модуль импорта было исполняемый файл.

Raymond Chen 11.11.2018 16:52

@RaymondChen Это верно. Я зафиксировал ответ. Когда я изначально писал это, я не понимал, что динамические библиотеки разделяют данные и что это соглашение важно.

J Alan 12.11.2018 02:31

Другие вопросы по теме