Сегментная адресация памяти
Наиболее распространенная модель адресации памяти — плоская, когда у каждого элемента памяти есть глобальный адрес. Но это не единственный способ работы с памятью, в данной статье я хочу рассмотреть одну из альтернатив — сегментную адресацию. Будут расмотрены несколько исторических систем, реализующих этот подход, преимущества сегментной адресации с точки зрения масштабирования и безопастности, а также высказаны гипотезы о причинах, по которым он не прижился (спойлер: буду ругать язык C и операционную систему Unix).
В подавляющем большинстве компьютерных систем для работы с некоторой ячейкой памяти необходимо как-то указать ее адрес, как правило 16-, 32- или 64-разрядное число. Количество бит в адресе часто называют разрядностью системы. Часто дополнительно используется механизм «трансляции страниц», который отображает области виртуальной памяти пользовательского приложения в физическую память, которой управляет операционная система. Но в каждый момент времени активна только одна «таблица страниц» и с точки зрения приложения (а во многом и с точки зрения ядра ОС) память остается плоской.
Рассмотрим старый процессор Intel 86/88/186. Размер регистров этих процессоров всего 16 бит, что позволяет адресовать только 64 килобайта памяти. Когда эти микросхемы разрабатывались, такого размера памяти уже не хватало для многих приложений, а 32-разрядные процессоры были слишком дороги. Проблему решили добавив в архитекруту сегментные регистры. При обращении к памяти по 16-битному адресу (хранящемуся в реристре общего назначения или прямо в коде команды) прибавлялось значение сегментного регистра, сдвинутое на 4 бита (что тоже самое, умноженное на 16) и полученное значение использовалось как физический адрес. Такой подход позволял адресовать до одного мегабайта памяти. В архитектуре персональных компьтеров IBM PC, созданных на базе этих процессров, часть адресного пространства была зарезервирована для системных нужд, а пользовательским приложениям и ОС было доступно до 640 килобайт. Но не все так просто.
К тому времени в области разработки ПО себя очень хорошо зарекомендовал язык C, который позволял писать очень эффективные, но достаточно переносимые между разными платформами программы. Одной из основных фич этого языка была адресная арифметика. Именно благодаря ей удавалось писать код, эффективно использующий железо, но в то же время не опускаться до ассемблера.
Как можно реализовать язык C на i86? Какой выбрать размер указателя на ячейку памяти? Первый вариант — использовть 16-битный адрес. Архитектура процессора поддерживала 4 сегментных регистра — CS, DS, ES и SS. CS всегда содержит сегмент кода. Язык C не предоставляет стандартных средств для модификации кода на лету, что позволяет комилятору и операционной системе более менее вольно с ним обращаться. Если весь код помещается в 64 КБ, то все хорошо, если нет — компилятор вынужден использовать более дорогие межсегментные вызовы и переходы, но это сравнительно небольшая цена. Некоторую сложность создают указатели на функции, которые требуются по стандарту C. Интерфейс функций вызываемых в пределах одного сегмента и с помощью межсегментных вызовов отличался — межсегментные вызовы дополнительно сохраняли в стеке старое значение CS, а для локальных вызовов это не требовалось. Кроме того размер адреса функции был разный — для внутрисегментной достаточно было 16-бит, межсегментная должна хранить сегмент и смещение в этом сегменте. (Компилятор мог бы располагать функции на адресах, кратных 16, и ссытаться только по значению сегмента, но я не видел реализацию C или другого языка, которая бы так умела.)
Регистр DS предназначен для сегмента данных — его использовали по умолчанию практически все команды работы с памятью (кроме некоторых строковых команд, использовавших ES, и обращений в стек, о которых чуть позже). Таким образом если для данных хватало 64 КБ, то можно все их хранить в одном сегменте и использовать 16-битный указатель.
Большинство компиляторов (но не все) на каждый вызов функции создают stack frame, область стека, которая содержит адрес возврата, аргументы и локальные переменные. В i86 для стека используется сегментный регистр SS. Операции push/pop, вызовы подпрограмм и выход из них неявно использовали регистр SP (stack pointer). В i86 не было команд, явно использующих для адресации SP, поэтому компилятор в начале подпрограммы копировал значение SP в регистр BP (base pointer), и для работы с аргументами и локальными переменными использовал его. При адресациии через BP по умолчанию использовался сегмент стека. В i386 появилась возможность явно использовать SP для обращения в память, в этом случае также использовался сегмент стека. Таким образом архитектура позволяла независимо использовать 64 КБ данных и 64 КБ стека, а таже реализовать многозадачность с моделью «общий хип, часные стеки», но…
Но язык C требует возможности получения указателя на произвольный объект, глобальный, аллокированный в сегменте данных или на стеке, в том числе на локальные переменные и аргументы функций. Таким образом, если мы хотим использовать 16-битный указатель, мы должны объединить сегменты данных и стека.
Компиляторы C для i86 поддерживали несколько моделей памяти, каждая из которых имела свои недостатки и ограничения. Кроме того, независимо от модели поддерживалось несколько дополнительных типов указателей — far и huge, которые хранили сегмент и смещение. Отличие было в поддержке адресной арифметики. Far предполагал что все адресуемые через него данные находятся в том же сегменте. Huge поддерживал полноценную адресную арифметику, для чего компилятор генерировал дополнительный код, проверяющий пересечение границы сегмента и пересчитывающий значение сегмента при необходимости.
Таким образом на x86 терялись главные преимущества языка C — эффективность и переносимость.
Архитектура x86 явно использовала 20-битную адресацию памяти и расширить ее не теряя совместимости с существующими приложениями было не просто. Тем не менее в i286 было реализовано интересное решение. Сегмент теперь использовался не как базовый адрес, а как индекс в таблице, по этому индексу извлекались тип, права доступа, размер сегмента и адрес в физической памяти. Значение селектора сегмента делилось на три поля — 13-битный индекс в таблице сегментов, 1-битный признак локальности и 2 бита отводилось для упавления правами. В зависимости от признака локальности, описание сегмента бралось из глобальной или локальной для задачи таблицы дескрипторов сегментов. Дескриптор задачи сам являлся дескриптором сегмента особого типа. Таким образом операционная система могла создать до 2^13 задач, каждая из которых владела бы 2^13 сегментами, каждый по 64 КБ, что составляет аж 4096 террабайт виртуальной памяти. Но структура таблицы поддерживала только 24-битный физический адрес начала сегмента и физической памяти можно было использовать только 16 мегабайт. В дополнение к увеличению доступной памяти была реализована защита памяти, что позволило запускать вполне серьезные операционные системы — Unix (Xenix), Windows 3.1, OS/2.
Ценой этого стали дорогими операции зарузки в сегментные регистры — приходилось лезть в память за описанием сегмнета и проверять доступность сегмента для задачи. Проблему можно было бы сгладить кешом дескрипторов сегментов или с помощью переименования регистров, но инженеры Intel на такое усложнение не пошли.
Ограничение на 64 килобайтные сегменты сохранилось, кроме того реализация huge-указателей требовала бы сложной поддержки в операционной системе. То есть большая часть вкусностей новой архитектуры оказалась недоступна для C-программистов, а это был уже самый популярный язык.
Настоящий прорыв в архитектуре x86 случился с появлением процессора i386. Процессор стал 32-битным, 32-битными и стали регистры общего назначения, и размеры сегментов. Так же увеличился объем доступной оперативной памяти вместе с битностью адреса начала сегмента. В дополнение появилось еще два сегментных регистра — FS и GS, что должно было немного снизить потери на операциях загрузки сегментов. Но старые программы не могли использовать 32-битной адресации.
Кроме 32-битного режима в i386 появилась трансляция страниц. Это позволило запускать современные системы — Unix и Windows NT. 4 гигабайт адресуемой памяти хватает любым приложениям, и использование сегментов уже не требовалось, все можно поместить в один сегмент. Архитектура Unix была рассчитана на плоскую память языка C (включая код и данные, для удобства реализации загрузчика), и этот подход быстро был поддержан в других ОС.
В архитектуре amd64 использование сегментов сильно ограничили. Регистры CS, DS, ES и SS всегда указывают на всю виртуальную память задачи. Доступны только FS и GS, которые некоторые ОС используют для хранения локальных данных нитей.
x86 — хорошо известная, но не единственная архитектура с сегментной адресацией. Эта статья была задумана с целью немного рассказать про Plessey System 250 и Intel iAPX 432.
Тип сегмента x86 определял содержит он код или данные. В данные попадали и обычные данные, и указатели, и селекторы сегментов. Права на сегмент проверялись при попытке загрузить его в сегментный регистр или выполнить на него переход. А если пойти дальше, и специфицировать, какие сегменты могут содержать ссылки на другие сегменты, а какие не могут? Так и было сделано в Plessey System 250 (PP250).
PP250 — очень древняя система, выпущенная в 1974 году (для сравнения Intel 8086 начал производиться в 1978). К сожалению, я не смог найти описания ее системы команд или готовый эмулятор, поэтому описываю архитектуру памяти исходя из найденных статей. В этой системе поддерживалось несколько типов сегментов, в зависимости от типа сегмент содержал код, данные или ссылки на другие сегменты. Так как у прикладной программы не было возможности получить ссылку на сегмент из сегмента данных, отделять селектор и дескриптор сегментов, как это сделано в Intel 286+, необходимости не было. Также у разработчиков оставалась возможность менять формат ссылок на сегмент в разных версиях системы не теряя совместимости прикладного ПО (например, при увеличении количества доступной памяти), но, на сколько я знаю, она так и не была реализована.
Во многих языках программирования есть возмоность создавать пользовательские типы данных, которые объединяют несколько объектов других, как встроенных, так и пользовательских типов, включая ссылочные типы. В PP250 такой объект не получается поместить в один сегмент, так как сегмент мог содержать либо ссылки, либо данные. Эта проблема была решена в Intel iAXP 432.
Intel iAXP 432 с ОС iMAX 432 начал разрабатываться в 1975 и был выпущен в 1981. Архитектура iAXP 432 оказала заметное влияние на организацию памяти в Intel 286, выпущенном годом позже. Основным ЯП для этой системы был язык Ada, но предполагалась поддержка и других языков, в частности Lisp. Одним из преимуществ таких архитектур является различимость указателей и данных, что упрощает реализацию важной для Lisp сборки мусора.
Сегмент iAPX 432 состоял из двух частей — области ссылок и области данных. Таким образом на уровне системы команд не было возможности попытаться превратить данные в указатель (в PP250 попытка загрузить ссылку из сегмента данных могла быть отслежена только во время исполнения).
Интересно, что разделение указателей и данных встречалось и в традиционных архитектурах, рассчитанных на плоскую память. Например в процессоре Motorola 68000 было два регистровых файла — регистры данных и регистры адресов. С точки зрения разработчика аппаратуры это упрощает реализацию конвейера и экономит биты в кодах команд. Правда с точки зрения разработчиков компиляторов это только усложняло алгоритмы распределения регистров. Похожая схема реализована в некоторый CPU для встраиваемых систем, таких как Blackfin.
Хотя сегментная адресация и имеет существенные преимущества в безопасности и масштабируемости, она плохо совместима с языком C и современными ОС, и не получила существенного распостранения. Тем не менее, познакомиться с ней полезно, я надеюсь она еще будет применена, например при проектировании основанных на байткодах виртуальных машин.