Search
Write a publication
Pull to refresh

MZ-Executable | Исполняемые файлы и MS-DOS (переработка)

Level of difficultyMedium
Reading time12 min
Views2.4K

Дисклеймер

Я не гонюсь за звездами или голосами. Я пишу из очень большого интереса,
и обязан донести материал "как есть", стараясь избегать серьезных неточностей.

Вступление

Практически сразу, в PC-DOS 1.0, вместе с .COM файлами, (или программами .COM),
появились .EXE файлы (полн. "EXEcutable" или "исполняемые"). Сегодня речь пойдет именно об этом.
Поскольку история происходит снова в Microsoft, запутаться можно очень легко, в любом месте.

Речь пойдет о много о чем в этот раз. И о первом формате сборки, то есть о знаменитом MZ-заголовке и его подопечных. И о технических деталях. Но впервую очередь о том "Зачем же это надо?"

Небольшой обзор

PC-DOS 1.0 снимок с Wikipedia
PC-DOS 1.0 снимок с Wikipedia

Согласно википедии:

.EXE (полн. англ. executable — исполняемый) — расширение исполняемых файлов, применяемое в операционных системах DOS, Windows, Symbian OS, OS/2 и в некоторых других, соответствующее ряду форматов. Кроме объектного кода может содержать различные метаданные (ресурсы, цифровая подпись).

А сама программа .EXE в понимании PC-DOS это

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

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

Те кто знаком с PC/MS-DOS любым образом, сразу держат в голове факт, что команда при вызове загружалась в "Program Memory" область и занимала какое-то там место, но помещалась там абсолютно вся. Из любой точки памяти процесса можно было "ткнуть" (или взять указатель) на любое место памяти процесса, без лишних телодвижений.

В чуть позже, это будет называться "Ближние указатели" (англ. "Near Pointers").
Я привык звать их "недалёкими".

Чтобы начать разговаривать о самом формате исполняемых файлов, придется
сначала подробно объяснить другие очень важные детали.

Взгляд на PC-DOS/MS-DOS

Обязательно держу в голове, что PC-DOS (MS-DOS тоже) - это ОС, работающая в реальном режиме процессора Intel, что предполагает использование адресного пространства размером всего лишь 1 МБ. Заодно все адреса физических устройств в памяти настоящие

Так-то памяти ещё меньше. Около 640 КБ основной памяти, потому что старшие адреса памяти заняты под BIOS другие компоненты, хотя среди них попадаются разрозненные куски оперативной памяти, называемые UMB (т.е. верхний блок памяти) или (англ. "Upper Memory Block").

Расчерчу схему заполненной памяти

+----------------------------+ <--Конец
| Верхние блоки памяти (UMB) |      |
+----------------------------+      | Это называют Транзитивной
|                            |      | областью памяти
|   Program Memory           |      |
| Область загрузки программ  |      |
|                            | <----+
+----------------------------+ <-+
| Резидентная часть          |   |
| (COMMAND.COM)              |   |
+----------------------------+   | В некоторых материалах это всё
| MS-DOS (MSDOS.SYS)         |   | является Резидентной областью памяти
+----------------------------+   | 
| BIOS (IO.SYS)              |   |
+----------------------------+ <-+-0x0400
| Векторы прерываний         |
+----------------------------+ <--Начало

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

Взгляд на архитектуру

Меня в этой статье крайне интересует сегментная адресация у I8086+, так как
далее она фигурирует абсолютно везде.

В основных терминах адресации I8086+ фигурирует параграф

Параграф = 16 байт. (или 0x10)

В контексте хх-DOS (и её менеджера памяти):

Блок памяти - это непрерывный участок оперативной памяти, который программа может запросить у операционной системы для своих нужд.

Когда программа работает под управлением DOS, она не имеет прямого неконтролируемого доступа ко всей памяти. Вместо этого она обращается к операционной системе через системные вызовы.

Все операции с блоками памяти в понимании DOS должны считаться параграфами. (шестнадками). Или перефразирую так:

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

Теперь разбор путаницы, которая меня застала и всё испортила.
Некоторое время в документации Microsoft будет фигурировать слово "Сегмент". Считайте, начиная со времен DOS, оно уже точно есть и активно используется.

Сегмент - это область памяти, начинающаяся с адреса, кратного 16 байтам. (то есть кратное параграфу)

Вот самое важное, что нужно мне надо было понять, что сегмент — это не фиксированный кусок памяти размером 64КБ. Это, в первую очередь, это метод адресации. Или способ вычисления физического адреса в памяти.

Типы указателей

И так, согласно документации Borland, физическим, (или аппаратным), возможностям того времени, и тяжелым временам, в голове должно сложиться приблизительно следующее:

  • Максимальное машинное слово (англ. "CPU WORD") - 16-бит. (времена Intel i8086+);

  • Максимальное машинное слово, которое понимает ОС - 16-бит;

  • Весьма маленький объем ОЗУ и Program Memory тоже;

  • .PSP сегмент (потому что это PC/MS-DOS).

Теперь всё просто:

  • Ближние (не далёкие) указатели (англ. "Near Pointers") - это указатели, которые помещаются в регистр. То есть 16-битные.

  • Далёкие указатели (англ. "Far Pointers") - это указатели, которые уже НЕ помещаются в регистр;

Сделаю акцент на последнем. Это важно.
Далёкие указатели это не просто адрес "куда-то" в 32-разрядном размере, а целая форма записи 16:16, что означало 16 бит адрес, 16 бит номер сегмента.

Из-за сегментной архитектуры x86, далёкий адрес строится таким образом:

let far_addr: u32 = (segment << 4) + offset // (20 бит физический адрес)
//  far_addr: u32 = (segment << 4) | offset // побитовое-ИЛИ лучше избегать

// Вот почему 20-разрядный адрес
let area = 2^20 = 1 048 576 // ровно 1МБ

А чтобы его разобрать на составные части, придется делать уже две
операции, а не одну.

let segment: u16 = far_addr >> 16;
let offset : u16 = far_addr & 0xFFFF;

Арифметика?

Intel решили в архитектуре 8086 использовать два 16-битных регистра (сегментный и регистр смещения) для формирования 20-битного адреса. Это позволило адресовать 1 МБ памяти (то есть см. выше)

Пробуем добрать до границы, которую ещё может "понять" машина.
BYTE это слово равное 1 байту, а WORD в i8086 это два байта.
Внутри себя BYTE сможет содержать 255 различных значений, поскольку байт это 8 бит, а устройство понимает лишь два варианта сигнала: 0 и 1. (основание системы для счёта будет 2).

let byte_variants = 2^8 // или 256.

Логика у WORD слова такая же, только уникальных значений она хранит больше.

let word_variants = 2^8 * 2^8 // 2^16 = 65 536
// Складывают показатели, основание остается неизменным

Где же небезызыестные 64 КБ? Почему они фигурируют?
Фигурируют они рядом. Достаточно максимальное значение 16-разрядного слова
разделить на 1024. Это будет ровно 64 КБ данных.

А теперь представьте, в программе есть две логические части, которые легко представить в виде
районов города:

  • Первая хранит сам код программы (будет районом Кода);

  • Вторая хранит строки или какие-то hardcoded (т.е. вшитые в код) данные. (Будет районом данных).

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

Пока вы находитесь в одном районе, вы можете свободно ходить по всем домам от 0 до 65535. Если вы дойдете до дома №65535 и сделаете еще шаг, на горизонте не будет другой район!

Вы просто перескочите в начало этого же района — к дому №0. Где-то это называется переполнение (англ. wrap-around), и это серьезная ошибка в программе, которая почти всегда приводит к ее краху.

Чтобы попасть в другой район (другой сегмент), надо сознательно и явно сменить название района в своей карте. То есть, программа должна выполнить команду, которая меняет значение сегментного регистра.

Проблема только одна. Программы-COM не могут содержать два района кода, у них на всё про всё рассчитано очень мало места, и ОС их загружает "как есть". А разработчик как архитектор города иногда вынужден выделять не один район кода.

Зачем EXE?

Здесь и раскрывается смысл появления исполняемых файлов.
EXE-программы по своей сути уже могут содержать несколько сегментов кода или данных.
Размер программы заведомо уже может быть больше 64КБ данных, но для прыжков по районам города
уже приходится пользоваться не просто указателями, а далёкими (англ. FAR) указателями, которые содержат не только смещение (или номер дома), но и район города тоже.

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

Перемещения

Внезапно промелькнули пару слов про какие-то исправления, ведь да? Значит пришло время объясниться.

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

Перемещение (англ. "relocation") — это информация о поправках к абсолютным адресам в памяти, созданная компилятором или ассемблером и хранящаяся в файле.

В новых программах (имеются ввиду .EXE-программы) необязательно, но
будут храниться те самые перемещения.

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

Ещё пару разделов и наконец-то спецификация формата.
Остается ответить один важный вопрос

  • "Как быть, если программа в памяти требует больше места, чем можно себе позволить?"

Положим, есть EXE-программа размером 300 КБ. Её надо запустить её на компьютере с 256 КБ ОЗУ. Даже если сам процессор может адресовать 1 МБ, физически в компьютере просто нет 300 КБ свободной памяти подряд, чтобы загрузить всю программу целиком. Она просто не влезет.

Эту проблему решали по-разному. Один из способов, о котором дальше речь - это оверлеи.
Второй, более интересный - это DOS расширители. (англ. "DOS extender"). Вдруг внезапно встречали DOS4GW или DOS32a? Это программные DOS-extenderы.

Оверлеи

Согласно словарям и старым источникам:

Overlay - это метод программирования, позволяющий создавать программы, занимающие больше памяти, чем установлено в системе

Метод предполагает разделение программы на фрагменты. Размер каждого оверлея/фрагмента ограничен, согласно размеру доступной памяти.

Место в памяти, куда будет загружен оверлей называется регионом (region или destination region) или областью перекрытия. Хотя часто программы используют только один блок памяти для загрузки различных оверлеев, возможно определение нескольких регионов различного размера.

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

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

Я никогда не пробовал такое делать, но в будущем обязательно вернусь к этому и попробую что-то сделать сам. В добавок, заранее документации Microsoft дают знать, что "В PC-DOS двоичные файлы, содержащие оверлеи, часто имели расширение .OVL."

Чуть-чуть прыгну вперёд статьи.

В структуре MZ заголовка есть поле, которое говорит номер части программы.
Эта информация подтверждается старыми источниками тоже.

Раз такое говорят - время проверять.
Я выбрал любимую дискету с DR-DOS 7.+ и взял оттуда .OVL части
программ, заодно и сами образы программ тоже.

Подсолнух говорит, что  равен нулю
Подсолнух говорит, что равен нулю

Само значение равное нулю говорит, что структура заголовка хранится в основной запускаемой части. А здесь незапускаемая .OVL часть держит противоречие.

Получается, что это "не идеальный образ" или образ, необязательная информация
в котором - неверна, или попросту отсутствует.

Значит DOS функция загружает overlay-части основываясь на чём-то другом.

DOS и Оверлеи

Сразу к делу. В самом ABI системы, функция отвечающая за оверлеи
имеет номер 0x4B и работает с параметрами немного хитрее, чем можно подумать.

Я нашел на просторах интернета хороший пример части программы,
которая демонстрирует использование той самой DOS функции.

Я просто переведу на русский язык все комментарии в коде.

          ...
          ...
          ...
          ; Выделим память для оверлея
          mov     bx,1000h        ; 64 KB (4096 блоков)
          mov     ah,48h          ; Номер функции 48h = выделить блок
          int     21h             
          jc      __error         ; Если не получилось выделить блок
 
          mov     pars,ax         ; адрес области перекрытия
          mov     pars+2,ax       ; сегмент для overlay-части
 
                                  ; создадим сегмент для входной точки
          mov     word ptr entry+2,ax
 
          mov     stkseg,ss
          mov     stkptr,sp
 
          mov     ax,ds           ; ES = DS
          mov     es,ax
 
          mov     dx,offset oname ; DS:DX = название файла
          mov     bx,offset pars  ; ES:BX = parameter block
          
          ; Специальная DOS функция для оверлеев
          mov     ax,4b03h        ; <-- EXEC 0x03
          int     21h
 
          mov     ax,_DATA        ; создадим свой собственный сегмент данных
          mov     ds,ax
          mov     es,ax
 
          cli
          mov     ss,stkseg       ; восстановить указатель на .STACK
          mov     sp,stkptr
          sti
 
          jc      __error
 
                                  ; В противном случае EXEC завершится без ошибок
          push    ds              ; сохраним наши данные в сегмент
          
          ; Вызов OVERLAY части
          ; Вызов функций из оверлея это всегда FAR (об этом позже в этой статье)
          call    dword ptr entry
          pop     ds              ; восстановим .DATA сегмент
          ...
          ...
          ...
  oname   db      'OVERLAY.OVL',0 ; название файла
 
  pars    dw      0               ; адрес сегмента для загрузки
          dw      0               ; релокации для файла
 
  entry   dd      0               ; входная точка для overlay-части
 
  stkseg  dw      0               ; сохранить Stack Segment
  stkptr  dw      0               ; сохранить Stack Pointer

MS DOS использует функцию EXEC для загрузки оверлеев. Эта
функция, номер 0x4B, используется также для загрузки и запуска одной программы из другой, если поместить код 0x00 в AL.

Если в AL поместить код 0x03, то тогда будет загружен оверлей. В этом случае не создается PSP-сегмент, поэтому оверлей не устанавливается как независимая программа.

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

Наконец-то теперь спецификация формата.

Mark Zbikowski Executable Format

Теперь, как один из важных вопросов закрыт, представляю вам то, что вы и без меня знаете.

Буквы MZ (шестн. 0x4D 0x5A) - это инициалы инженера Microsoft, который предложил и представил
альтернативу односегментным .COM программам.

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

struct MzHeader{
    // Стандартная (повсеместная) часть заголовка
    pub e_sign: Lu16,       // подпись ZM или MZ
    pub e_cblp: Lu16,       // последний блок
    pub e_cp: Lu16,         // количество блоков/страниц
    pub e_relc: Lu16,       // количество релокаций
    pub e_cparhdr: Lu16,    // размер заголовка в блоках
    pub e_minep: Lu16,      // мин. выделенной памяти  в блоках
    pub e_maxep: Lu16,      // макс. выделенной памяти в блоках
    pub ss: Lu16,
    pub sp: Lu16,
    pub e_check: Lu16,
    pub ip: Lu16,
    pub cs: Lu16,
    pub e_lfarlc: Lu16,      // Сырое смещение таблицы релокаций
    
    // Расширенная/Дополнительная часть MZ-заголовка 
    pub e_ovno: Lu16,        // Текущая .OVL часть. (как было показано в предыдущей главе, скорее всего оно опционально)
    pub e_res0x1c: [Lu16; 4],// UInt16[4] линкер/компилятор или мусор
    pub e_oemid: Lu16,
    pub e_oeminfo: Lu16,
    pub e_res_0x28: [Lu16; 10], // UInt16[10] линкер/компилятор/OEM/мусор
    pub e_lfanew: Lu32,         // Пока что равен нулю.
}

Пройдусь по некоторым полям, которые мало кто хочет обозначать

  • Префикс e_ это "executable";

  • e_minep и e_maxep расшифровываются как минимальное и запрашиваемое (а не максимальное) значение ожидаемой памяти в блоках (например e_maxep = maximum expected paragraphs);

  • e_lfarlc - содежит сырое (или абсолютное) смещение от начала образа (с нуля), ровно до таблицы релокаций.

  • e_ovno расшифровывается как overlay's number, а не количество оверлеев, что напрямую говорит о том, что оверлеи тоже внутри себя хранят расширенный DOS заголовок

    • 0x0000 - Главная программа (.EXE файл)

    • 1+ - Оверлей-часть программы (.EXE или .OVL);

  • e_res0x1C - массив зарезервированных байт, который долгое время был выделен для дальнейших полей, но (судя по всему) откладывался;

  • e_res0x28 - массив зарезервированных байт. В "идеальных" файлах является нулевым (везде хранит нули);

  • e_oemid и e_oeminfo практически нигде не документированны, и возможно используются "как попало". Ожидается, что в них хранится уникальный номер и ссылка на информацию от производителя ПО, но эти поля так же не влияют на запуск.

Более того, DR-DOS в OEM полях хранит свои специальные данные.

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

Исследуя компилятор Open Watcom я не смог найти чего-то интересного для
MZ заголовка, поэтому подтверждать гипотезу о компиляторах не осмелюсь пока что. А инструменты Borland и Watcom закрыты от глаз пользователей, к моему сожалению.

Поля до e_ovno встречаются во всех DOS, поэтому в некоторых кругах
DOS-заголовок означает только стандартные поля структуры.

А вся структура заголовка, какой она есть в PC-DOS, MS-DOS и других MS-DOS совместимых ОС, называется MZ-заголовок или в некоторых кругах - Расширенный DOS заголовок

Поле e_relc говорит количество релокаций в файле, а поле e_lfarlc
переводится как "location FAR address relocations" (предположительно).

Таблица релокаций выглядит очень просто:

struct MzRelocation {
    // Количество записей определяет "e_relc"
    // Формат записи 16:16
    pub offset: u16,
    pub segment: u16
}

///
/// Внимание, это псевдо-инструкции
/// Читает таблицу перемещений, основываясь на смещении таблицы
/// 
pub fn get_relocs(...) -> Vec::<MzRelocation> {
    let rel_vec: Vec::<MzRelocation> = Vec::<MzRelocation>::new();
    
    reader.seek(e_lfarlc, 0); // <-- Смещение от 0 до таблицы релокаций
    for rel_index in 0..e_relc {
        // Записать релокации в таблицу
        let rel: MzRelocation = reader.Fill::<MzRelocation>() // <-- Чтение и сдвиг позиции reader-a
        rel_vec.push(rel); // <-- Запись
    }

    rel_vec
}

Некоторые источники говорят, что небезызвестное поле e_lfanew
появилось позже с появлением первого сегментного формата - "New Executable".
Отсюда, собственно и название поля: "LONG file address New..." (а дальше продолжите сами).

Главный интересующий меня вопрос - это загрузка программ.

Запуск

Здесь будет немного математики, но я постараюсь уложить это так, чтобы было проще читать.

Поскольку речь идет о PC-DOS и MS-DOS, перед тем как загрузить программу, система
выделяет специальную область, которая называется PSP-сегмент.

Рисовать ситуацию, как это выглядит в памяти - ненадо. Все просто. Но очень необычным ходом является следующее.

Операционной системе для загрузки программы надо знать список важных переменных

  1. Размер программы;

  2. Начальные Значения регистров для программы;

  3. Начальный адрес загрузки образа;

  4. MZ-Заголовок;

  5. Релокации (исправления к специальным адресам)

Чтобы узнать размер программы (или образа программы), надо немножечко посчитать:

let image_size = (e_cblp = 0) match {
    true => e_cp * 512
    false => (e_cp - 1) * 512 + e_cblp
}

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

let image_base = psp_offset + e_cparhdr + 0x10

Поле e_cparhdr хранит размер заголовка в параграфах. Чтобы получить смещение к началу кода/данных после заголовка, это значение нужно умножить на 16 (что практически эквивалентно сдвигу на 4 бита влево, или +0x10)

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

Применяет исправления операционная система таким образом:

let base_address = load_address; // адрес загрузки
for rel in rel_vec {
    let target_ptr = base_address + rel.offset as usize;
    let value = read_u16(target_ptr);           // <-- текущее значение
    write_u16(target_ptr, value + base_segment);// <-- запись исправленного адреса
}

Выводы

В целом, сам MZ заголовок и его данные выглядят и расшифровываются не трудно, но чтобы понять "зачем это надо?", пришлось много искать, и вдумчиво разбирать. Основная доля записей посвящалась истории и заметкам "Охотника за указателями".

Проблемы .EXE файлов проявляются с каждым форматом их сегментации, а их за время набралось немало.
У каждого формата свои достоинства и недостатки, а определять их можно по первому слову от e_lfanew смещения. (То есть указатель в e_lfanew покажет расположение следующей ASCII-подписи).

Источники

Tags:
Hubs:
+12
Comments36

Articles