OMF еще послужит
Речь пойдет о том 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 lдля Lunix или Win64 у Microsoft.
При этом, конечно, опять очень не хотелось переделывать компилятор и редактор связей. Или, по крайней мере, хотелось, чтобы переделки были такими же простыми как при предыдущей смене разрядности и не потребовали бы большого времени на отработку.
В 64-х разрядной среде x86–64 для этого есть хорошие основания. Ведь в этой среде большинство процессорных инструкций чаще используют четырехбайтовую адрес-константу в своем коде, а не восьми байтовую (хотя есть и такие команды). Дело в том, что на выполняемые программы наложено слабое ограничение: суммарный объем кодов и статических данных в образе EXE-файла не должен превышать 2 в 32 степени. В этом случае, так сказать, относительная (в пределах образа EXE-файла) адресация умещается в четыре байта, а, значит, и доработка OMF в этой части не потребуется. И это ограничение действительно слабое. Например, в программе, которая является основным видом моей деятельности, на сегодня коды занимают 2 мегабайта, статические данные — 11 мегабайт и, стало быть, я могу увеличивать ее еще в 330 раз, пока не выйду на это ограничение. При этом во время работы программа захватывает памяти уже полтора десятка Гбайт, но на использование OMF это никак не влияет.
Я пошел еще дальше, и наложил ограничение на загрузку своих программ: код не может загружаться по начальному адресу, превышающему 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», сообщаю, что рад за таких программистов, но призываю подобных комментариев к данной статье не оставлять, поскольку информации к изложенной теме они не прибавляют.