[Урок] Виртуальная машина SourcePawn

Kailo

Участник
Сообщения
168
Реакции
755
Контекст
Контекст - набор данных, совокупность которых образует сам объект чего-либо как таковой. В данном случае контекст плагина.
Контекст плагина состоит из:
- кода
- памяти (memory)
- указателя стека (stack pointer)
- указателя кучи (heap pointer)
- указателя стекового кадра aka. фрейм (frame pointer)
- сохраненное значение указателя стека (для проверки на утечку)
- сохраненное значение указателя кучи (для проверки на утечку)

Все указатели являются адресами в памяти плагина.


Виртуальная память
Память условно разделяется на две части:
1) данные
2) память (немного тавтологии):
a) Куча​
б) Стек​

Данные, расположены начиная от начала памяти плагина. Размер этой секции определяется компилятором во время процесса компиляции и записан в smx. В секции содержатся глобальные и статические локальные переменные, константные массивные значения используемые в плагине. Так как этот раздел памяти начинается с самого начала памяти, то адреса памяти и адреса глобальных переменных плагина совпадают.

Память, разделена на две составляющие: кучу и стек. Раздел памяти начинается сразу после конца раздела данных. В начале располагается куча, а за ней стек.

Размер памяти выделяемой плагину определяется при компиляции, вот пример лога компиляции:
Код:
SourcePawn Compiler 1.8.0.6041
Copyright (c) 1997-2006 ITB CompuPhase
Copyright (c) 2004-2015 AlliedModders LLC

Code size:             3568 bytes
Data size:             2400 bytes
Stack/heap size:      16384 bytes
Total requirements:   22352 bytes
Куча предназначена для хранения временных переменных и динамических массивов.
На временные переменные ни как нельзя повлиять кодом, т.к. они создаются компилятором для нужд исполнения кода, и разрушаются сразу же как исполнении свою функцию. К примеру если вы используете вызов функции, которая возвращает массив или передаете константное значение в роли массива, который может быть изменен при вызове.

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

Стековый кадр состоит из:
- аргументов вызова функции
- количества параметров при вызове
- информации о старом кадре
- указатель предыдущего кадрового ферейма​
- ранее просто значение 0 (ноль), сейчас старый указатель кучи (изменения еще не были добавлены в стабильную версию SM).​
- локальных переменных

Приведу пример для наглядности.
Имея следующий код:
PHP:
public void OnPlayerSpawn(int client, int team)
{
    int myweapon = GetClientWeapon(client); // функция вернула 307
    if (myweapon != -1)
    {
        FillAmmoToMax(myweapon);
    }
}

// Ну скажем у нас модификация где нет обойм у оружия
void FillAmmoToMax(int weapon)
{
    int ammo = GetAmmo(weapon); // функция вернула 294
    int maxammo = GetMaxAmmo(weapon); // функция вернула 400
    if (ammo < maxammo)
    {
        // В этот момент мы решили посмотреть состояние стека, с.м. ниже
        SetAmmo(weapon, maxammo);
    }
}
Состояние стека (вершина стека сверху)
Вначале указан адрес ячейки в HEX, от начала стека.
PHP:
/* Здесь и выше, свободная память стека */

/* Конец кадра FillAmmoToMax */
  /* Конец локальных переменных кадра */
  0x80: 400 // переменная maxammo
  0x7С: 294 // переменная ammo
  /* Начало локальных переменных кадра */

  /* Конец информации о старом кадре */
  0x78: 0 // просто 0
  0x74: 0x64 // указатель кадра вызвавшей функции
  /* Начало информации о старом кадре */

  /* Конец аргументов вызова */
  0x70: 1 // кол-во аргументов при вызове
  0x6C: 307 // weapon аргумент
  /* Начало аргументов вызова */
/* Начало кадра FillAmmoToMax */

/* Конец кадра OnPlayerSpawn */
  /* Конец локальных переменных кадра */
  0x68: 307 // переменная myweapon
  /* Начало локальных переменных кадра */

  /* Конец информации о старом кадре */
  0x64: 0 // просто 0
  0x60: ? // указатель кадра вызвавшей функции
  /* Начало информации о старом кадре */

  /* Конец аргументов вызова */
  0x5C: 2 // кол-во аргументов при вызове
  0x58: 1 // client аргумент
  0x54: 2 // team аргумент
  /* Начало аргументов вызова */
/* Конец кадра OnPlayerSpawn */

/* И так далее... */
Важно! Стек вызовов виртуальной машины SP расположен в памяти перевернуто.
Адреса в примере не соответствуют реальности и даны лишь для примера.
Т.е. Он начинается от конца памяти и идет к началу.

Подробнее в Википедия:Стек вызовов.

Контекст вызова,
Интерпретатор

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

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

Если с внутренними вызовами функции внутри плагина все выглядит вполне понятно, то остается понять как происходит "начальный вызов" (когда в нашем плагине вызывается форвард или обработчик) или же вызов функций внутри других плагинов при использовании Call_* функций.
Для этого у контекста плагина есть функция Invoke. Она порождает первоначальный интерпретатор и запоминает начальное значение фрейма в этот момент (invoke frame).
Так же, для передачи данных между плагинами, и корректным доступом к аргументом, по необходимости выделяет в куче память для аргументов, и по завершению копирует их значения в источник вызова.

Регистры
Если я не ошибаюсь в терминологии, то виртуальную машину SP правильно назвать регистровой машиной (еще бывают стековые), т.к. основные операции производятся внутри регистров.
Регистры представляют собой ячейки (англ. cell) - переменные размером 4 байта.
Существует два регистра:
- Основной регистр (англ. primary, сокр. pri)
- Вспомогательный регистр (англ. alternative, сокр. alt)
О том в каких случаях какие регистры используются, см. Набор инструкций (байткод) SP ниже в статье.

Соглашение о вызовах
Обычный вызов функции
Вызовы функций происходят по их адресу в ".code" секции, которая предварительно было скопирована из smx в контекст плагина.
Если коротко:
1) Передача аргументов
Производится через стек в обратном порядке.
2) В стек заносится кол-во аргументов
3) Сохранение значений строго фрейма в стеке и установка текущий вершины стека как текущего фрейма.
4) Локальные переменные и память выделяемая в куче должны быть освобождены вызванной функций создавший их до возврата, иначе получите ошибку (утечка стека - "stack leak" или утечка кучи - "heap leak").
5) При возврате, возвращаемое значение заносится в основной регистр.
6) Вызывающая функция производит очистку стека от аргументов, т.к. их количество было передано при вызове.

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

От теории к практики
Для того, чтобы было легче понять выше сказанное, приведу примеры и пояснения.

Фрейм - адрес в памяти (внутри стека), который указывает на место между локальными переменными и сохраненный информацией о старом фрейме.
Помним что стек расположен перевернуто в памяти!

Локальные переменные
Мы создаем новую локальную переменную - разберем что значит "создаем":
Локальные переменные создаются на вершине стека.
Во-первых рассмотрим обычные переменные (не массивы):
Раньше их было два вида, сейчас один, но рассмотрим оба.
1) Не инициализированные.
(Кто еще не понял сути "decl" надеюсь сейчас разберется.)
У них нет значения, которому они должны быть равны после создания ("инициализированы"), значит нам надо лишь сместить вершину стека на размер нашей переменной.
PHP:
decl myvar;
В байткоде это выглядит как
PHP:
stack -4
Внимание! Сложите в голове пазл: стек перевернут, вершина его движется справа-налево (если представлять память как линию с началом слева и концом справа). А следовательно для увеличения размера стека его вершина должна сместиться влево, где адрес меньше. Следовательно - при увеличении стека мы вычитаем, а при уменьшении прибавляем.

(инструкция stack прибавляет переданное значение к стеку, т.е. sp + -4 = указатель стека сместился влево и мы "выделили" память переменной размером 4 байта)

2) Инициализированные
У них передается значение для установки переменной, поэтому мы можем использовать инструкцию для внесения значения в стек, она автоматически изменит значение вершины стека.
PHP:
new myvar = 6;
new myvar2;
в байткоде выглядит как
PHP:
push.c 6
push.c 0
Заметьте, что SP использует концепцию "зануления", т.е. при отсутствии явного указания значения для инициализации, инициализирует значением 0.

Закрепим разницу:
PHP:
new myvar;
decl myvar2;
new myvar3 = 777;

// байткод
push.c 0
stack -4
puch.c 777
Думаю, что к массивам, мы пока обращаться не будем.

Теперь произведем синтез фрейма с локальными переменными. Собственно локальными их называют, т.к. у них вместо адреса в памяти смещение от фрейма до начала переменной.
Используя последний пример с тремя переменными:
Адрес myvar = -4;
Адрес myvar2 = -8;
Адрес myvar3 = -12;
Так же помните, что переменные могут иметь область существования и внутри функции, к примеру в if условии или цикле. Т.е. в разные моменты времени по одному и тому же адресу будут разные переменные:
PHP:
int myvar;
if ()
{
    int myvar2;
}
int myvar3;
myvar2 и myvar3 имею одинаковый адрес = -8.

Освобождение памяти aka. разрушение переменных происходит смещением вершины стека.
PHP:
stack 8 // освобождаем память от 2х пременных
Аргументы вызова
Собственно аргументы не так сильно отличаются от локальных переменных. Они расположены справа от фрейма и имеют положительные смещения, но важно не забывать о информации о старом фрейме и количестве переменных. Мы должны пропустить 3 ячейки (0, адрес старого фрейма и переменная с количеством аргументов), получаем, что смещение первого аргумента 12 байт, второго 16 байт, и т.д.

Теперь посмотрим как выглядит вызов в байткоде (к примеру вызов функции FillAmmoToMax их функции OnPlayerSpawn из примера выше):
PHP:
public void OnPlayerSpawn(int client, int team)
{
    int myweapon = GetClientWeapon(client); // функция вернула 307
    if (myweapon != -1)
    {
        FillAmmoToMax(myweapon);
    }
}

void FillAmmoToMax(int weapon)
PHP:
push.s -4 // передаем значение myweapon как аргумент вызова weapon
push.c 1 // передаем кол-во аргументов при вызове
call 2408 // производим вызов функции по адресу 2408, в нашем случае это FillAmmoToMax
// По завершению вызова аргументы уже будут отчищены из стека а pri регистр содержать вернувшиеся значение.
Возврат значения происходит всегда, даже когда явно не указано!
При "void", или "return;", или отсутствии любого return в функции, он туда все равно добавляется и возвращает 0.

Набор инструкций
Подробнее о инструкциях вы можете прочитать в
Байт-код SourcePawn (Справочник)

Заключение
Виртуальная машина SourcePawn не является в полной мере копией вымышленного процессора.
 
Последнее редактирование:

Rabb1t

Хранитель кеша
Команда форума
Сообщения
2,745
Реакции
1,150
Давно задавался вопросом реализации подобного на павне. Жду урок. :)
 

Dragokas

Участник
Сообщения
97
Реакции
30
Спасибо большое за статью.
Все довольно толково и понятливо расписано.

Интересно, как расчитывается обьем памяти выделяемой под стек/кучу. Эти 16 кб я так понимаю начальный размер? У меня в плагинах есть stringmap-ы которые и по 50 кб. хранят. Есть ли какой то лимит?
 

Kailo

Участник
Сообщения
168
Реакции
755
Размер памяти для кучи/стека определяется указанным значением. По умолчанию это 4096 ячеек, что эквивалентно 16384 байтам. Можно использовать директиву компилятор, чтобы переопределить это значение.
Код:
#pragma dynamic 4069
Стек используется только для хранения кадров вызовов, а куча только для временных переменных и динамических массивов (те что char[] array = new char[N];)
Работа с памятью плагина идет напрямую байт-кодами. А вот StringMap, это у нас дитя Handle, они находятся в собственном блоке памяти, не относящимся к собственной памяти плагина.
Собственно из этого следует то свойство, что хэнделы можно передавать между плагинами, и они будут работать в другом плагине.
 
Сверху