Недавно Реймонд Чен завершил серию постов, начатую ещё полтора года назад, и посвящённую управлению виртуальной памятью безо всякой поддержки со стороны процессора: Windows до версии 3.0 включительно поддерживала реальный режим 8086. В этом режиме трансляция адреса из «виртуального» (видимого программе) в физический (выдаваемый на системную шину) осуществляется бесхитростным сложением сегмента и смещения — никакой «проверки доступа», никаких «недопустимых адресов». Все адреса доступны всем. При этом в 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 функцию GlobalLock
заменили «заглушкой»: теперь Windows может перетасовывать блоки памяти, не изменяя их «виртуальный адрес», видимый приложению (селектор: смещение) — а значит, приложению теперь нет надобности закреплять невыгружаемые объекты. Иными словами, закрепление теперь предотвращает выгрузку блока, но не предотвращает его (незаметное для приложения) перемещение. Поэтому для закрепления данных «взаправду» в физической памяти, для тех, кому нужно именно это (например, для работы со внешними устройствами), добавили пару GlobalFix
/ GlobalUnfix
. Так же, как и GlobalWire
/ GlobalUnwire
, в Win32 эти функции стали бесполезными; и они точно так же удалены из документации, хотя остались в kernel32.dll, и перевызывают GlobalLock
/ GlobalUnlock
.
Управление кодом
Самое хитрое начинается здесь. Блоки кода — так же, как и неизменяемые данные — удалялись из памяти, и потом загружались из исполняемого файла. Но как Windows обеспечивала, что программы не попытаются вызвать функции в выгруженных блоках? Можно было бы обращаться и к функциям через хэндлы, и перед каждым вызовом функции вызывать гипотетическую LockFunction
; но вспомним, что многие функции крутят «message loop», например показывают окно или выполняют DDE-команды, — и их на это время тоже можно было бы выгрузить, т.к. фактически их код в это время не нужен. Тем не менее, при использовании «хэндлов функций» сегмент функции не будет освобождён до тех пор, пока она не вернёт управление вызвавшей функции.
Вместо этого 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 не может пройтись по всей памяти, найти все вызовы выгруженных функций, и заменить их все на reload thunks. Вместо этого межсегментные вызовы компилируются с учётом того, что вызываемой функции может не быть в памяти, и фактически вызывают «заглушку» в таблице входов модуля. Эта заглушка состоит из инструкции int 3fh
, и ещё трёх служебных байтов, указывающих, где искать функцию. Обработчик int 3fh
находит по своему адресу возврата эти служебные байты; определяет нужный сегмент; загружает его в память, если он ещё не загружен; и напоследок перезаписывает заглушку в таблице входов абсолютным переходом jmp xxxx:yyyy
на тело функции, так что следующие вызовы этой же функции замедляются лишь на один межсегментный переход, без прерывания.
Теперь, когда Windows выгружает функцию, ей достаточно в таблице входов модуля заменить вставленный переход обратно на заглушку int 3fh
. Системе незачем искать все вызовы выгруженной функции — они все были найдены ещё при компиляции! В «таблицу входов» модуля сведены все далёкие функции, про которые компилятор знает о существовании межсегментных вызовов (сюда относятся, в частности, экспортируемые функции и WinMain
), а также все далёкие функции, которые передавались куда-либо по указателю, а значит, могли вызываться откуда угодно, даже извне кода программы (сюда относятся WndProc
, EnumFontFamProc
и прочие callback-функции).
Вместо указателей на далёкие функции всюду передаётся указатель на заглушку; а значит, адреса, полученные из GetWindowLong(GWL_WNDPROC)
и подобных вызовов, тоже указывают на заглушку, а не на тело функции. Даже GetProcAddress
хитрит, и вместо адреса функции возвращает адрес её заглушки в таблице входов DLL. (В Win32 аналог «таблицы входов» лишь у DLL и остался, под названием «таблицы экспортов».) Статические межмодульные вызовы (вызовы функций, импортируемых из DLL) резолвятся при помощи той же самой GetProcAddress
, и поэтому точно так же вызывают в итоге заглушку. В любом случае оказывается, что при выгрузке функции достаточно исправить заглушку, и не нужно трогать сам вызывающий код.
Вся эта премудрость с перемещаемыми сегментами кода пришла в Windows «по наследству» из оверлейного линкера для DOS. Мол, сначала вся схема — в точности в таком виде — появилась в компиляторе Zortech C, а потом и в Microsoft C. Когда создавался формат исполнимых файлов для Windows, за основу взяли уже существующий формат оверлеев для DOS.
Но как 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