Статья — попытка разобраться в том, как на самом деле работает управление памятью в 16-битных Windows. Система была не то чтобы undocumented, но и хорошо описанной её не назвать: Microsoft считала, что программисты будут использовать высокоуровневые языки, а вся механика останется за кадром. Большинство мануалов фокусировались на окнах, иконках и меню, а управление памятью, критичное для любого приложения сложнее Hello World, объясняли вскользь. В основе лежал real mode процессоров 8086 — Windows 1.x и 2.x работали именно так, а сложность сохранилась даже в защищённом режиме 3.1.
Суть в том, что Windows с самого начала была навороченным overlay-менеджером. Система была слишком большой для компьютеров того времени, поэтому нужно было держать в физической RAM только активные сегменты, а остальные — выгружать и подгружать по требованию. Страничной памяти не было — её не поддерживали ни 8086, ни 80286.
Всё вращалось вокруг сегментов — блоков до 64 КБ. Вместо прямого адреса использовались хендлы, 16-битные идентификаторы, за которыми стоит таблица. Это похоже на защищённый режим x86. Разработчик Стив Вуд, создавая менеджер памяти Windows 1.0, вдохновлялся Intel 286.
Хендл — не адрес. Чтобы получить указатель, нужно было вызвать GlobalLock — он возвращал сегментный адрес и блокировал сегмент, чтобы его не двигали. Закончили работу — вызвали GlobalUnlock. Сегмент мог снова переместиться в любой момент. Подвох в том, что сразу после GlobalUnlock он, скорее всего, не дёрнется, поэтому баги с доступом к разлоченной памяти могли долго не проявляться.
Сегменты делились на fixed (не двигаются — нужно для векторов прерываний, например) и movable (идеал для компактности памяти). А также на discardable (обычно код и ресурсы, можно перезагрузить с диска) и nondiscardable (обычно данные, они же изменяются).
DLL в Windows были новинкой для середины 80-х. Они тоже использовали NE-формат (New Executable), но не имели собственного стека — работали со стеком вызывающего кода. При этом SS != DS, поэтому компилятору нужно было явно говорить, что генерируется DLL. Microsoft C поддерживал это с версии 3.0 — существовали секретные ключи /Gw и /Aw, которые почти не документировались, а отсылали программиста в SDK.
Эти ключи включали специальные прологи и эпилоги функций. Поначалу они кажутся пустой тратой тактов — push ds, pop ax, xchg ax, ax, inc bp... Но это заготовка для Windows Loader. Если функция экспортируется из NE-модуля, система пропатчит первые три байта так, чтобы они загружали в AX правильный data segment. При перемещении сегмента памяти патч обновляется. А inc/dec bp нужны, чтобы Windows могла ходить по стеку и понимать, где лежат сегменты вызванных функций — иначе их нельзя двигать.
Сравнение с OS/2 16-bit показательно: у OS/2 был аппаратный защищённый режим 286, где селектор одновременно и хендл, и адрес. Не нужно было GlobalLock/GlobalUnlock, не нужно было экспортировать функции и писать специальные прологи — аппаратура всё делала сама.
Для отлова багов в Windows SDK шли инструменты: SHAKER — принудительно «тряс» память, вытесняя и перемещая сегменты; HEAPWALK — показывал занятые блоки и мог симулировать нехватку памяти, выделяя всё подряд и освобождая по килобайту. В Windows 3.1 SDK SHAKER заменили на Stress — он тестировал поведение при истощении разных ресурсов.
Вывод: 16-битные Windows построили продвинутую систему управления памятью на голом железе. Программисту приходилось соблюдать жёсткую дисциплину — правильные ключи компилятора, экспорт функций, корректные блокировки. Если что-то нарушить, «все ставки отменялись».