arthur2 писал(а):Отличие глобальных и локальных хендлов только в том, где потом они хранятся и откуда они могут быть использованы, но это всё равно просто адреса начала выделенных данных.
Всё совсем не так.
В 16-битных Windows был совершенно другой подход к организации памяти. Это сейчас тот факт, что каждый процесс имеет изолированное АП, и что АП делится на страницы, кажутся естественными и непоколебимыми. Но в 16-битном мире всё было совсем не так.
Начнём с того, что авторы архитектуры Intel придумали гениальную фишку, которую индустрия в итоге не оценила, опошлила и потеряла: сегментную организацию памяти. Идея была в том, что вместо того, чтобы рассматривать всю память как одну большую непрерывную зону, где единственный параметр, определяющий конкретное место памяти, — это адрес, можно сделать так, чтобы исполняемый процессором код видел память как набор объектов, представляющих собой большие или маленькие регионы памяти, не пересекающиеся или же пересекающиеся между собой.
Вместо одного одномерного адресного пространства с плоскими одномерными адресами предлагалось иметь многомерное пространство, состоящее из одномерных подпространств, где вместо плоских адресов использовались бы пары из идентификатора объекта и смещения внутри него.
То есть это даровало объектно-ориентированный подход к тому, как код видит память и как работает с памятью. И это очень похоже на то, как изобретение файловых систем изменило программирование: хотя за файлами скрывается диск, представляющий собой просто непрерывное хранилище с адресами от нуля до самого последнего сектора, программы больше работают с диском напрямую, не делают чтения и записи, указывай адрес, отсчитываемый от начала диска. Программы теперь пишут и читают с диска, работая через файлы, указывая идентификатор файла и смещение внутри самого файла. Как результат: программы не беспокоятся о проблемах фрагментации, не беспокоятся о том, что файл может быть перемещён в пределах диска с одного места диска на другое, не беспокоятся, как бы случайно при записи не задеть содержимое другого файла.
Главная полезная вещь, которую давали сегменты: это контроль доступа (можно ли читать, можно ли писать, можно ли исполнять) и контроль границ. Это с плоским (flat) адресным пространством мы, пробегаясь по какому-то буферу, массиву, длинной строке и делая чтение или запись, можем случайно выбежать за его пределы и начать читать какие-то совершенно другие непричастные данные. С сегментами, которые можно рассматривать как аппаратные массивы (или аппаратные буферы, аппаратные контейнеры данных) любой выход за границы сегмента будет пресекаться на корню, и что самое главное — эта защита от выходов за границы, как и защита от несанкционированного доступа, выполнена аппаратно.
Коду не нужно тратить драгоценное время и содержать дополнительные инструкции для проверки того, не вышли ли мы за границы массива/буфера/объекта — проверка делается не явно, а скрытно, автоматически и средствами схем самих процессоров.
Все уязвимости типа «переполнений буфера», перезаписи кода, и просто банальные глюки из-за выхода за границы буфера и записи туда, куда не нужно, были бы решены разом, и решены на аппаратном уровне, а не программном. Чтобы создать такую уязвимость, надо было бы не как сейчас забыть сделать проверку, а целенаправленно задать сегменту чрезмерно большой лимит (определяющий границу сегмента).
Под капотом, точно также, как пара
имяфайла:смещениевфайле в конечном счёте транслируется сначала в смещение в рамках логического тома (смещение от начала тома), а затем в смещение в рамках физического диска (смещение от начала диска), пара
селекторсегмента:смещение под капотом транслируется сначала в плоский линейный адрес, а линейный адрес транслируется в физический адрес. Это если мы говорим о 32-битном защищённом режиме с включенным страничным режимом. В 32-битном защищённом режиме с выключенным страничным режимом или в 16-битном защищённом режиме, где страничного режима не было вообще, пара
селекторсегмента:смещение сразу преобразовывалась в физический адрес, или, иначе говоря, линейное пространство адресов было тождественно и эквивалентно пространству физических адресов. В отличие от файловой системы, которая пару из имени файла и смещения внутри файла преобразовывала в дисковые координаты, пару из селектора сегмента и смещения внутри сегмента в плоские (одномерные) адреса преобразовывал сам процессор своими, грубо говоря, электрическими цепями, поэтому это преобразование выполнялось быстро и без необходимости какие-то действий со стороны кода.
А теперь коснёмся непосредственно селектора сегмента. Что это такое? С точки зрения пользователя этой некий идентификатор сегмента, то есть идентификатор, идентифицирующий блок памяти, к которому можно обращаться через этот селектор (в паре со смещением внутри блока). С точки зрения внутреннего устройства селектор это 16-битное число, состоящее из трёх битовых полей: индекс дескриптора сегмента в таблице дескрипторов, флаг, определяющий, какая из двух (
глобальная или
локальная) таблиц дескрипторов используется, и поле, определяющая требуемый уровень доступа.
Так вот, прежде чем начать пользоваться сегментом, нужно создать — нужно определить для процессора, что это за сегмент, какие его свойства, какой его размер, и какие правила преобразования логического адреса (пара селекторсегмента:смещение) в линейный (плоский одномерный адрес) или физический должны быть использованы. Правила преобразования, на самом деле, предельно простые: у сегмента есть
база и
лимит, при обращении к определённому байту сегмента по некоторому смещению,
база складывается со
смещением и получается плоский линейный адрес, который при этом контролируется, чтобы он был меньше, чем
база+
лимит.
Вся подобная информация о сегменте содержится в дескрипторе сегмента (это просто структура), а дескриптор сегмента заносится в таблицу дескрипторов. Индекс дескриптора в этой таблице и становится основной частью селектора сегмента.
Таких таблицы две:
глобальная и
локальная. Идея в том, что в многозадачной системе захочется иметь набор глобальных сегментов, которые будут одновременно видимы всем задачам, и наборы локальных сегментов (каждой задаче будет виден только свой набор локальных сегментов).
Поэтому в реальности, некая абстрактная операционная система (в полную силу использующая преимущества сегментов) будет иметь одну глобальную дескрипторную таблицу (GDT), и какой-то набор локальных дескрипторных таблиц (LDT), например, по числу работающих задач.
Для GDT у процессора есть специальный регистр GDTR, хранящий адрес (плоский, одномерный) начала GDT в памяти.
Для текущий LDT у процессора есть специальный регистр LDTR, который хранит... нет, не адрес, а почти что адрес LDT в памяти. Хотя LDTR мог бы, подобно GDTR, хранить адрес LDT в памяти, в реальности LDT должна храниться отдельном сегменте, и в регистре LDTR вместо адреса LDT содержится селектор сегмента, внутри которого лежит LDT.
При переключении задач, когда (как правило) значение всех регистров процессора сохраняется (до следующего раза, когда эта задача опять начнёт выполняться), и заменяется на запомненные ранее значения регистров другой задачи (на которую мы сейчас переключаемся), значение LDTR запоминается и заменяется точно так же, как значения регистров общего назначения. Так что LDTR входит в контекст задачи.
Отдельно можно поговорить о недостатках, которые имела сегментная модель, и которые можно было исправить (по мере развития архитектуры Intel), но которые не стали исправлять.
Логические адреса всю жизнь состояли из пары
селекторсегмента:смещение.
Селектор всю жизнь был 16-битным.
Смещение в 16-битных процессорах и 16-битных режимах 32-битных процессоров были 16-битными.
Смещения в 32-битных процессорах стали 32-битными.
Это давало 32-битные логические адреса в 16-битных режимах и 48-битные логические адреса в 32-битных режимах.
Хотя на первый взгляд кажется, что 32-битные логические адреса позволяли бы адресовать 2
32 (4 Гб) отдельный байтиков, а 48-битные адреса — аж 2
48 отдельных ячеек памяти, в реальности всё куда скромнее.
Во-первых, хотя 16-битные селекторы могут принимать 65536 возможных значений, они не являются уникальными идентификаторами сегментов и не могут идентифицировать 65536 уникальных сегментов. Не все 16 бит селектора являются индексом сегмента, а лишь 13 старших битов хранят индекс. Младшие 2 бита селектора это поле RPL, третий бит справа определяет, в какой таблцие сегментов содержится дескриптор для нужного нам сегмента.
Поэтому селекторы
1230,
1231,
1232 и
1233 ссылаются не на 4 разных, а на один и тот же сегмент (описанный в GDT). Это можно сравнить с тем, что у нас на диске есть один файл, но мы с помощью
CreateFile открыли этот файл несколько раз и получили несколько разных хендлов: один хендл позволяет только читать из файла, но не писать, потому что при вызове
CreateFile было запрошено только право на чтение, а другой хендл ссылается на тот же самый файл, но используя этот хендл можно и читать, и писать, потому что при вызове
CreateFile были запрошены другие, более полные права.
Так как младшие 2 бита селектора, какие бы значения они ни принимали, не меняют то, какой сегмент идентифицируется селектором, пространство логических адресов сужается с 2
32 до 2
30 (в 16-битных режимах работы процессора) и с 2
48 до 2
46 (в 32-битных режимах).
Во-вторых, логические адреса в итоге транслируются либо сразу в физические, либо сначала в линейные, а затем физические. Выбор между двухступенчатой и трёхступенчатой схемой трансляции адресов осуществляется сменой значения флага PG регистра CR3. Как бы там ни было, ширина линейного адреса — 2
32, поэтому толку с с логических адресов шириной 2
46 оказывается очень мало. Хотя мы можем иметь 2
46 уникальных логических адресов, они все в совокупности будут ссылаться на куда более скромное множество из 2
32 уникальных ячеек памяти, и на все или чатсь таких ячеек памяти в логическом пространстве будут целая куча эээ... псевдонимов. Ширина же физических адресов (адресов физ. памяти) определялась железом, и лишь с появлением у процессоров режима PAE достигла 36-бит, что давало возможность всем задачам в совокупности использовать больше 4 Гб памяти в сумме, но одна отдельно взятая задача не могла использовать (точнее просто «видеть») больше 4 Гб памяти, потому что это ограничение проистекает из ширины линейных адресов. А логические адреса (пространство которых больше 4 Гб) транслируются сперва в линейные, а потом в физические. Промежуточный этап в виде линейных адресов можно было бы исключить: если отключить страничную грануляцию памяти (бит PG регистра CR0), логические адреса будут преобразовываться сразу в физические (точнее сказать, линейное пространство адресов и физическое пространство станут тождественными), однако в дескрипторе сегмента есть поле
база (которое при сложение со смещением даёт плоский одномерный адрес), и это поле 32-битное, так что получить физические адреса больше 0xFFFFFFFF (4 Гб) всё равно никак не получилось бы, а одновременно с этим отключение бита PG отрезает для нас страничное преобразование (хитрое преобразование, преобразующее линейные адреса в физические адреса, дробя логический 32-битный адрес на пару индексов и используя сначала первый, а затем второй индекс, чтобы через пару таблиц вычислить, какая страница физпамяти соответствует адресуемой страницы линейного пространства) — а отключение страничного преобразования отрезает PAE с его 36-битным расширением физических адресов. То есть мы не только не добьёмся того, чтобы одна задача смогла видеть сразу больше 4 Гб (2
32) памяти, используя 46-битные логические адреса, но и потеряем возможность для всех задач в сумме использовать больше 4 Гб.
И всё таки существует трюк, который позволит (позволил бы) одной задаче, имя пространство логический адресов размером 2
46 (что гораздо больше, чем 4 Гб) видеть и работать с памятью, больше чем 4 Гб. Но к этому мы буквально скоро вернёмся.
Третья проблема сегментов в том, что селектор имеет размер 16 бит, из них только 13 бит отведено под индекс сегмента в соотв. таблице, что даёт нам 2
13 = 8192 возможных индексов. Это означает, что на всю систему число глобальных сегментов (общих, между всеми задачами) не может превышать этот теоретический лимит, и, если говорить об отдельно взятой задаче, то в её LDT не может быть больше 8192 дескрипторов.
То есть теоретический максимум сегментов, которыми может распоряжаться отдельно взятая задача: 8192 сегментов в GDT (общие для всех задач) и 8192 сегментов в LDT. Если наша программа будет под каждый маленький объект, под каждый массив, под каждую структуру, под каждую строчку выделять память в лице отдельного сегмента (каждый объект будет жить в собственном сегменте, каждый массив будет жить в собственном сегменте), то наша программа не сможет выделить больше 8 тысяч блочков памяти под объекты/массивы/структуры. Тот факт, что сумма объектов, массивов и тому подобных сущностей, которые может породить и держать одновременно существующими одна программа, ограничена числом около 8 тысяч кажется дикой и возмутительной, и кажется, что это проблема сегментов. Но, на самом деле, ответ не такой однозначный.
Задача — это концепция и сущностью, предлагаемая нам архитектурой процессора с его аппаратным механизмом многозадачности.
Процесс и поток — это концепции и сущности, предлагаемые нам уже архитектурой современных ОС, и процессор о них мало что знает и не требует использовать такие концепции.
Программа — это вообще концепция, предлагаемая людьми для людей.
Мы привыкли, что запущенная программа — это процесс, и что процесс — это один или несколько потоков. За потоками (как понятием операционной системы стоят задачи (как понятие процессорной архитектуры), каждый поток это отдельная задача, и многопоточность обеспечивается тем, что процессор умеет обеспечивать многозадачность, переключаясь между задачами и изменяя контекст (сохраняя и подменяя значения всех регистров при переключении с одной задачи на другую).
Но кто сказал, что это — единственный возможный подход, и что этот подход надо принимать за догму? Существует и сильно другой подход: процессорную сущность «задача» можно использовать не для одного потока, а к более мелкой части программы. Саму программу можно рассматривать не как набор функций, код которых последовательно выполняются, и которые передают управление из одной в другую, а как набор функциональных блоков, обменивающихся друг с другом сообщениями. Существуют целые языки программирования, где нет вызовов функций, но есть обмен сообщений между некими объектами. Модель оконных сообщений в Windows тоже частично следует этой парадигме, что кроме процедурного подхода, может быть и другой, основанный на обмене сообщениями между асинхронно функционирующими (потенциально — одновременно исполняющимися) сущностями.
И тогда да, кажется, что иметь не более 8 тысяч сегментов на одну программу или один поток программы — это очень мало. Но кто сказал, что нужно иметь одну задачу на один поток? Ведь задача, если вдуматься в смысл самого слова, это не обязательно поток. Почему бы не поместить, грубо говоря, каждую отдельную функцию в свою отдельную задачу? А если точнее, то почему бы не поместить отдельный функциональный блок в свою отдельную задачу?
Скажем, у нас есть браузер. Ему нужно периодически сжимать и разжимать данные, сжатые с помощью gzip. В текущем подходе для этого в коде браузера будет функция для компрессии/декомпрессии gzip-сжатых данных. И в каждом потоке, когда надо что-то сжать или разжать, эта функция будет вызвана, ей будет передано управление, а потом возвращено в вызывающий код.
А есть совершенно другой подход: механизм gzip-сжатия можно выделить в отдельную задачу. У этой задачи может быть очередь входящих заданий: вход и выход данных. Когда нашему приложению нечего сжимать, эта задача спит, процессор просто не переключается на неё. Но когда кому-то надо что-то декодировать, он кидает задание в очередь заданий gzip-механизму, и в рамках отдельной процессорной задачи gzip-код выполняет свою непосредственную работу. И если думать о задачах как о маленьких функциональных единицах программы (отдельно рендерер текста, отдельно gzip-механизм, отдельно спелл-чекер), то лимит в 8 тыс. локальных и 8 тыс. глобальных сегментов уже не кажется таким маленьким.
Если выделить код преобразования ANSI-текста в юникод, и юникода в ANSI в отделную задачу, то этой задаче (с помощью аппаратных возможностей процессора) можно настроить такое окружение, что кроме собственного кода, сегмента с входными данными, сегмента под выходные данные и сегмента с таблицей правил преобразования символов, ей не будет доступно ничего. Какая бы страшная ошибка ни была в коде преобразования юникода, какие бы страшные данные на вход не подали этому преобразователю, её код был в жесткой изоляции от всего остального, в песочнице (sandbox), и дальше если всё выйдет из под контроля, вышедший из под контроля код не сможет ничего испортить. Он сможет записать какую-нибудь гадость в буфер под выходные данные — и не более того. Буфер под входные данные будет только для чтения, код самой задачи будет только для чтения, задача сможет испортить свой стек. Таблица преобразований символов будет тоже только для чтения.
Это ли не мечта всех безопасников и параноиков? Разбить программу на маленькие функциональные блоки и максимально изолировать их друг от друга, предоставив каждой задачи только тот абсолютный минимум данных с абсолютно минимальными правами, которые только необходимы для этого? А не так, как сейчас, когда функция преобразования текста может неправильно посчитать указатель, и случайно перезаписать как-нибудь бредом совершенно левый участок памяти, в котором содержится дерево UI-контролов или код сохранения документов, и испортить жизнь не только себе, но и всем другим функциональным компонентам программы, всем потокам, то есть, в целом, загубить целый процесс.
И это бы не означало, что на уровне программирования пришлось бы отказаться от чисто процедурных языков.
Сейчас, когда мы вызываем SendMessage и шлём сообщение окну из чужого потока, наш поток через механизмы межпоточного взаимодействия даёт сигнал другому потоку (помещает сообщение в очередь) и просто засыпает в ожидании, когда чужой поток ответит (а он может не ответить никогда). Под капотом оно использует нечто вроде WaitForSingleObject, а на уровне диспетчера задач процессора это оборачивается тем, что потоку, вызвавшему SendMessage, просто не дадут возможности дальше выполняться, пока другой поток не удовлетворит условие ожидания.
Обычный процедурный вызов оборачивается тем, что один поток засыпает и ждёт, пока другой поток не сделает часть своей работы. Ну и чем это отличается от использования задач, когда задача — не олицетворение потока, а олицетворение отдельно взятой процедуры, и вызов процедуры приводит к засыпанию вызывающей задачи и активации вызываемой (которая могла быть активной и до этого и работать в духе обработчика очереди — разгребать очередь входящих заданий и выдавать результаты, выдача которых размораживала бы те задачи, которые заснули до момента завершения своих заявок)?
К сожалению, для такой революционной концепции не было ни одного готового приемлемого ЯП. Например Си имеет просто указатели, и не имеет никакой поддержки для двухуровневой адресации в стиле селекторов и смещений. Для этого был бы нужен некий объектно-ориентированный (или даже блочно-ориентированный) язык, в котором был бы отдельный тип для ссылок на блоки, и отдельный тип для идентификации сущностей внутри блоков.
Ладно, возвращаемся к этим словам:
И всё таки существует трюк, который позволит (позволил бы) одной задаче, имя пространство логический адресов размером 246 (что гораздо больше, чем 4 Гб) видеть и работать с памятью, больше чем 4 Гб. Но к этому мы буквально скоро вернёмся.
И в 16-битном, и в 32-битном защищённом режиме процессора в дескрипторе сегмента есть флаговый бит «P» — Present. Он обозначает, присутствуют ли данные, доступ к которым обеспечивается этим сегментом, в памяти или нет. Если поле сброшено в ноль, при обращении к данным сегмента процессор генерирует исключение #NP, для которого ОС (или софт в общем случае) может назначить свой обработчик и что-то с сегментом сделать.
Сегодня под механизмом подкачки все понимают главным образо подкачку страниц: всё АП процесса делится на страницы, и часть страниц, которые в своём АП видит программа, могут отсутствовать в физический памяти, но при обращении к страницам они будут подгружены из файла подкачки или файла-образа на диске, а некоторые другие (ненужные) страницы могут быть наоборот выгружены.
Такой механизм подкачки появился лишь в процессоре 80386 с добавлением режима страничной организации и страничного преобразования, но ещё в 80286 своппинг (подкачка) был и была его поддержка в Windows начинается, кажется, с версии 2.0. Только это была не подгрузка и выгрузка отдельных страниц, а подгрузка и выгрузка целых сегментов целиком.
Выгруженному сегменту в дескрипторе ставилось P=0, а при повторном обращении к нему срабатывал предоставленный системой обработчик исключения #NP (not present), который загружал нужный сегмент (выгружая взамен какой-нибудь другой) и устанавливал P=1 — а программа, которая делала попытку обращения к сегменту, даже не замечала подвоха и не знала, что её обращение к сегменту вызвало цепную реакцию, итогом которой стала подгрузка этого сегмента с диска и выгрузка какого-то другого на диск.
Такой подход позволял одной программе, или точнее определённой задаче работать с неким числом сегментов, суммарный размер которых превышал возможности физической памяти компьютера, которые во времена 80286 с его 16-битным защищённым режимом были очень скромными.
Если вернуться к вопросу о том, какой трюк позволяет во времена 32-битного защищённого режима из одной задачи (с логическим адресным пространством размером 2
48 что равно 64 Тб) установить сегменты так, чтобы они охватывали более 4 Гб не пересекающейся памяти. А он довольно интересный: хотя поле
база в дескрипторе сегмента 32-битное, и 46-битные логические адреса конвертируются в 32-битные линейные (и затем в 32-битные или 36-битные физические), мы можем все сегменты разбить на группы и каждой группе присвоить своё отдельное 4 гигабайтное линейное адресное пространство.
В Windows и большинстве других ОС сделано так, что все потоки одного процесса видят одно и то же АП, а разные процессы имеют совершенно разные изолированные друг от друга АП. А все страницы всех АП, если разобраться, находятся либо в выгруженном состоянии, и тогда им соответствует какая-то страница на диске, либо в загруженном состоянии, и тогда им соответствует какая-то страница физической памяти.
Почему при переключении между потоками, принадлежащими разным процессам, код вдруг начинает видеть совершенно другое АП? Потому что при установке бита PG в CR0 активируется страничная организация памяти и страничное преобразование. Суть страничного преобразования состоит в том, что после того, как логический адрес (селектор:смещение) преобразован в линейный адрес (32-битный плоский одномерный адрес), линейный адрес преобразуется в физический адрес при помощи двухуровневого дерева таблиц.
В регистре CR3 есть битовое поле, которое называется PDBR (Page-Directory Base Register) и хранит указатель на начало Page Directory. Это таблица, которая состоит их элементов (Page Directory Entry, PDE), и каждый элемент или невалидный, или хранит указатель на начало Page Table. Это другая таблица, которая состоит из других элементов (Page Table Entry, PTE), каждый из которых описывает отдельно взятую страницу линейного адресного пространство (которое размером 4 Гб) и для каждой страницы хранит во-первых флаги (атрибуты доступы, признак того, что для этой странице линейного пространства есть своя страница в физ. памяти) и адрес соответствующей страницы в физ. памяти.
Любое обращение к памяти из любого потока любого процесса (под Windows и аналогичными ОС) приводит к тому, что сначала логический адрес (пара селектор:смещение) преобразуется в линейный адрес по формуле:
ЛинейныйАдрес = ТаблицаДескрипторов(ВзятьИндексИзСелектора(Селектор)).База + СмещениеА затем линейный адрес преобразуется в физический по формуле:
- Код: Выделить всё
СмещениеВнутриСтраницы = ВзятьМладшие12Бит(ЛинейныйАдрес)
ИндексPTE = ВзятьСредние10Бит(ЛинейныйАдрес)
ИндексPDE = ВзятьСтаршие10Бит(ЛинейныйАдрес)
МассивPDEшек = CR3.PDBR
МассивPTEшек = МассивPDEшек(ИндексPDE).УказательНаСоотвТаблциуPTEшек
ФизическийАдрес = МассивPTEшек(ИндексPTE).ФизическийАдреСтраницы + СмещениеВнутриСтраницы
По мере этого преобразования в PDE и PTE проверяется флаговый бит P, и если P=0, вызывается предоставленный операционной системой обработчик исключения #PF (Page Fault), который должен загрузить отсутствующую страницу в физическую память с диска (возможно ценой выгрузки какой-то другой)
Вся суть «переключения АП» при переключении между потоками разных процессов состоит в том, что при этом изменяется значение регистра PDBR (это часть регистра CR3), а значит автоматически сменяется дерево таблиц, используемых для преобразования линейных адресов в физический (и проверки линейных адресов на предмет того, присутствует ли адресуемая страница в физпамяти). Помимо этого ещё и сбрасывается
кеш TLB, но это малозначимо в рамках данной дискуссии.
Разбив все сегменты текущей задачи на группы, можно для каждой группы иметь своё 4 ГБайтное линейное пространство, определяемой своим каталогом страниц. При обращении к сегменту, если он принадлежит к другой группе — не к той, линейное пространство которой выбрано в данный момент как текущее (а текущее пространство это то — на каталог страниц которого указывает PDBR), то мы переключаем пространство сменой значения PDBR, а всем сегментам, которые стали невалидными в результате переключения на другое линейное пространство, мы в дескрипторах установим P=0, чтобы, когда код обратится к одному из таких невалидных сегментов, сработал обработчик исключения #NP, и переключил PDBR на другое линейное пространство, актуальной для той группы сегментов, к которой будет обращение, а текущую группу отметил невалидной, проставив в её дескрипторах сегментов этой группы P=0. Это переключение будет точно таким же, как переключение между АП при смене текущего потока с потока одного процесса на поток другого процесса в современных ОС.
Недостаток такого подхода: временнЫе затраты на переключение между линейными АП, когда идут обращения к разным сегментам, принадлежащим разным группам сегментов. Если такие обращения чередуются, у нас каждое обращение будет приводить к переключению. Но несколько групп и несколько линейных пространств нам понадобится только в том случае, если нам нужны несколько сегментов, которые в совокупности не могут быть спроецированы на одно линейное 4 Гбайтное пространство без пересечений.
Я не знаю ни одной актуальной ОС, которая вообще использовала бы сегменты по первоначально задуманному назначению, и уж тем более не знаю, чтобы существовала хоть одна ОС, которая бы использовала подобные тактики для того, чтобы одна задача могла распоряжаться несколькими непересекающимися сегментами, размер которых в сумме больше чем 4 Гб.
Intel могла бы развить концепцию сегментов, увеличив количество сегментов, доступных одной задаче, расширив размер линейного адресного пространства. Иметь возможность распоряжаться четырьмя миллиардами аппаратно-страхуемых буфером, размером до 4 Гб каждый — звучит соблазнительно, но, поскольку спроса не было, тему решили задавать.
Все актуальные ОС, поскольку механизм сегментов нельзя отключить, делают трюк с созданием лишь минимального набора сегментов и установкой всем им минимальной базы (нулевой) и максимального лимита (4 Гб). Это даёт иллюзию так называемой плоской (flat) модели памяти при том, что механизм сегментов никуда не девается.
Современные версии Windows, например, создают ещё отдельный сегмент с небольшим лимитом и его селектор помещает в регистр FS. Этот сегмент ссылается на регион памяти, в котором находится TIB — блок информации о текущем потоке. В результате первое поле TIB-а доступно как FS:[0], второе — как FS:[4]. Но сам блок памяти, доступный через FS, доступен также и через DS/CS/ES, то есть через гигантские вырожденные сегменты. То есть Windows не использует всю мощь сегментов по обеспечению изоляции, контроля доступа и безопасности, а использует этот механизм только для лёгкого поиска потоко-специфичного блока данных.
Но вернёмся к 16-битным версиям Windows.
Не существовало иллюзии плоского адресного пространства. У каждого процесса не было изолированного АП. Широко использовались сегменты с ненулевыми базами. Не существовало разделения памяти на страниц. Не существовало постраничной подкачки: была только подгрузка и выгрузка сегментов целиком. Была GDT с дескрипторами глобальных сегментов и LDT у каждой задачи с сегментами, специфичными для задачи. Был лимит на размер сегмента в 64 кб и размер на количество сегментов в одной таблице в 8 тысяч.
Как должны были вести себя функции выделения памяти в тогдашних версиях Windows? Если программист запрашивает блоки по 16 байт, должна ли система для каждого запрошенного блока создавать новый сегмент? Тогда быстро исчерпается лимит на количество сегментов. Или же все запрашиваемые блоки должны выделяться из одного сегмента? Тогда исчерпается сам сегмент, из которого идут выделения.
В первом случае функция выделения памяти могла бы возвращать 16-битные селекторы сегментов, что соответствовало бы разрядности процессоров (а процессор хорошо работает с числами, размер которых соответствует его разрядности). Во втором случае функция выделения памяти должна была бы возвращать полные указатели — пару сегмент:смещение, которые занимают 32-бита, что в два раза больше разрядности процессора, и что не влазит целиком в один регистр процессора и требует использовать пару.
Создатели Windows решили сделать так, чтобы при выделении памяти вместо указателя на блок возвращался бы 16-битный хендл блока. На время, когда к блоку нужен доступ, программист вызывает GlobalLock или LocalLock, и, передав хендл, получает взамен указатель. Только в этот момент для блока памяти выделяется конкретный сегмент и становится ясным конкретное смещение. Поработав с блоком, используя указатель, когда работа с блоком оконечена, программист вызывает GlobalUnlock или LoclUnlock, и «открепляет» блок памяти. У программиста остаётся на руках хендл, а вот рабочий указатель становится невалидным, и тот сегмент, в котором был блок, может быть и выгружен, и перемещён в другое место (в физ. памяти), и использован для других нужд.
Поскольку сегменты бывают глобальными и локальными (и живут в GDT и LDT), построенный поверх мезанизм выделения памяти и управления ею через хендлы тоже состоит из двух семейств функций: Global-функций и Local-функций, и для хендлов существует два типа: HGLOBAL и HLOCAL.
Итак, на 16-битных версиях Windows HGLOBAL и HLOCAL — это именно хендлы блоков памяти, но никак не указатели на них. Используя хендлы, нельзя напрямую читать или писать данные. Блок памяти нужно закрепить в памяти функцией GlobalLock или LocalLock, что даст нам указатель, используя который уже можно писать, читать и копировать данные. Разумный подход состоит в том, чтобы фиксировать память только на те моменты, пока это нужно, а в остальное время оставлять на руках только хендл.
GlobalHandle и LocalHandle — это обратные функции, способные по указателю предварительно зафиксированного блока, вернуть хендл этого блока.
И ещё раз: хендл — это не указатель. Хотя бы потому, что хендл 16-битный, а указатель состоит из 16-битного селектора и 16-битного смещения.
С приходом 32-битных версий поменялось всё. Процессы получили отдельные изолированные АП. Сегменты ушли в тень. Модель памяти стала «плоской». Ушли в прошлое near-, far- и huge-указатели — остались только
просто указатели (32-битные). Появилась страничная организация памяти. Появились атрибуты доступа у каждой страницы, а не у всего сегмента целиком.
Появились новые функции для выделения памяти (VirtualAlloc/HeapAlloc). Но старые функции выделения памяти не могли исчезнуть, потому что тонны программ, в коде которых они использовались, должны были компилироваться под новую 32-битную систему без переписывания всего кода.
Функции LocalAlloc и GlobalAlloc остались, и формально они по прежнему возвращали хендл блока, который надо было фиксировать, прежде чем нам дадут указатель, по которому можно писать/читать. В реальности же функции превратились в заглушки-обётки над HeapAlloc, а возвращаемые значения (якобы хендлы) были самыми настоящими адресами.
Так можно ли в итоге функциям, ожидающим на вход HGLOBAL или HLOCAL, передавать указатель, полученный не от LocalAlloc или GlobalAlloc? По идее — нет, потому что типы не соответствует (указатель и хендл).
Можно ли, если функция ожидает на вход HGLOBAL или HLOCAL, передавать указатель, прогнав его через GlobalHandle или LocalHandle? По идее, тоже нет, если мы не уверены точно, что этот указатель мы через цепочку LocalAlloc->LocalLock.
Вопрос в совместимости. Все эти функции остались существовать только для обеспечения совместимости старого кода с новыми ОС. Если нас волнует совместимость нашего кода со старыми ОС, мы должны использовать старые функции строго в рамках той идеологии, в какой они работали. Если нас волнует совместимость нашего кода с новыми ОС, мы вообще не должны использовать эти старые функции, кроме случаев-исключений, указанных в MSDN.