Некоторое время назад я написал несколько статей о различных трюках, применявшихся в операционной системе DOS, чтобы вписаться в те жёсткие лимиты памяти, которые действовали в реальном режиме на архитектуре x86. Постоянно возникал и оставался без ответа один вопрос: а каковы были различные «модели», которые предлагались компиляторами тех времён? Взгляните, как выглядело меню для генерации кода в Borland Turbo C++:
![Меню генерации кода в Borland Turbo C++. Здесь выведен список тех моделей, которые мы рассмотрим в этой статье. Меню генерации кода в Borland Turbo C++. Здесь выведен список тех моделей, которые мы рассмотрим в этой статье.](https://habrastorage.org/getpro/habr/upload_files/f3d/cd2/aab/f3dcd2aab56652b10bd55be2dce22291.png)
Tiny (крошечный), small (маленький), medium (средний), compact (компактный), large (большой), huge (огромный)… Что означают эти опции? Каковы их эффекты? Ещё важнее… а так ли важен весь этот антиквариат сегодня, в мире 64-разрядных машин и гигабайтных ОЗУ? Чтобы ответить на этот вопрос, сделаем небольшой обзор архитектуры 8086 и тех двоичных форматов, которые поддерживались в DOS.
❯ Сегментация 8086
В архитектуре 8086 — именно той, для которой проектировалась операционная система DOS — ссылки в памяти состоят из двух частей: 2-байтного сегмента «идентификатора» и 2-байтного сдвига внутри сегмента. Эти пары часто выражаются как segment:offset
.
Сегменты — это непрерывные 64-килобайтные участки памяти, каждый из которых идентифицируется по собственному базовому адресу. Чтобы можно было адресовать целый 1 МБ памяти, поддерживаемый 8086, между сегментами оставляют зазоры по 16 байт каждый. Соответственно, сегменты частично перекрываются, и поэтому на конкретное местоположение в физической памяти может указывать множество ссылок вида сегмент/сдвиг.
![Представление двух следующих друг за другом сегментов 8086, демонстрирующее, как можно выразить один и тот же физический адрес памяти в виде разных пар сегмент/сдвиг. Представление двух следующих друг за другом сегментов 8086, демонстрирующее, как можно выразить один и тот же физический адрес памяти в виде разных пар сегмент/сдвиг.](https://habrastorage.org/getpro/habr/upload_files/7e8/138/e64/7e8138e64a77b5a3e4950d6533fcb4d6.png)
Например, сегментированный адрес B800h:0032h
соответствует физическому адресу B8032h
, который вычисляется как B800h * 10h + 0032h
. Притом, что эта пара является человеко-читаемой, на уровне инструкций машинного кода она запрограммирована иначе. При работе инструкции опираются на регистры сегментов, и в 8086 поддерживается четыре таких регистра: CS (сегмент кода), DS (сегмент данных), ES (дополнительный сегмент данных) и SS (сегмент стека). Зная это, для доступа к произвольной позиции в памяти требуется сначала загрузить B800h
в DS,
в потом поставить ссылку DS:0032h
.
При обращениях к памяти инструкции опираются именно на регистры сегментов, а не на идентификаторы сегментов не в последнюю очередь по соображениям эффективности. Чтобы закодировать регистр сегмента, требуется всего 2 бита (всего у нас будет 4 регистра сегментов) — по сравнению с 2 байтами, которые понадобились бы, чтобы сохранить базу сегмента. Подробнее об этом ниже.
❯ Файлы COM
Файлы COM — это максимально тривиальный формат исполняемых файлов, какой только можно себе представить. В них содержится необработанный машинный код, который можно разместить практически в любой области памяти, а после выполнения он не потребует никакой постобработки. Не будет никаких перемещений, не применяются разделяемые библиотеки, вообще не о чем беспокоиться. Можно просто скопировать двоичный файл в память как блок битов и запустить.
Такой механизм работает благодаря тому, как устроена сегментированная архитектура 8086: COM-образ загружается в любой сегмент памяти, причём, всегда со сдвигом 100h в рамках этого сегмента. Все адреса памяти в образе COM отсчитываются относительно этого сдвига (именно поэтому существует конструкция ORG 100h
, возможно, уже попадавшаяся вам ранее). При этом образу не требуется знать, какой именно сегмент был загружен. Загрузчик (в нашем случае это DOS, но вообще файлы COM происходят из CP/M) устанавливает CS, DS, ES и SS именно в этот сегмент и передает управление CS:100h
.
![Сверху показан COM-файл именно в таком представлении, в каком он хранится на диске. Внизу объяснено, как именно DOS выполняет загрузку этого COM-файла в два разных сегмента. Сверху показан COM-файл именно в таком представлении, в каком он хранится на диске. Внизу объяснено, как именно DOS выполняет загрузку этого COM-файла в два разных сегмента.](https://habrastorage.org/getpro/habr/upload_files/0ae/0f7/014/0ae0f701483af8e16a48400d9edd6271.webp)
Магия! COM-файлы — это, в сущности, файлы PIE (Исполняемый код, не зависящий от адреса), и для работы с ними не требуется никаких блоков управления памятью (MMU) или причудливых приёмов управления со стороны ядра.
К сожалению, не всё так радужно. COM-файлы ограничены по размеру, и в этом заключается проблема с ними. Поскольку каждый из таких файлов загружается в один сегмент, а длина сегмента составляет не более 64 КБ, самый крупный COM-файл может иметь размер 64 КБ минус 256 байт спереди, резервируемые для подсистемы PSP. В этот объём должны уместиться и код, и данные, а 64 килобайта — в принципе немного. Естественно, при работе программа COM полностью владеет процессором и может обращаться к любым областям памяти вне отдельно взятого сегмента, сбрасывая значения регистров CS, DS, ES и/или SS, но всё управление памятью остаётся на долю программиста.
❯ Файлы EXE
Чтобы справиться с ограничениями COM-файлов в DOS, Microsoft предложила для этой системы иной исполняемый формат: EXE-файлы, также известные под названием MZ-исполняемые файлы.
EXE-файлы отличаются от COM-файлов наличием внутренней структуры; при этом они не ограничены лимитом в 64 КБ. Соответственно, в них могут содержаться более крупные блоки кода и более объёмные данные. Но… как же так получается, учитывая, что размер в 64 КБ — это потолок для сегментов 8086? Ответ прост: в EXE-файле содержится множество сегментов, код и данные распределены по ним.
Чтобы можно было поддерживать множество сегментов во время исполнения, в заголовках EXE-файлов содержится информация о перемещении (relocation). В сущности, из этой информации загрузчик узнаёт, в каких позициях образа двоичного файла могут содержаться «неполные» указатели. Такие указатели потребуется исправить на уровне базовых адресов сегментов после того, как они будут загружены в память. DOS в данном случае действует в качестве загрузчика, и именно она отвечает за такое пропатчивание.
![Вот как выглядит файл EXE и содержимое одного из его сегментов с кодом. В коде видим ближний указатель и два дальних указателей. Их придётся пропатчить прямо во время исполнения так, чтобы они указывали на другие сегменты в двоичном файле. Вот как выглядит файл EXE и содержимое одного из его сегментов с кодом. В коде видим ближний указатель и два дальних указателей. Их придётся пропатчить прямо во время исполнения так, чтобы они указывали на другие сегменты в двоичном файле.](https://habrastorage.org/getpro/habr/upload_files/487/262/696/4872626962594567ae6c68065746bb7a.webp)
Но, всё-таки, сколько сегментов входит в состав EXE-файла? Зависит от ситуации, так как у разных программ разные нужды. Есть программы, которые в принципе настолько крошечные, что умещаются в единственном COM-файле. В других программах содержатся большие объёмы данных, но мало кода. Бывают и программы, в которых содержится множество кода и данных. И т.д.
В таком случае возникает вопрос: каким образом EXE-формат, рассчитанный сразу на все эти варианты, может эффективно их поддерживать? Именно для этого и становятся важны модели памяти, но, прежде чем поговорить о них, сделаем ещё одно отступление и разберём типы указателей.
❯ Типы указателей
Согласно принципу локальности, «обычно в течение краткого промежутка времени процессор обращается к одному и тому же множеству адресов в памяти». Это вполне логично: обычно код выполняется почти последовательно, а данные упаковываются в виде следующих друг за другом фрагментов памяти, например, массивов или структур.
Именно поэтому было бы расточительно пытаться выразить все адреса в памяти в виде 4-байтных пар segment:offset
. Именно в данной ситуации сегментация 8086 снова оборачивается в нашу пользу. Сначала можно загрузить регистр сегмента, содержащий базовый адрес «всех наших данных». После этого нам остаётся просто записать адреса как сдвиги в рамках этого сегмента. Чем реже приходится перезагружать регистры сегментов — тем лучше, поскольку уменьшается объём той информации, которую требуется переносить туда-сюда в каждой инструкции и в каждой ссылке на память.
Но не получится просто в любом случае обойтись сдвигами в рамках отдельно взятого сегмента, так как, возможно, нам придётся иметь дело не с одним, а с двумя и более сегментами. Сдвиги также бывают разных размеров, поэтому также было бы расточительно подбирать общий размер, в который умещались бы любые из них. Таким образом, нужно предусмотреть адреса в памяти или указатели разных размеров и форм, так, чтобы на любой практический случай нашёлся указатель, который лучше всего подходит именно для него.
![Представление двух коротких указателей: один адресует вышестоящий адрес в памяти (может быть, это переход вперёд, позволяющий миновать условное ветвление), а другой — нижестоящий адрес в памяти (может быть, это переход назад, к началу цикла). Представление двух коротких указателей: один адресует вышестоящий адрес в памяти (может быть, это переход вперёд, позволяющий миновать условное ветвление), а другой — нижестоящий адрес в памяти (может быть, это переход назад, к началу цикла).](https://habrastorage.org/getpro/habr/upload_files/9dc/6a7/31c/9dc6a731c1d447fb81e3e48a00797992.webp)
Короткий указатель занимает всего один байт и выражает адрес, записанный относительно той инструкции, которая сейчас выполняется. Такие адреса часто используются в инструкциях переходов, чтобы их двоичное представление оставалось компактным. Переход происходит в любой условной конструкции или цикле, а зачастую ветка условного перехода или тело цикла настолько коротки, что целесообразно минимизировать объём кода, нужного для выражения этих точек ветвления.
![Представление ближнего указателя. Представление ближнего указателя.](https://habrastorage.org/getpro/habr/upload_files/bc6/c75/6ac/bc6c756acf59546a1d15895f8988f2d3.webp)
При помощи ближних указателей можно ссылаться на адреса в пределах 64-килобайтного сегмента, которые, как подразумевает контекст, имеют по 2 байта в длину. Например, в инструкции вида JMP 12829h
обычно не требуется информация о сегменте, на который ссылается этот адрес, поскольку переходы почти всегда осуществляются в пределах того же CS, в котором находится код, спровоцировавший переход. Аналогично, инструкция вида MOV AX, [5610h]
предполагает, что заданный адрес содержит ссылку на выбранный DS, поэтому не приходится каждый раз выражать сегмент. Сдвиг, закодированный в ближнем указателе, может быть относительным или абсолютным.
![Представление дальнего указателя, ссылающегося на адрес в другом сегменте. Представление дальнего указателя, ссылающегося на адрес в другом сегменте.](https://habrastorage.org/getpro/habr/upload_files/063/2ed/5f6/0632ed5f6ffe25c1c8c1623777f97591.webp)
Дальние указатели могут ссылаться на любой адрес в памяти, кодируя как соответствующий сегмент, так и его сдвиг. Они имеют по 4 байта в длину. При использовании в арифметике указателей сегмент остаётся фиксированным, варьируется только смещение. Это важно, например, при переборе массивов, поскольку мы можем всего один раз загрузить базовый адрес в DS или ES, а затем оперировать сдвигом в пределах сегмента. Правда, это означает, что размер такой итерации может составить не более 64 КБ.
Огромные указатели напоминают дальние в том, что также имеют по 4 байта в длину и могут ссылаться на любой адрес в памяти, но при работе с ними не действует ограничение на 64 КБ в контексте арифметики указателей. Дело в том, что такие указатели заново вычисляют участки сегмента и сдвига при каждом обращении к памяти (напомню, что сегменты перекрываются, так что для любого физического адреса у нас получается множество пар «сегмент/сдвиг». Понятно, что для этого при каждом обращении к памяти требуется дополнительный код, поэтому огромные указатели заметно обременяют любую работу во время выполнения.
❯ Модели памяти
Итак, мы уже немало узнали о сегментации в 8086, EXE-файлах и типах указателей. Теперь мы, наконец-то, можем связать все эти феномены вместе, и окажется, что нет ничего таинственного в тех моделях памяти, что применяются в старых компиляторах, рассчитанных на работу с DOS.
Разберём по порядку:
Крошечная: это модель памяти, действующая в COM-образах. Вся программа умещается в одном 64-килобайтном сегменте, и все регистры сегментов также устанавливаются именно в его пределах, это необходимо для запуска программы. Таким образом, все указатели в рамках программы являются короткими или близкими, так как они всегда ссылаются на один и тот же 64-килобайтный сегмент.
Малая: повсюду используются близкие указатели, но сегменты с данными и стеком отличаются от сегмента с кодом. Таким образом, в программах отводится по 64 КБ на код и 64 КБ на данные.
Компактная: для кода применяются короткие указатели, а для данных — дальние. Соответственно, такие программы могут задействовать под данные пространство памяти в 1 МБ. Поэтому они особенно полезны в играх, где код должен располагаться настолько плотно, насколько это возможно, и при этом в нём нужно предусмотреть возможность быстро загрузить все ресурсы в память и расставить ссылки на них.
Средняя: противоположна компактной. Для работы с кодом используются дальние указатели, а для работы с данными — короткие. Эта модель странная, поскольку, если у вас есть программа с большим количеством кода, то логично ожидать, что в ней также будет обрабатываться много данных.
Большая: повсюду используются дальние указатели, поэтому и код, и данные могут в полном объёме ссылаться на всё адресное пространство размером 1 МБ. Однако, в силу самой природы дальних указателей, все сдвиги в памяти составляют по не более чем по 64 КБ, поэтому размеры структур данных и, в частности, массивов, получаются ограниченными.
Огромная: повсюду используются огромные указатели. В результате удаётся обойти все ограничения, предыдущей большой модели, поскольку гигантская модель выдаёт код, который вычисляет абсолютные адреса при каждом обращении к памяти и допускает создание массивов и структур, занимающих в памяти по 64 КБ и более. Естественно, за это приходится платить: код программы увеличивается, и издержки во время исполнения теперь гораздо больше.
Вот и всё!
Стоит подчеркнуть, что все эти модели есть соглашения, которыми старинный компилятор C руководствовался при порождении кода. Если вы пишете на ассемблере вручную, то можете по своему усмотрению сочетать и смешивать указатели разных типов и делать всё, что вам захочется, поскольку сами эти концепции не имеют никакого особого значения на уровне операционной системы.
❯ Развитие до современного состояния
Всё, о чём я рассказал в этом посте — это легаси, и вы вполне можете отбросить эту информацию как ненужную. Или нет?
В этом посте я не затронул, в частности, плотность кода и то, как она связана с производительностью. Ваш выбор указателей для кода прямо сказывается на плотности кода. Вот почему вычислительная техника развилась от 16-битных машин, таких, как 8086, до современных 64-битных машин. Представления указателей сильно выросли, и теперь нам то и дело приходится сталкиваться со сложным выбором.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
![Перейти ↩ Перейти ↩](https://habrastorage.org/r/w1560/getpro/habr/upload_files/5fc/4de/cda/5fc4decdad5e03afac9916f691a1aae1.png)