Управление памятью в реальном режиме Windows

в 7:45, , рубрики: windows, Алгоритмы, старое железо, управление памятью, хитрости, метки: ,

Управление памятью в реальном режиме WindowsНедавно Реймонд Чен завершил серию постов, начатую ещё полтора года назад, и посвящённую управлению виртуальной памятью безо всякой поддержки со стороны процессора: Windows до версии 3.0 включительно поддерживала реальный режим 8086. В этом режиме трансляция адреса из «виртуального» (видимого программе) в физический (выдаваемый на системную шину) осуществляется бесхитростным сложением сегмента и смещения — никакой «проверки доступа», никаких «недопустимых адресов». Все адреса доступны всем. При этом в Windows могли одновременно работать несколько программ и не мешать друг другу; Windows могла перемещать их сегменты в памяти, выгружать неиспользуемые, и по мере необходимости подгружать назад, возможно — по другим адресам.

(Интересно, всегдашние холиворщики «это была графическая оболочка, а не операционная система» в курсе об этих её необычайных способностях?)

И как же она ухитрялась?

Управление данными

Управление памятью в реальном режиме Windows Подкачки в реальном режиме Windows не было. Неизменяемые данные (например, ресурсы) просто удалялись из памяти, и при необходимости загружались снова из исполняемого файла. Изменяемые данные выгружаться не могли, но могли (как и любые другие данные) перемещаться: приложение для работы с блоками памяти использует не адреса, а хэндлы; и на время обращения к данным «закрепляет» блок, получая его адрес, а потом — «освобождает», чтобы Windows могла его при необходимости перемещать. Что-то аналогичное появилось спустя дюжину лет в .NET, уже под названием pinning.

Функции GlobalLock / GlobalUnlock и LockResource / FreeResource сохранились в Win32API для совместимости с теми дремучими временами, хотя в Win32 блоки памяти (в том числе ресурсы) никогда не перемещались.

Функции LockSegment и UnlockSegment (закреплять/освобождать память по адресу, а не по хэндлу) оставались какое-то время в документации с пометкой «obsolete, do not use», но теперь от них не осталось даже воспоминания.

Для тех, кому нужно закреплять память на долгий промежуток времени, была ещё функция GlobalWire — «чтобы блок не торчал посередине адресного пространства, перенести его в нижний край памяти и закрепить там»; ей соответствала GlobalUnwire, полностью равносильная GlobalUnlock. Эта пара функций, на удивление, жива в kernel32.dll до сих пор, хотя из документации они уже удалены. Сейчас они просто перевызывают GlobalLock / GlobalUnlock.

Управление памятью в реальном режиме Windows В защищённом режиме Windows функцию GlobalLock заменили «заглушкой»: теперь Windows может перетасовывать блоки памяти, не изменяя их «виртуальный адрес», видимый приложению (селектор: смещение) — а значит, приложению теперь нет надобности закреплять невыгружаемые объекты. Иными словами, закрепление теперь предотвращает выгрузку блока, но не предотвращает его (незаметное для приложения) перемещение. Поэтому для закрепления данных «взаправду» в физической памяти, для тех, кому нужно именно это (например, для работы со внешними устройствами), добавили пару GlobalFix / GlobalUnfix. Так же, как и GlobalWire / GlobalUnwire, в Win32 эти функции стали бесполезными; и они точно так же удалены из документации, хотя остались в kernel32.dll, и перевызывают GlobalLock / GlobalUnlock.

Управление кодом

Самое хитрое начинается здесь. Блоки кода — так же, как и неизменяемые данные — удалялись из памяти, и потом загружались из исполняемого файла. Но как Windows обеспечивала, что программы не попытаются вызвать функции в выгруженных блоках? Можно было бы обращаться и к функциям через хэндлы, и перед каждым вызовом функции вызывать гипотетическую LockFunction; но вспомним, что многие функции крутят «message loop», например показывают окно или выполняют DDE-команды, — и их на это время тоже можно было бы выгрузить, т.к. фактически их код в это время не нужен. Тем не менее, при использовании «хэндлов функций» сегмент функции не будет освобождён до тех пор, пока она не вернёт управление вызвавшей функции.

Управление памятью в реальном режиме Windows Вместо этого Windows начинает с предположения, что выгрузить можно любую функцию, которая не выполняется прямо сейчас; а раз прямо сейчас выполняется код менеджера памяти Windows, значит выгрузить можно вообще любую функцию. Ссылки на неё могут оставаться либо в коде программ, либо в стеке, если эта функция не успела вернуться до момента выгрузки.

Так что Windows проходит по стекам всех запущенных задач (так назывались контексты выполнения в Windows, пока не разделили процессы и потоки), находит адреса возврата, ведущие внутрь выгруженных сегментов, и заменяет их на адреса reload thunks — «заглушек», которые загружают нужный сегмент из исполняемого файла, и передают управление внутрь него, как ни в чём не бывало.

Чтобы Windows могла пройтись по стеку, программы обязаны поддерживать его в правильном формате: никакого FPO, кадр стека обязан начинаться с BP — указателя на кадр вызвавшей функции. (Поскольку стек состоит из 16-битных слов, значение BP всегда чётное.) Кроме того, Windows должна различать в стеке записи внутрисегментных («близких») и межсегментных («далёких») вызовов, и близкие вызовы может игнорировать — они-то уж точно не ведут в выгруженный сегмент. Поэтому постановили, что нечётное значение BP в стеке означает далёкий вызов, т.е. каждая далёкая функция должна начинаться с пролога INC BP; PUSH BP; MOV BP,SP и заканчиваться эпилогом POP BP; DEC BP; RETF (На самом деле пролог и эпилог были сложнее, но сейчас не об этом.)

Управление памятью в реальном режиме Windows Со ссылками из стека разобрались, а как быть со ссылками из других сегментов кода? Конечно же, Windows не может пройтись по всей памяти, найти все вызовы выгруженных функций, и заменить их все на reload thunks. Вместо этого межсегментные вызовы компилируются с учётом того, что вызываемой функции может не быть в памяти, и фактически вызывают «заглушку» в таблице входов модуля. Эта заглушка состоит из инструкции int 3fh, и ещё трёх служебных байтов, указывающих, где искать функцию. Обработчик int 3fh находит по своему адресу возврата эти служебные байты; определяет нужный сегмент; загружает его в память, если он ещё не загружен; и напоследок перезаписывает заглушку в таблице входов абсолютным переходом jmp xxxx:yyyy на тело функции, так что следующие вызовы этой же функции замедляются лишь на один межсегментный переход, без прерывания.

Теперь, когда Windows выгружает функцию, ей достаточно в таблице входов модуля заменить вставленный переход обратно на заглушку int 3fh. Системе незачем искать все вызовы выгруженной функции — они все были найдены ещё при компиляции! В «таблицу входов» модуля сведены все далёкие функции, про которые компилятор знает о существовании межсегментных вызовов (сюда относятся, в частности, экспортируемые функции и WinMain), а также все далёкие функции, которые передавались куда-либо по указателю, а значит, могли вызываться откуда угодно, даже извне кода программы (сюда относятся WndProc, EnumFontFamProc и прочие callback-функции).

Управление памятью в реальном режиме Windows Вместо указателей на далёкие функции всюду передаётся указатель на заглушку; а значит, адреса, полученные из GetWindowLong(GWL_WNDPROC) и подобных вызовов, тоже указывают на заглушку, а не на тело функции. Даже GetProcAddress хитрит, и вместо адреса функции возвращает адрес её заглушки в таблице входов DLL. (В Win32 аналог «таблицы входов» лишь у DLL и остался, под названием «таблицы экспортов».) Статические межмодульные вызовы (вызовы функций, импортируемых из DLL) резолвятся при помощи той же самой GetProcAddress, и поэтому точно так же вызывают в итоге заглушку. В любом случае оказывается, что при выгрузке функции достаточно исправить заглушку, и не нужно трогать сам вызывающий код.

Вся эта премудрость с перемещаемыми сегментами кода пришла в Windows «по наследству» из оверлейного линкера для DOS. Мол, сначала вся схема — в точности в таком виде — появилась в компиляторе Zortech C, а потом и в Microsoft C. Когда создавался формат исполнимых файлов для Windows, за основу взяли уже существующий формат оверлеев для DOS.

Управление памятью в реальном режиме Windows Но как Windows выбирает, какой из сегментов выгрузить? Выбирать наугад было бы рискованно — можем попасть в код, который только что выполнялся, и который придётся тут же загружать обратно. Поэтому Windows использует нечто наподобие «accessed-бита» для сегментов кода: зная, что все межсегментные вызовы функции проходят через её заглушку, они придумали вставить туда (перед int 3fh или заменяющим его jmp) инструкцию sar byte ptr cs:[xxx], 1, которая сбрасывает байт-счётчик из 1 в 0 при каждом вызове функции. Эта инструкция как раз занимает пять байт: можно сохранить существующий формат исполнимого файла, и загружать заглушки int 3fh через одну, перемежая инструкцией-счётчиком.

Значения счётчиков для всех сегментов кода инициализируются в 1, и раз в 250мс Windows обходит все модули, собирает обновлённые значения, и переупорядочивает сегменты кода в своём списке LRU. Обращения к сегментам данных можно отследить и безо всяких ухищрений: все такие обращения и так отмечены явным вызовом GlobalLock или аналогичных функций. Так что когда приходит время выгрузить какой-нибудь сегмент, чтобы освободить память — Windows постарается выгрузить тот сегмент, к которому дольше всего не было обращений: либо сегмент кода, счётчик которого дольше всего не сбрасывался в 0, либо сегмент данных, который дольше всего не закреплялся.

Рекламные объявления Windows 1.0-2.1 взяты на GUIdebook

Автор: tyomitch

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js