Некоторое время назад я написал несколько статей о различных трюках, применявшихся в операционной системе 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-файлы — это, в сущности, файлы 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-файла? Зависит от ситуации, так как у разных программ разные нужды. Есть программы, которые в принципе настолько крошечные, что умещаются в единственном COM-файле. В других программах содержатся большие объёмы данных, но мало кода. Бывают и программы, в которых содержится множество кода и данных. И т.д.
В таком случае возникает вопрос: каким образом EXE-формат, рассчитанный сразу на все эти варианты, может эффективно их поддерживать? Именно для этого и становятся важны модели памяти, но, прежде чем поговорить о них, сделаем ещё одно отступление и разберём типы указателей.
❯ Типы указателей
Согласно принципу локальности, «обычно в течение краткого промежутка времени процессор обращается к одному и тому же множеству адресов в памяти». Это вполне логично: обычно код выполняется почти последовательно, а данные упаковываются в виде следующих друг за другом фрагментов памяти, например, массивов или структур.
Именно поэтому было бы расточительно пытаться выразить все адреса в памяти в виде 4-байтных пар segment:offset
. Именно в данной ситуации сегментация 8086 снова оборачивается в нашу пользу. Сначала можно загрузить регистр сегмента, содержащий базовый адрес «всех наших данных». После этого нам остаётся просто записать адреса как сдвиги в рамках этого сегмента. Чем реже приходится перезагружать регистры сегментов — тем лучше, поскольку уменьшается объём той информации, которую требуется переносить туда-сюда в каждой инструкции и в каждой ссылке на память.
Но не получится просто в любом случае обойтись сдвигами в рамках отдельно взятого сегмента, так как, возможно, нам придётся иметь дело не с одним, а с двумя и более сегментами. Сдвиги также бывают разных размеров, поэтому также было бы расточительно подбирать общий размер, в который умещались бы любые из них. Таким образом, нужно предусмотреть адреса в памяти или указатели разных размеров и форм, так, чтобы на любой практический случай нашёлся указатель, который лучше всего подходит именно для него.
Короткий указатель занимает всего один байт и выражает адрес, записанный относительно той инструкции, которая сейчас выполняется. Такие адреса часто используются в инструкциях переходов, чтобы их двоичное представление оставалось компактным. Переход происходит в любой условной конструкции или цикле, а зачастую ветка условного перехода или тело цикла настолько коротки, что целесообразно минимизировать объём кода, нужного для выражения этих точек ветвления.
При помощи ближних указателей можно ссылаться на адреса в пределах 64-килобайтного сегмента, которые, как подразумевает контекст, имеют по 2 байта в длину. Например, в инструкции вида JMP 12829h
обычно не требуется информация о сегменте, на который ссылается этот адрес, поскольку переходы почти всегда осуществляются в пределах того же CS, в котором находится код, спровоцировавший переход. Аналогично, инструкция вида MOV AX, [5610h]
предполагает, что заданный адрес содержит ссылку на выбранный DS, поэтому не приходится каждый раз выражать сегмент. Сдвиг, закодированный в ближнем указателе, может быть относительным или абсолютным.
Дальние указатели могут ссылаться на любой адрес в памяти, кодируя как соответствующий сегмент, так и его сдвиг. Они имеют по 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)
? Читайте также:
Комментарии (8)
nv13
08.02.2025 08:19Интересно, конечно) Вся страна играла в Wolfenstein, а потом в Doom, но пользовалась Борландом вместо Watcom, в котором под DOS4GW (и нескольких других менее популярных экстендерах) был легко доступен 32 разрядный режим с линейной адресацией. Почему?) Возможно потому, что в Watcom не было IDE с контекстным хелпом и встроенных графических средств конфигурирования проектов.
maxlilt
08.02.2025 08:19С какими моделями памяти использовались оверлеи?
qw1
08.02.2025 08:19Оверлей - кусок данных/кода, который лежит за пределами структуры exe-файла, или даже в отдельном файле. Компилятор про него ничего не знает, а программист может кусок использовать на своё усмотрение, в любой модели памяти.
geher
08.02.2025 08:19Компилятор (по крайней мере борландовский) про него как раз вроде знал и добавлял соответствующий код для подгрузки кода из оверлеев. Точно уже не помню, могу ошибаться, но вроде что-то там было на уровне компилятора для работы с оверлеями.
NickDoom
08.02.2025 08:19Только вчера, на ночь глядя, в припадке ретро-кодинга накололся: Ватком, большая модель, один массив декларирован как __huge (он размером больше сегмента). При попытке в него писать любыми библиотечными средствами (хоть мемсетом), принимающими MyHuge+MyIndex, даром что результат имеет тип __huge ptr — всё равно они пишут как в __far, то есть просто игнорят то, что сегмент подошёл к концу, и в какой-то момент начинают гадить в начало сегмента (переполнение смещения).
К счастью, читать из файла надо было кусочками меньше одного сегмента, поэтому перед вызовом библиотечной функции я руками стал приводить __huge к __far — то есть тупо оставлял в смещении последние биты адреса (внутри параграфа), а всё, что можно, переносил в сегмент. Получался каждый раз большой «запас» впереди на чтение, и всё заработало.
Но обнаружил не сразу, конечно. Долго локализовывал, пока не «поймал за руку» стандартную библиотеку :) Оказывается, они не умеют в __huge :-D ну и хрен бы с ними, я умею в __huge.
ednersky
интересно, что ни один компилятор тогда не предоставлял "автоматической" системы адресации, хотя она, казалось, лежала на поверхности.
данные внутри функции обычно помещаются в tiny, а переходы между функциями могут вычисляться хоть рантайм.
AlB80
Как такое реализовать между единицами трансляции? Адреса это также параметры функций. Соответственно, автоматическая система адресации могла такого накрутить, что потом слинковать было бы проблемой. В сях слинковалось бы, но я такое боюсь запускать.