Сегодня я решил создать немного стековая виртуальная машина в C ++ 11 для удовольствия — все шло довольно хорошо, пока я не стал вызывать функции и возвращаться из функций.
Я пытался следовать правилам вызова, аналогичным сборка х86 но я действительно запутался.
У меня проблемы с смещения указателя базы стека и с возвращаемые значения.
Кажется, очень трудно отслеживать регистры, используемые для возвращаемых значений и аргументов (для вызовов функций) в стеке.
Я создал простой язык, похожий на ассемблер, и компилятор. Вот прокомментированный пример (что моя виртуальная машина компилируется и выполняется). Я пытался объяснить, что происходит, и поделиться своими мыслями в комментариях.
//!ssvasm
$require_registers(3);
// C++ style preprocessor define directives to refer to registers
$define(R0, 0);
$define(R1, 1);
$define(R2, 2);
// Load the 2.f float constant value into register R0
loadFloatCVToR(R0, 2.f);
// I want to pass 2.f as an argument to my next function call:
// I have to push it on top of the stack (as with x86 assembly)
pushRVToS(R0);
// I call the FN_QUAD function here: calling a function pushes both
// the current `stack base offset` and the `return instruction index`
// on the stack
callPI(FN_QUAD);
// And get rid of the now-useless argument that still lies on top of the stack
// by dumping it into the unused R2 register
popSVToR(R2);
halt(); // Halt virtual machine execution$label(FN_DUP); // Function FN_DUP - returns its argument, duplicated
// I need the arg, but since it's beneath `old offset` and `return instruction`
// it has to copied into a register - I choose R0 - ...
// To avoid losing other data in R0, I "save" it by pushing it on the stack
// (Is this the correct way of saving a register's contents?)
pushRVToS(R0);
// To put the arg in R0, I need to copy the value under the top two stack values
// (Read as: "move stack value offset by 2 from base to R0")
// (Is this how I should deal with arguments? Or is there a better way?)
moveSBOVToR(R0, 2);
// Function logic: I duplicate the value by pushing it twice and adding
pushRVToS(R0); pushRVToS(R0); addFloat2SVs();
// The result is on top of the stack - I store it in R1, to get it from the caller
// (Is this how I should deal with return values? Or is there a better way?)
popSVToR(R1);
popSVToR(R0); // Restore R0 with its old value (it's now at the top of the stack)
// Return to the caller: this pops twice - it uses `old stack base offset` and
// unconditionally jumps to `return instruction index`
returnPI();$label(FN_QUAD); // Function FN_QUAD
pushRVToS(R0);
moveSBOVToR(R0, 2);
// Call duplicate twice (using the first call's return value as the second
// call's argument)
pushRVToS(R0); callPI(FN_DUP); popSVToR(R2);
pushRVToS(R1); callPI(FN_DUP); popSVToR(R2);
popSVToR(R0);
returnPI();
Я никогда раньше не программировал на ассемблере, поэтому я не уверен, что используемые мной техники правильные (или эффективные).
Правильный ли способ обработки аргументов / возвращаемых значений / регистров?
Должен ли вызывающий функции выдвигать аргументы, затем вызывать, а затем выдвигать аргументы? Кажется, что использовать регистр было бы проще, но я читал, что x86 использует стек для передачи аргументов. Я уверен, что метод, который я здесь использую, неверен.
Должен ли я подтолкнуть оба old stack offset
а также return instruction index
на вызов функции? Или я должен хранить old stack offset
в реестре? (Или вообще не хранить?)
То, о чем вы говорите, называется соглашением о вызовах. Другими словами, определение, кто и как строит стек, как вызывающий или вызываемый, и как должен выглядеть стек.
У них есть много способов сделать это, и никто не может быть лучше, чем другой, вы просто должны сохранять добросовестность.
Поскольку было бы слишком долго описывать различные соглашения о вызовах, вам следует просто проверить статью в Википедии, которая действительно завершена.
Но по-прежнему быстро соглашение о вызовах x86 C указывает, что вызывающая сторона должна сохранить свои регистры и построить стек, а также освободить вызываемого абонента от использования регистров, чтобы вернуть значение или просто сделать что-то.
Для конкретных вопросов в конце вашего поста лучше всего иметь тот же стек, что и у C, хранить в последних EIP и EBP и оставлять регистры свободными для использования. Пространство стеков не так ограничено, как количество имеющихся у вас регистров.
Я решил эту проблему в своей машине стека, над которой я работал, следующим образом:
Инструкция вызова void (без параметров) делает что-то вроде этого:
Существует _stack [] (основной стек) и _cstack [] (стек вызовов, содержащий информацию о вызовах, например, размер возврата).
При вызове функции, ( VCALL
(void вызов функции) встречается следующее:
u64& _next = _peeknext; //refer to next bytecode (which will be function address)
AssertAbort((_next > -1) && (_next < _PROGRAM_SIZE), "Can't call function. Invalid address");
cstack_push(ip + 2); //address to return to (current address +2, to account for function parameters next to function call)
cstack_push(fp); //curr frame pointer
cstack_push(_STACK_SIZE); //curr stack size
cstack_push(0); //size of return value(would be 4 if int, 8 for long etc),in this case void
ip = (_next)-1; //address to jump to (-1 to counter iteration incrementation of program counter(ip))
Затем, когда RET
(возврат) инструкция встречается, сделано следующее:
AssertAbort(cstackhas(3), "Can't return. No address to return to.");
u64 return_size = cstack_pop(); // pop size of return value form call stack
_STACK_SIZE = cstack_pop(); //set the stack size to what it was before the function call, not accounting for the return value size
fp = cstack_pop(); //reset the frame pointer to the current value to where it was before the function call
ip = cstack_pop() - 1; //set program counter to addres storedon call stack from last function call
_cstack.resize(_STACK_SIZE + return_size); //leave the top of the stack intact (size of return value in bytes), but disregard the rest.
Это, вероятно, бесполезно для вас сейчас, так как этот вопрос довольно старый, но вы можете задать любые вопросы, если хотите 🙂
Лучшее решение зависит от машины.
Если push и pop в стеке работают так же быстро, как и при использовании регистров (в стеке микросхем или в стеке запеченного чипа L1), и в то же время вы очень ограничены в количестве регистров, имеет смысл использовать стек.
Если у вас много регистров, вы можете использовать некоторые из них для хранения счетчиков (указателей) или переменных.
В общем, чтобы модули взаимодействовали друг с другом или переводили (или компилировали) другие языки в вашу сборку, вы должны указать двоичный интерфейс приложения.
Вы должны сравнить различные ABI для разных аппаратных средств (или виртуальных машин), чтобы найти методы, подходящие для вашей машины. Как только вы определите свой ABI, программы должны соответствовать бинарной совместимости.