Эта заметка или статья является продолжением цикла о формате
Новых исполняемых (ориг. "NE") файлов для Windows 1.x-3x и OS/2 1x.
В этот раз речь пойдет о таблицах резидентных и не резидентных имён,
будет разбор типов экпортируемых записей и много интересных наблюдений
за Microsoft LINK.EXE.

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

К сожалению, это не совсем правда, поэтому я не буду дальше
говорить о том, что "только у динамических библиотек присутствует экспорт."

Обзор | Экспортируемые имена

Этот документ будет очень большим, потому что моя задача это задача попытаться описать
процесс и хранение экспорта для NE сегментных программ.

Сначала, стоит определить только два типа экспортируемых процедур в данном формате сегментации.

  • Резидентные процедуры;

  • Не резидентные процедуры.

Можете вспомнить и провести аналогию с устройством PC-DOS/MS-DOS в оперативной памяти;
Системные важные части MS-DOS (IO.SYS, MSDOS.SYS, CONFIG.SYS), а так же BIOS и векторы прерываний
находились в "Резидентной" области. А программы, которые запускались - всегда ложились в виде структур
в Транзитивную область. Можно сказать "Не резидентную".

Я полагаю, Microsoft следовали похожей логике, когда этот формат разрабатывался.

Резидентные имена | Обзор

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

Это оригинал, а предыдущий абзац это дословный перевод этого отрывка из документации:

The resident-name table follows the resource table, and contains this 
module's name string and resident exported procedure name strings. The 
first string in this table is this module's name. These name strings 
are case-sensitive and are not null-terminated.

The Resident names table has a following format:

Таблица имеет следующий формат:

BYTE        Count of bytes in ASCII string

BYTE array  ASCII not terminated string of procedure/resource name

WORD        Procedure or Resource Ordinal

Теперь воссоздадим структуру только одной записи в этой таблице.
Сама таблица это массив таких записей.

#[repr(C)]
#[derive(Clone, Debug, Eq, Pad)]
struct ResidentNameRecord {
    pub r_cbname: Lu8,
    pub r_sname: Vec<u8>, // for real length of slice will be r_cbname
    pub r_ordinal: Lu16,  // this is what I want to describe next in this note
}

Не сложно. Эту таблицу можно прочитать и без десериализации структур
взятых из выбранной области. Чтение Pascal-строк - не трудно реализовать.
А строки в таблице будут ASCII, не смотря на настойчивость IBM использовать
другую кодовую страницу.

Резидентные имена | LINK.EXE ломает мозг

На самом деле, Microsoft LINK.EXE всегда делает записи в таблице только высокого регистра. (но ПОЧЕМУ?...)

Если вы попробуете использовать LINK.EXE для компановки
Win16-приложения или модуля - все резидентные функции,
внезапно после обработки станут переписаны только в высоком регистре.
Плагин Sunflower делает одну таблицу для всех имен, но помечает
какая запись к какой таблице относится.
Давайте вызовем его для доказательства, что я не вру.
Я выбрал компонент из Windows 1.01, а именно приложение "Часы".
Выбрал я его по нескольким простым причинам

  • "Это графическое приложение, а именно Win16-приложение";

  • Это именно приложение, а не подключаемая в рантайме библиотека;

  • Это небольшой модуль, и его таблицу не придется урезать.

Count:1

Name:s

Ordinal:2

NameTable:s

5

CLOCK

@0

[Resident]

5

ABOUT

@1

[Resident]

12

CLOCKWNDPROC

@2

[Resident]

Если вы приглядитесь внимательнее в таблицу - вы увидите PascalCase,
наименования но переписанные LINK.EXE только в верхнем регистре.
На самом деле это довольно знакомое наименование для тех, кто
пару раз точно работал с Win32. Это ClockWndProc которая возвращает дескриптор окна.
Правда в данном случае он 16-разрядный, но все еще знакомый HWND.

Ординалы процедур начинаются с единицы. Специальный ординал @0 хранит
это запись о названии модуля. Все эти записи приходят из специального .def файла,
который использует компилятор и линкер.

 Псевдо-DEF файл
+-------------------------+
| type=EXE;               | Нужен клипилятору
| name=clock;             | и линкеру                 
| About @1                |-------------->[файл]CLOCK.EXE
| ClockWndProc @2;        | чтобы создать   |
| ...                     |                 |
+-------------------------+                 |
            +-------------------------------+
            |
            |
     байты CLOCK.EXE        
    +-------+--------+
    | MZ Header      |
    | DOS stub       |
    | NE Header      |--[относительно заголовка ]----+
    | ...            |                               |
    | Resident names |-------> [09_CLOCK.EXE_0,05_ABOUT_1,12_CLOCKWNDPROC_2]
    | ...            |

Интересная идея, я полагаю, что ABOUT это запись ресурса
потому что в часах определенно есть диалоговое окно "О программе",
а оно собирается специально из файла-ресурса.

Не резидентные имена | Обзор

Таблица нерезедентных имен содержит имена экспортируемых процедур,
которые сам модуль не использует. Эти функции полностью предоставляются
другим модулям, которые их будут вызывать.

Другими словами, как это описано в документации Microsoft:

The nonresident-name table follows the entry table, and contains a 
module description and nonresident exported procedure name strings. 
The first string in this table is a module description. These name 
strings are case-sensitive and are not null-terminated.

Таблица не резидентных имен следует за таблицей точек входа (ориг. EntryTable)
и содержит описание модуля, имена экспортируемых процедур.
Первая строка в таблице это описание модуля. Эти строки чувствительны к регистру и не имеют
завершающего символа (имеется ввиду, что они тоже PascalStrings, а не Си-строки).

Таблица не резидентных имен похожа по формату на таблицу резидентных имен,
но всё-равно сделаю образ структуры.

struct NonResidentRecord {
    pub n_cbname: Lu8,
    pub n_sname: Vec<u8>, // n_cbname
    pub n_ordinal: Lu8,
}

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

Не резидентные имена | LINK.EXE делает странные вещи

Вы видели это? Прочтите, что пишет Microsoft внимательнее.
Имена в этой таблице все так же чувствительны к регистру.
Но в понимании линкера это снова неправда! Почему?
Линкер Microsoft снова исправляет все имена в только верхний регистр. И это сырые ASCII
строки, которые как были так и остались в файле после компановки.

Я не видел ещё скомпанованных Win16 приложений где бы записи процедур
были записаны в первозданном виде.
Но, если забегать вперёд, то LNK386.EXE и другие инстурменты IBM и Microsoft,
но уже для LE/LX исполняемых форматов процедуры держатся правильно.
Доказать, что Watcom 1.8 не искажает имена функций, к сожалению пока что я доказать не могу,
но обязательно сделаю репозиторий с анализом связи во времени выполнения между изолированными
библиотеками, собранными WLINK.

Пришло время выбрать подопытного, кто бы смог показать нерезидентные записи.
Самый простой выбор, который напрашивается это база Windows оболочки.

  • KERNEL отвечает за управление памятью и ресурсами оболочки;

  • USER предоставляет интерфейс взаимодействия для пользовательских программ;

  • GDI предоставляет интерфейс графических объектов.

Откроем Sunflower вместе с KERNEL.EXE, позаимствованным из Microsoft Windows 3.10.

Count

Name

Ordinal

Name Table

50

Microsoft Windows Kernel Interface for 2.x and 3.x

@0

[Not resident]

12

GLOBALUNLOCK

@19

[Not resident]

12

ISTASKLOCKED

@122

[Not resident]

12

GETLPERRMODE

@99

[Not resident]

7

LSTRCPY

@88

[Not resident]

7

_LCLOSE

@81

[Not resident]

10

GLOBALLOCK

@18

[Not resident]

14

LOCALCOUNTFREE

@161

[Not resident]

Я ограничил эту таблицу. Она очень большая. Но здесь все имена все так же
держатся только в высоком регистре.

Но как экспорты попадают в таблицы? | Внутри старого Borland проекта

Чтобы доказать себе свои слова, я нашел источники Win16 приложения.

; SYSVALS.DEF module definition file
; Made: Charles Petzold
;------------------------------------

NAME           SYSVALS   WINDOWAPI

DESCRIPTION    'System Values Display (C) Charles Petzold, 1988' 

PROTMODE
HEAPSIZE       1024
STACKSIZE      8192

EXPORTS        ClientWndProc

И специально вызову отчет о SYSVALS.EXE, который ни разу не изменялся.

Count

Name

Ordinal

Name Table

47

System Values Display (C) Charles Petzold, 1988

@0

[Not resident]

7

SYSVALS

@0

[Resident]

13

CLIENTWNDPROC

@1

[Resident]

В таблице после линковки процедура ClientWndProc изменилась регистром на CLIENTWNDPROC,
а индекс процедуры увеличился на 1, как и положено, согласно исходному файлу с определениями.

EntryTable (или таблица входных точек) | Обзор

В начале, я предупреждал, что это будет очень долго.
После всего, что я знаю, наконец-то можно перейти ко второй части.

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

Главные характеристики таблицы - это её относительное положение e_enttab
и количество пакетов - e_cbent. Это не количество байт, как говорит
Венгерская нотация, а количество мешочков (англ. "bundles") из которых состоит таблица.

// u16 just because value of EntryTable offset
// guarantees no data turncation. Using of u32 is redundant.
let real_enttab: u16 = e_lfanew + e_enttab;

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

Таблица входных точек разделяется на мешочки содержащие группы
входных точек, отличающиеся какими-то характеристиками.

Время посмотреть на часть документа Microsoft об этом.

The entry table follows the imported-name table. This table contains 
bundles of entry-point definitions. Bundling is done to save space in 
the entry table. The entry table is accessed by an ordinal value. 
ordinal number one is defined to index the first entry in the entry 
table. To find an entry point, the bundles are scanned searching for a 
specific entry point using an ordinal number. The ordinal number is 
adjusted as each bundle is checked. When the bundle that contains the 
entry point is found, the ordinal number is multiplied by the size of 
the bundle's entries to index the proper entry. 
The linker forms bundles in the most dense manner it can, under the 
restriction that it cannot reorder entry points to improve bundling. 
The reason for this restriction is that other .EXE files may refer to 
entry points within this bundle by their ordinal number.

Теперь перевод:
Таблица входных точек следует за импортируемыми именами.
Она содержит пакеты входных точек. Доступ к процедуре допускается
по её ординалу. Ординал @1 говорит, что это первая запись
в таблице входных точек.

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

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

Теперь структура таблицы:

 
BYTE    Number of entries in this bundle. All records in one bundle 
        are either moveable or refer to the same fixed segment. A zero 
        value in this field indicates the end of the entry table. 

BYTE    Segment indicator for this bundle. This defines the type of 
        entry table entry data within the bundle. There are three 
        types of entries that are defined. 
            0x00 = Unused entries. There is no entry data in an unused 
            bundle. The next bundle follows this field. This is 
            used by the linker to skip ordinal numbers. 
            
            0x01-0xFE = Segment number for fixed segment entries. A fixed 
            segment entry is **3 bytes long** and has the following 
            format. (Fix up that size in your head. This information very important later)

            0xFF = Moveable segment entries. The entry data contains the 
            segment number for the entry points. A moveable segment 
            entry is **6 bytes long** and has the following format. 
            (Fix up this size too. This extremely needs little later.)

То, что было описано выше это заголовок каждого пакета таблицы входных точек.
Следующая структура, которая описывается в документации называется фиксированная точка.
Индикатор входной точки измеряется от 0x00 до 0xFE.

BYTE    Flag word. 
        0x01 = Set if the entry is exported. 
        0x02 = Set if the entry uses a global (shared) data 
               segments. 

The first assembly-language instruction in the 
entry point prologue must be "MOV AX,data 
segment number". This may be set only for 
SINGLEDATA library modules. 

WORD    Offset within segment to entry point.

Если индикатор сегмента равен 0xFF, это перемещаемая точка.
И её запись в два раза больше.

BYTE    Flag word. 
        01h = Set if the entry is exported. 
        02h = Set if the entry uses a global (shared) data 
              segments. 

INT 0x3F. 

BYTE    Segment number. 

WORD    Offset within segment to entry point.

Интересное в этой схеме - наличие сырой опкод инструкции.
Пришло время создать структуру.

struct EntryBundle {
    pub e_entries_count: u8,
    pub e_indicator: u8,
}

Дальше всё строго зависит от индикатора сегмента. Представьте, что
сама структура пакета это массив похожих друг на друга элементов:


Entry Bundle #1
+-----------------+
| entries count <------count=2
| seg indicator   |<---type=FIXED
|+---------------+|       ||
|| flag=export   ||<-------+ This entry is FIXED
|| data=shared   ||       |  Entry @1 in this bundle is @1 in whole entry table.
|| seg=0x02      ||       |  This @1 actually named "ordinal"
|| offset=0xDD0  ||       |
|+---------------+|       |
|+---------------+|       |
|| flag=export   ||<------+ And this entry is FIXED
|| data=shared   ||         Entry @2 is a @2 in whole entry table too, following
|| seg=0x02      ||         this logic next. That's why it calls ordinals.
|| offset=0xDE2  ||
|+---------------+|
+-----------------+<== After this block (bundle)
                       follows Entry Bundle #2
And next Entry Bundle #2 will has unknown (used/unused)
records about entries and each entry in entry bundle #2
has global incremented ordinal (or index if you think it simplier).
Means, entries in bundle #2 starts from @3. Not from @1.

И наконец, то я вызызваю Sunflower для KERNEL.EXE последний раз, чтобы продемонстировать
эти самые пакеты данных и адреса записи процедур, на которые были имена в таблице нерезидентных имён.

Это сырой отрывок из отчета, чтобы показать разбиение входных точек по пакетам.

### EntryTable Bundle #1

The linker forms bundles in the most dense manner it can, 
under the restriction that it cannot reorder entry points to improve bundling. 
The reason for this restriction is that other .EXE files may refer to entry points within this bundle by their ordinal number.

| Ordinal   | Offset   | Segment   | Entry    | Data type   | Entry type   |
|-----------|----------|-----------|----------|-------------|--------------|
| @1        | 65F8     | 1         | Export   | [Single]    | [FIXED]      |
| @2        | 2DBA     | 1         | Export   | [Single]    | [FIXED]      |
| @3        | 29AD     | 1         | Export   | [Single]    | [FIXED]      |



### EntryTable Bundle #2

The linker forms bundles in the most dense manner it can, 
under the restriction that it cannot reorder entry points to improve bundling. 
The reason for this restriction is that other .EXE files may refer to entry points within this bundle by their ordinal number.

| Ordinal   | Offset   | Segment   | Entry    | Data type   | Entry type   |
|-----------|----------|-----------|----------|-------------|--------------|
| @4        | 213B     | 2         | Export   | [Single]    | [MOVEABLE]   |



### EntryTable Bundle #3

The linker forms bundles in the most dense manner it can, 
under the restriction that it cannot reorder entry points to improve bundling. 
The reason for this restriction is that other .EXE files may 
refer to entry points within this bundle by their ordinal number.

| Ordinal   | Offset   | Segment   | Entry    | Data type   | Entry type   |
|-----------|----------|-----------|----------|-------------|--------------|
| @5        | 465A     | 1         | Export   | [Single]    | [FIXED]      |
| @6        | 46DC     | 1         | Export   | [Single]    | [FIXED]      |
| @7        | 483B     | 1         | Export   | [Single]    | [FIXED]      |
| @8        | 4891     | 1         | Export   | [Single]    | [FIXED]      |
| @9        | 48B5     | 1         | Export   | [Single]    | [FIXED]      |
| @10       | 4861     | 1         | Export   | [Single]    | [FIXED]      |
...
<--- Turncated here. Entry points in KERNEL.EXE about 160+

Перемещаемые Точки входа | LINK.EXE Пытается организоваться

Так всё-таки: зачем нужны "Moveable Entries"?

Moveable entries называются так, потому что в специфике загрузчика буквально
они так и выглядят.
Когда образ сегментированной программы загружается в оперативную
память, все данные о каждом сегменте взяты из SegmentsTable (и таблицы релокаций, если
они существуют для каждого сегмента), применяются для создания правильных указателей
на ожидаемые записи.

Операционная система может переместить некоторые точки входа
чтобы освободить сегмент для необходимых данных. Эти самые точки входа
называют перемещаемыми или "Moveable" (полн. "Способными перемещаться").

Системный загрузчик читает сырые байты инструкции INT 0x3F и Windows или OS/2
обрабатывает это прерывание, заменяя INT 3Fh на далёкий вызов CALLF <адрес>
до требуемой процедуры.

Следующие байты в записи о перемещаемой точки входа
помогают разрешить проблему с FAR указателем на процедуру

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

Легко проверить, правильно ли заполнена
таблица EntryTable, так как Sunflower не
использует поле e_cbmovent (количество перемещаемых точек),
можно посчитать все [MOVEABLE] точки входа и сравнить с установленым полем.
Поэтому Sunflower заполняет таблицу правильно.

EntryTable | Опять немного про Microsoft LINK.EXE...

Во-первых, чтобы сохранить порядковые номера процедур, установленные
в .def файле, LINK.EXE использует "не используемые" типы записей,
которые являются пробелами между процедурами.

Между процедурами @1 (т.е. MessageBox)
и @15, а именно GetCurrentTime, из модуля USER,
нет пространства. Все 13 записей это тоже процедуры.

Если бы между MessageBox и GetCurrentTime не было бы
записей, а ординалы сохранились, Microsoft LINK.EXE
создал бы отдельный пакет неиспользуемых записей между
процедурами.

Во-вторых, я нашел применение таблицы входных точек.
Я долго думал, что она содержит адреса на функции, но если внимательнее прочесть
документы, то окажется, что это должно просто быть "экспортировано".
То есть вообще не важно что после адреса стоит. И это сильно развязывает руки.
Адрес точки входа может указывать на небезопасную структуру или на
что-то еще, поимио функции.

К сожалению или счастью, это правда развязало руки.
По моим предположениям, Visual Basic 3.0 и 4.0 явно имеют дело
с таблицей входных точек. А VB 4.0 при себе ещё имеет вшитую в файл -
структуру для инициализации рантайма (я оставлю это здесь: vb4struct).
Как раз я в поисках способа её найти внутри NE сегментных программ.

Так же этот интересный факт повлиял на VxD драйвера, которые собирались
из смешанного кода, (преимущественно 16-разрядного), и имели при себе
свои специфические структуры данных. Одна из них это name_DDB или Блок описания устройства
(англ. "Device Description Block"). На самом деле он тоже хранится в таблице резидентных имен,
а следовательно имеет связь с таблицей входных точек. И адрес этой структуры держится внутри EntryTable.

EntryTable | Почему так важны размепры?

Помните речь про размеры записей? Теперь они как никогда нужны,
чтобы продемонстрировать реинтерпретацию таблицы EntryTable.

///
/// Attempts to rewrite my logic. 
/// Algorithm mostly bases on Microsoft NE segmentation format.pdf
/// 
/// \param r -- binary reader instance
/// \param cb_ent_tab -- bundles count in EntryTable /see NE Header/
/// 
pub fn read_sf<R: Read>(r: &mut R, cb_ent_tab: u16) -> io::Result<Self> {
    let mut entries: Vec<SegmentEntry> = Vec::new();
    let mut bytes_remaining = cb_ent_tab;
    let mut _ordinal: u16 = 1; // entry index means ordinal in non/resident names tables

    while bytes_remaining > 0 {
        // Read bundle header
        let mut buffer = [0; 2];
        r.read_exact(&mut buffer)?;
        bytes_remaining -= 2;

        let entries_count = buffer[0];
        let seg_id = buffer[1];

        if entries_count == 0 {
            // End of table marker
            break;
        }

        if seg_id == 0 {
            // Unused entries (padding between actual entries)
            for _ in 0..entries_count {
                entries.push(SegmentEntry::Unused);
                _ordinal += 1;
            }
            continue;
        }

        // Calculate bundle size based on segment type
        let entry_size = if seg_id == 0xFF { 6 } else { 3 };
        let bundle_size = (entries_count as u16) * entry_size;
        
        if bundle_size > bytes_remaining {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("Bundle size exceeds remaining bytes: bundle_size={}, remaining={}", 
                        bundle_size, bytes_remaining),
            ));
        }
        bytes_remaining -= bundle_size;

        // Process each entry in the bundle
        for _ in 0..entries_count {
            let entry = if seg_id == 0xFF {
                // Movable segment entry (6 bytes)
                SegmentEntry::Moveable(MoveableSegmentEntry::read(r)?)
            } else {
                // Fixed segment entry (3 bytes)
                SegmentEntry::Fixed(FixedSegmentEntry::read(r, seg_id)?)
            };
            entries.push(entry);
            _ordinal += 1;
        }
    }

    Ok(Self { entries })
}

Весь исходный код хранится в проекте win16ne.
А этот листинг - часть файла из этого проекта.

Итого

Теперь вы видели сами, что

  • LINK.EXE искажает имена процедур;

  • Перемещаемые точки входа могут быть чем угодно;

  • Таблица входных точек - универсальный предмет для общих частей кода;

  • Резидентные и нерезидентные имена отличаются друг от друга

  • LINK.EXE использует своеобразный механизм разрешения экспортов;

  • LINK.EXE использует своеобразный механизм релокаций для точек входа.

Я надеюсь это что-то изменит или поможет разобраться в этой нелёгкой теме.
Следующая (возможно последняя) статья в цикле будет про особенности импортируемых процедур.
А на самом деле, я настоятельно рекомендую с головой погрузиться в проект
otya128.
Он близок к реалиям системы, так как архиитектура его строится поверх 64-разрядной Windows NT.
Все обработчики и дескрипторы имеют специальные преобразователи в 64-разрядные вызовы, и очень много
деталей в коде дадут вам совершенно другое представление о том, что писали Microsoft.

Комментарии (0)