Речь пойдет о том OMF, который Relocatable Object Module Format, поскольку есть и другие вещи с той же аббревиатурой. Вероятно, первоначально этот формат был разработан Intel (Intel Technical Specification 121748-001), а потом его приняли и использовали и IBM и Microsoft. Это формат объектных модулей (OBJ), в котором записывали результаты своей работы различные трансляторы, а затем различные редакторы связей в соответствии с OMF собирали исполняемые программы в течение многих десятилетий.

Помню, что когда переводил свою систему программирования из 16-ти разрядной среды MS-DOS в 32-х разрядную, казалось, что потребуются значительные доработки и компилятора и редактора связей именно из-за 16-ти разрядного OMF. Ведь в те времена у меня еще не было доступа в Интернет и к документации, поэтому я понятия не имел, что примерно в том же году Комитет по стандартам интерфейсов (TIS) принял спецификацию OMF версии 1.1, где узаконил использование этого формата и для 32-х разрядной среды.

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

Время шло, и у меня возникла необходимость следующего перехода. Теперь уже от 32-х разрядной среды Windows XP к 64-х разрядным Windows 7, 8, 10. И опять показалось, что вот тут-то и придется радикально переделать OMF или вообще отказаться от него, поскольку больше зарезервированных бит в нем не осталось, а тот самый TIS Committee (жив ли он?) не спешит выпускать новые версии спецификации. И вообще стали использоваться другие форматы вроде ELF-64 для Linux или Win64 у Microsoft.

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

В 64-х разрядной среде x86-64 для этого есть хорошие основания. Ведь в этой среде большинство процессорных инструкций чаще используют четырехбайтовую адрес-константу в своем коде, а не восьмибайтовую (хотя есть и такие команды). Дело в том, что на выполняемые программы наложено слабое ограничение: суммарный объем кодов и статических данных в образе EXE-файла не должен превышать 2 в 32 степени. В этом случае, так сказать, относительная (в пределах образа EXE-файла) адресация умещается в четыре байта, а, значит, и доработка OMF в этой части не потребуется. И это ограничение действительно слабое. Например, в программе, которая является основным видом моей деятельности, на сегодня коды занимают 2 мегабайта, статические данные – 11 мегабайт и, стало быть, я могу увеличивать ее еще в 330 раз, пока не выйду на это ограничение. При этом во время работы программа захватывает памяти уже полтора десятка Гбайт, но на использование OMF это никак не влияет.

Я пошел еще дальше, и наложил ограничение на загрузку своих программ: код не может загружаться по такому начальному адресу, чтобы образ EXE-файла выходил за 2 в 32 степени, т.е. как был адрес базы 400000H со времен Windows-XP, так он и остается. В конце концов, ведь адресация виртуальная и адресное пространство задачи изолировано. Ничто не мешает Windows загружать каждую программу именно в начало ее виртуального адресного пространства.

Данное ограничение позволяет не только использовать «внутри» образа EXE-файла четырехбайтовую адресацию, но и вообще использовать 32-х разрядные формы инструкций. Например, вместо:

MOV RBX,OFFSET X

можно использовать просто

MOV EBX,OFFSET X

т.е. без REX-префикса получить тот же эффект, поскольку процессор любезно автоматически обнулит старшую половину регистра RBX, что в данном случае и требуется. Получается, что только указатели на динамические объекты используют для адресации все восемь байт, а вся адресация статических переменных «внутри» EXE-файла остается 32-х разрядной и даже за счет исключения части REX-префиксов код становится более плотным. И такая адресация статических объектов представима в OMF.

Так можно ли использовать «древний» OMF для создания 64-х разрядных объектных модулей не переделывая капитально имеющиеся утилиты? Да, можно. Но одно исправление все-таки потребуется.

Дело в том, что для создания полностью перемещаемого кода в x86-64 введен режим адресации данных относительно текущего счетчика команд RIP. Если раньше такая адресация применялась только для команд переходов и вызовов, то теперь она стала применима и к данным.

Поясню на примере для тех, кто никогда не разбирался с режимами адресации. Пусть в 32-х разрядной среде имеется переменная X по адресу 408С90H, а по адресу 40120A имеется команда увеличения переменной на единицу:

0040120A FF05908C4000      INC  D PTR [00408C90] .X

В самом коде этой команды легко увидеть тот самый адрес 408С90H.

В 64-х разрядной форме этой же программы переменная X разместилась уже по адресу 40A5B8H, поскольку объем команд увеличился (главным образом, из-за REX-префиксов) и статические данные расположились по более далеким адресам. Но в команде:

0040120A FF05A8930000         INC    D PTR [0040A5B8] .X

уже не находится соответствующий адрес-константа 40A5B8, поскольку теперь адрес получается прибавление к RIP константы 93A8H, что и дает искомый адрес. 401210H+93A8H=40A5B8H

Дорабатывая OMF, сначала я хотел применить в таких случаях уже имевшуюся адресацию относительно RIP для команд переходов. Но выяснилось, что так не получится.

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

Объектный модуль в формате OMF состоит из множества отдельных частей-«записей», даже защищенных контрольными суммами. Типов записей много и они содержат разную информацию, но, пожалуй, главных две: одна имеет аббревиатуру LEDATA (Logical Enumerated Data Record), а другая аббревиатуру FIXUPP (Fixup Record).

Правда, я не вижу во второй аббревиатуре требуемой по смыслу буквы «R», вместо нее идет вторая «P», но, возможно, это не от слова «Record», а от слова «Previous», поскольку каждый FIXUPP относится к предыдущей LEDATA.

Записи LEDATA – это разбитый на кусочки по 1024 байт образ почти готовых кодов, где остались недостроенные адреса, которые и будет достраивать редактор связей. Для этого за каждой LEDATA следует свой FIXUPP, который содержит информацию, где в данной LEDATA расположены такие адреса, и каким образом их надо подправить («fix up» - подправить). Положение адресов отсчитывается от начала каждой LEDATA, поэтому для указания положения каждого адреса достаточно лишь десяти бит и это число не зависит от разрядности среды.

Так вот, когда достраиваются адреса переходов и вызовов, внутри очередного элемента записи FIXUPP указывается место адреса перехода от начала LEDATA и указание, каким должен получиться этот адрес. Редактор связей пересчитывает значение этого адреса в число относительно текущего счетчика команд RIP. Но когда процессор выполняет команду перехода, RIP уже показывает на начало следующей команды. Поэтому и относительный адрес перехода нужно рассчитывать не от начала данной команды, а от начала следующей. В случае инструкций CALL, JMP или условных переходов, следующая команда всегда начинается сразу после настраиваемого адреса, т.е. всегда через 4 байта (через байт для коротких условных) и редактор связей это учитывает.

Но в случае относительной адресации данных в x86-64 это не всегда так. Например: Следующая команда начинается сразу после настраиваемого адреса:

FF05C8930000      INC    D PTR [0040A5D8] .X

Следующая команда начинается через байт после настраиваемого адреса:

803DC593000001   CMP    B PTR [0040A5DC],01 .Y

Следующая команда начинается через 2 байта после настраиваемого адреса:

66813DB79300000001   B32:  CMP  W PTR [0040A5DE],0100 .Z

Поэтому-то уже имеющийся в FIXUPP способ адресации переходов относительно RIP и не подходит. Редактору связей еще нужно знать, где начинается следующая команда, чтобы правильно учесть значение RIP, а не только где в LEDATA расположен сам относительный адрес.

Поскольку я сопровождаю и компилятор, и редактор связей, и не требуется, к счастью, согласовывать изменения OMF с TIS Committee, я сделал доработку FIXUPP самым простым пришедшим в голову способом: приписал к началу каждого элемента записи FIXUPP еще по одному байту. Если этот байт нулевой – значит, относительная адресация данных в этой настройке не используется. Иначе он содержит число байт от начала настраиваемого адреса до конца данной процессорной инструкции.

Доработки редактора связей получились примитивными. Теперь он всегда читает еще один байт в каждом элементе записи FIXUPP. Если этот байт нулевой – никаких дополнительных действий не делается, иначе рассчитывается значение RIP с учетом числа в этом байте (оно не может быть меньше четырех) и из полученного значения RIP вычитается уже определенный ранее (в этом же элементе FIXUPP) «абсолютный» адрес самих данных. Вот пример фрагмента кода, где использованы три приведенные выше команды:

00401200 B880924002              MOV    EAX,02409280
00401205 E8A2020000              CALL   004014AC .?START
0040120A FF05C8930000            INC    D PTR [0040A5D8] .X
00401210 803DC593000001          CMP    B PTR [0040A5DC],01 .Y
00401217 7E05                    JLE    0040121E
00401219 E8730E0000              CALL   00402091 .?STOPX
0040121E 66813DB79300000001      CMP    W PTR [0040A5DE],0100 .Z
00401227 7E05                    JLE    0040122E
00401229 E8630E0000              CALL   00402091 .?STOPX

Вот этот же фрагмент в виде записи LEDATA:

       A1        LEDATA-32     ДЛИНА=  57
01 00 00 00 00 B8 80 92 40 02 E8 00 00 00 00 FF
05 E8 00 00 00 80 3D EC 00 00 00 01 7E 05 E8 00
00 00 00 66 81 3D EE 00 00 00 00 01 7E 05 E8 00
00 00 00 E8 00 00 00 00
К.С.=CA

А вот доработанный FIXUPP для настройки этой LEDATA:

       9C        FIXUPP        ДЛИНА=  33
00 A4 06 86 0F 05 E4 0C 9D 06 E4 12 9D 00 A4 1A
86 0E 07 E4 21 9D 00 A4 2A 86 0E 00 A4 2F 86 0E
К.С.=15

Байты, содержащие 05, 06 и 07 как раз и содержат длины до конца инструкций в случае относительной адресации. Правда, по историческим причинам к этим длинам добавлена еще единица, но редактор связей это также учитывает.

Простой получилась и доработка компилятора. В том месте, где формируется информация для элементов FIXUPP, известны и длина команды и способ адресации. Если это та самая адресация данных относительно RIP – в переменную, которая затем будет выводиться как первый байт каждого элемента FIXUPP, записывается длина в байтах до конца текущей команды. Иначе переменная обнуляется.

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

Заключение

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

Однако особенности архитектуры x86-64, позволяющие продолжать широко использовать 32-х разрядную адресацию и в 64-х разрядной среде, делают необходимые доработки OMF тривиальными, за исключением реализации относительной адресации данных. Впрочем, и здесь доработки, к тому же только одного единственного элемента формата оказываются слишком простыми, чтобы из-за них отказываться от использования OMF в 64-х разрядной среде вообще. Шесть лет успешного использования этой доработки вполне это подтверждают.

P.S. Опыт написания подобных статей показывает, что в комментариях обязательно появится утверждение: «все это не нужно». При этом комментаторы всегда забывают добавлять в это утверждение местоимение «мне». Поэтому, заранее соглашаясь с доводами «у нас байт-код/LLVM и мы никогда не слышали ни о каком OMF», сообщаю, что рад за таких программистов, но призываю подобных комментариев к данной статье не оставлять, поскольку информации к изложенной теме они не прибавляют.

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


  1. DreamingKitten
    08.09.2021 07:53

    А какие современные компиляторы в нативный код используют OMF ?


    1. marsianin
      08.09.2021 15:24
      +1

      Очевидно, никакие


  1. NeoCode
    08.09.2021 11:36
    +1

    Считаю что объектный формат хранения и линковка вообще устаревшие концепции, т.к. они не позволяют хранить метапрограммы (например шаблоны C++), в результате в том же C++ шаблоны могут существовать только в *.h-файлах. Хранить нужно частично скомпилированные синтаксические деревья, а вместо линковки применять процесс «окончательной компиляции».


    1. Justlexa
      08.09.2021 15:34

      … в результате в том же C++ шаблоны могут существовать только в *.h-файлах. Хранить нужно частично скомпилированные синтаксические деревья...

      Поясните пожалуйста, возможно я заблуждаюсь, но ведь к моменту обработки AST и последующей оптимизации/кодогенерации шаблоны уже будут развёрнуты к подходящему для используемых типов аргументов виду, разве нет? Т.е. в AST шаблоны уже должны быть последовательностью инструкций используемого компилятором IR.


      1. berez
        08.09.2021 19:18

        Это если шаблон построен по старинке — и объявления, и реализации в *.h-файлах. Но если оставить в хедерах только объявления, а реализации положить в отдельные файлы *.cpp, то в момент обработки AST шаблоны в последовательность инструкций развернуть не получится. Этим придется заниматься линкеру.
        Понятно, что не всякий линкер для этого подойдет. Помнится, солярисовский Sun PRO компилятор именно поэтому использовал свой, интеллектуальный линкер ldi (или ild? Склероз проклятый). Можно было и gcc-шный ld подсунуть, и он даже работал — но ровно до тех пор, пока дело не доходило до линковки с «внешним» шаблонным кодом.


        1. marsianin
          08.09.2021 20:05

          Это очень похоже на модули из C++20


  1. Siemargl
    08.09.2021 11:51

    А в TinyC пошли наоборот, сделали ELF и для Windows. Исторически, видимо сложилось.

    А что происходит с поддержкой отладчиков и всяких исключений SEH, которые разные для 32 и 64-бит ?


    1. Dukarav Автор
      09.09.2021 05:47

      Они не разные


      1. Dukarav Автор
        09.09.2021 05:48

        Например вот так https://habr.com/ru/post/536990/


  1. Justlexa
    08.09.2021 15:33
    +1

    суммарный объем кодов и статических данных в образе EXE-файла не должен превышать 2 в 32 степени

    Несущественно, но на самом деле в два раза меньше:
    docs.microsoft.com/en-us/cpp/build/x64-software-conventions

    Ничто не мешает Windows загружать каждую программу именно в начало ее виртуального адресного пространства

    ASLR

    Данное ограничение позволяет не только использовать «внутри» образа EXE-файла четырехбайтовую адресацию, но и вообще использовать 32-х разрядные формы инструкций

    Ровно до того момента, пока Вы не захотите иметь дело например с сиплюсплюсным RTTI под виндой — там, увы, придётся писать addr64 в некоторых структурах.

    А вообще очень, очень жаль, что в подобных отраслях тулчейн из 16битных времён тянут в 64битные кучей костылей и велосипедов. Раз уж Вам так нравится PL/1 — почему бы не превратить Ваш компилятор например во фронтэнд к LLVM?


    1. Dukarav Автор
      09.09.2021 05:56

      Так речь идет об адресации в пределах EXE-файла. Да, там есть команды типа mov rax,<переменная>, т.е. адресация в 8 байтном поле.

      И в структурах самой программы полно указателей и прочего. Но в этих случаях старшие 4 байта просто остаются нулевыми, а младшие - нормально настраиваются через имеющийся OMF.


      1. Justlexa
        09.09.2021 11:55

        Так речь идет об адресации в пределах EXE-файла

        Именно! И старшие 4 байта Вы принимаете нулевыми только потому, что выбрали ImageBase не 0x140000000, как делает по умолчанию майкрософтовский компоновщик для 64битных PE, а 0x400000.
        Причина стала следствием и наоборот :)
        mingw'шный компоновщик например тоже так делает, но, думаю, ровно по тем же причинам — желание всеми способами удержать на плаву явно устаревающий/неподходящий, но такой тёплый и ламповый формат.


        1. Dukarav Автор
          10.09.2021 19:08

          А какое преимущество дает загрузка по такому странному адресу 140000000 ? Если оставлять загрузку из IA-32, по крайней мере, можно код короче записать и, да, маленьким прицепом-бонусом можно по-прежнему использовать OMF после пустяшной доделки.

          А здесь в чем плюсы? Например, при тестировании я загружал в RSP большое значение и если в недрах системных подпрограмм случайно был оставлен ESP вместо RSP, то в этом месте старшая половина RSP обнулялась и сразу все падало. Удачный тест, но зачем в обычных задачах такое значение стека? Наверное, есть задачи, где свой код требуется отодвинуть в памяти подальше, но большинство задач этого не требует. И, кстати, LLVM и OMF ортогональные вещи. Зачем же отказываться от OMF, который еще 40 лет назад прекрасно позволял собирать выполняемый модуль, не тащя в него библиотеки целиком?


          1. Justlexa
            10.09.2021 23:42

            А какое преимущество дает загрузка по такому странному адресу 140000000 ?

            На явное преимущество не тянет… Преследуемая цель та же, что и переход от 0x10000 к 0x400000 30-35 лет назад: разделить 16бит и 32бит. Только теперь жёсткого требования нет, лишь рекомендация, и то, сходу не смог нагуглить где же она была, но точно где-то в доках или блогах MS.
            Но цель та же — гарантированно отделить в АП 32битные образы от 64битных.
            Странный? Нисколько. Любой PE из x64-винды или созданный родным компоновщиком из VS будет иметь такой ImageBase по умолчанию (для DLL 0x180000000).

            Зачем же отказываться от OMF

            Затем, что на очередной итерации это придётся сделать.
            Конечно, можно и дальше кастомизировать его для себя, just for fun, потому что интересно.
            Но однажды всем этим придётся заниматься кому-то кроме/вместо Вас и вероятно случится так, что написать весь Ваш набор инструментов по-новой или взять готовый общеиспользуемый окажется проще, дешевле и надёжнее, чем разбираться что и как было.


  1. Justlexa
    09.09.2021 11:55

    /промахнулся веткой ответа/


  1. Panzerschrek
    12.09.2021 14:09

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


    1. Dukarav Автор
      12.09.2021 17:52

      Наверное, плохо объяснил. Адрес в 4 байта - это только для адресов-констант, которые известны во время компиляции и действуют "внутри" Exe-файла. А всякие DLL и ALLOCATE - разумеется через адрес в 8 байт, но их настраивать через OMF не нужно.