Эта заметка или статья является продолжением цикла о формате
Новых исполняемых (ориг. "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-приложение";
Это именно приложение, а не подключаемая в рантайме библиотека;
Это небольшой модуль, и его таблицу не придется урезать.
|
|
|
|
---|---|---|---|
5 |
|
@0 |
[Resident] |
5 |
|
@1 |
[Resident] |
12 |
|
@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 |
|
@0 |
[Not resident] |
12 |
|
@19 |
[Not resident] |
12 |
|
[Not resident] |
|
12 |
|
@99 |
[Not resident] |
7 |
|
@88 |
[Not resident] |
7 |
|
@81 |
[Not resident] |
10 |
|
@18 |
[Not resident] |
14 |
|
[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 |
|
@0 |
[Not resident] |
7 |
|
@0 |
[Resident] |
13 |
|
@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.