Что за таинственная инструкция inc bp в начале функций 16-битного кода?

Совсем недавно мы рассмотрели цепочку значений EBP. Ее реализация в 32-битном коде довольно проста, поскольку в этом случае существует только один тип вызова функций. Но в 16-битном коде есть два типа вызова функций: ближний вызов (near call) и дальний вызов (far call).

Перед переходом к входной точке функции ближний вызов помещает в стек 16-битный адрес возврата. Входная точка функции должна располагаться в том же сегменте, что и вызывающий код. Затем, когда вызываемая функция хочет передать управление вызывающему коду, она использует инструкцию ret (ближний возврат). При этом центральный процессор продолжит выполнение операций с указанного адреса, находящегося в том же сегменте кода.

В отличие от ближнего вызова дальний вызов помещает в стек и сегмент (или селектор, если используется защищенный режим), и смещение адреса возврата (оба являются 16-битными значениями), а вызываемая функция должна использовать инструкцию retf (дальний возврат). При этом центральный процессор будет извлекать из стека два 16-битных значения для определения адреса, с которого следует продолжить выполнение кода.

Первая выпущенная версия Windows запускалась на процессоре 8086 с 384 Кб оперативной памяти. Это представляло определенную сложность, поскольку в процессоре 8086 отсутствовал менеджер памяти, не было уровней привилегий центрального процессора и не поддерживалась концепция переключения задач. Для того чтобы уместиться в 384 Кб памяти, операционной системе Windows требовалось выполнять загрузку кода с диска по запросу и освобождать часть памяти, занятую кодом, когда свободной памяти оставалось слишком мало.

Одной из наиболее сложных задач менеджера памяти реального режима было исправление всех ссылок на функции во время загрузки и выгрузки кода. При выгрузке функции вы должны убедиться, что весь остальной код, который в данный момент находится в памяти и вызывает данную функцию, на самом деле не вызывает ее, потому что этой функции больше нет по указанному адресу. Если у вас есть современный менеджер памяти, вы можете просто пометить этот сегмент или страницу памяти как отсутствующую, однако в процессоре 8086 подобная роскошь отсутствует.

Решение этой проблемы состоит из нескольких частей, но та часть, которая относится к ответу на наш вопрос, заключается в способе исправления содержимого стеков в системе. Очевидно, что если вы освободили память, занимаемую функцией, вы должны убедиться, что все ссылки на функцию, в том числе и в виде адресов возврата в стеке, исправлены, прежде чем код попытается выполнить инструкцию retf и обнаружит, что функции, которой он возвращает управление, уже не существует.

И тут появляется таинственная инструкция inc bp.

Первое правило использования фреймов стека в реальном режиме Windows заключается в том, что стек всегда должен базироваться на регистре bp. Оптимизация указателя фрейма (FPO) не разрешена. (К счастью, оптимизация указателя фрейма в этом случае не особо помогает, поскольку в 16-битном наборе инструкций доступ к содержимому стека с использованием регистра, отличного от bp, довольно затруднителен. Таким образом, самый простой способ является также и самым правильным способом). Другими словами, первое правило требует, чтобы каждый фрейм стека всегда имел корректную цепочку значений bp.

Второе правило использования фреймов стека в реальном режиме Windows заключается в том, что если вы собираетесь выполнять возврат при помощи инструкции retf, вы должны инкрементировать регистр bp перед его помещением в стек (и, соответственно, декрементировать его после извлечения из стека). Это правило означает, что код, который использует цепочку значений bp, сможет найти следующую функцию в стеке. Если значение bp четно, тогда функция будет использовать ближний вызов, следовательно, она получит 16-битное значение, размещенное в стеке после адреса, хранящегося в регистре bp, и не будет изменять регистр cs. С другой стороны, если значение bp нечетно, функция «понимает», что нужно извлечь из стека и 16-битное смещение, и 16-битный адрес сегмента.

Итак, давайте соберем все вместе: после того как часть памяти, занимаемая кодом, была освобождена, ядро системы просматривает все стеки (что возможно, благодаря двум вышеприведенным правилам). При нахождении адреса возврата, соответствующего выгруженной функции, этот адрес подменяется на адрес участка кода, который передает управление менеджеру памяти. Менеджер памяти, в свою очередь, снова загружает функцию и обновляет все адреса возврата так, чтобы они теперь указывали на новый адрес, по которому эта функция была загружена (скорее всего, этот адрес будет отличаться от адреса, по которому функция располагалась до выгрузки). После этого он передает управление изначальному коду, как будто ничего и не случилось.

Я продолжаю удивляться тому, как многое было сделано в Windows 1.0 для того, чтобы работать в условиях таких ограниченных ресурсов. В ней даже был использован алгоритм определения функций, которые не использовались дольше всех (least recently used, LRU), для выбора кандидатов на выгрузку. Он базировался на программной реализации «времени последнего доступа» (accessed bit) — то, что в современных процессорах реализуется на аппаратном уровне.