شبیه سازها فناوریهای جالبی هستند که به کاربران امکان میدهند یک سیستم کاملا متفاوت را بر روی سیستم دیگری اجرا کنند. در اینجا ما با استفاده از یک ماشین ساده که برنامههای نوشته شده برای یک پردازنده مجازی را اجرا میکند، نحوه کار پردازنده و چگونگی نرمافزار آن را شبیه سازی میکنیم.
شبیه سازها طیف گستردهای از برنامهها را دارند، مانند اجرای برنامههای x86 در دستگاههای ARM یا اجرای برنامههای ARM Android بر رویx86 Windows Desktops و حتی اجرای بازی Retro مورد علاقتان روی رایانه یا کنسول شبیه سازی شده روی Raspberry Pi.
هنگامی که یک سیستم کامل پیاده سازی میشود، نرم افزار شبیه ساز باید تمام دستگاههای سختافزاری آن سیستمها را کنترل کند. این ممکن است نه تنها پردازنده مرکزی بلکه سیستم ویدئو، دستگاههای ورودی و خروجی و ... را نیز شامل شود. با این وجود، مفهوم اصلی یک شبیه ساز، شبیه سازی CPU است.
برنامهها فقط یک سری بایت هستند
این جا افتاده است که پردازنده مرکزی هسته دستگاه است که وظیفه اصلی آن اجرای برنامهها است و برنامهها چیزی بیشتر از یک سری دستورالعملها در حافظه کامپیوتر نیستند.
در این مرحله ممکن است وسوسه شوید که باور کنید پردازنده شما جاوااسکریپت را میداند. اگرچه مفهومی وسوسه انگیز است، اما چنین نیست. تصور اینکه اگر نسخه جدیدی از جاوااکسریپت منتشر شد، باید پردازنده را تغییر دهید!
در واقعیت پردازنده مرکزی فقط یک سری دستورالعمل محدود را میفهمد. دستورالعملهای تیم مهندسی که برای درک CPU طراحی شده است.
کاربران میتوانند با استفاده مستقیم از این دستورالعملها یا با کد نویسی به زبان سطح بالاتر و سپس با استفاده از یک کامپایلر، آنها را ایجاد کنند تا برنامه را به مجموعه دستورالعملهای پردازنده ترجمه کنند.
صرف نظر از اینکه کاربر چگونه برنامه را ایجاد کرده است، دستورالعملها به صورت یک سری بایت مانند موارد زیر در حافظه ذخیره میشوند:
11,0,10,42,6,255,30,0,11,0,0,11,1,1,11,3,1,60,1,10,2,0,20,
2,1,60,2,10,0,1,10,1,2,11,2,1,20,3,2,31,2,30,2,41,3,2,19,31,0,50
نقش پردازنده این است که این دستورالعملها را از حافظه بگیرد و آنها را اجرا کند.
استفاده از mnemonics کار نوشتن برنامهها را برای انسان بسیار آسان میکند. اگر کسی با استفاده از دستورالعملهای mnemonics برنامه بنویسد، گفته میشود که به زبان اسمبلی کدنویسی میکند. برای تبدیل این برنامههای نوشته شده به زبان اسمبلی به دستورالعملهای باینری (به عنوان مثال زبان ماشین) فقط یک مرحله ساده طول میکشد.
دستورالعملهای پردازنده مجازی ما، همانطور که در زیر توضیح داده شده است، کاملا شهودی هستند. اما اگر میخواهیم از آن پردازنده در نرمافزار شبیه سازی کنیم باید دستورالعملهای پردازنده را بدانیم.
توجه: در زیر هر دستورالعمل شمارهای که برای رمزگذاری آن دستورالعمل خاص استفاده میشود مشخص شده است. همچنین رجیسترهای (ثباتهای) R0 ،R1 ،R2 ، R3 به صورت شمارههای 0، 1، 2 و 3 شماره گذاری میشوند:
مقدار را از regsrc به regdst لود میکند. به عنوان مثال regdst = regsrc.
MOVR reg_dst, reg_src
MOVR = 10
مقدار عددی را در رجیستر regdst لود میکند. به عنوان مثال value = regdst.
MOVV reg_dst, value
MOVV = 11
مقدار regsrc را به مقدار regdst اضافه میکند و نتیجه را در reg_dst ذخیره میکند.
ADD reg_dst, reg_src
ADD = 20
مقدار regsrc را از مقدار regdst کم میکند و نتیجه را در reg_dst ذخیره میکند.
SUB reg_dst, reg_src
SUB = 21
مقدار reg_src را روی پشته push میکند.
PUSH reg_src
PUSH = 30
آخرین مقدار را از پشته pop میکند و آن را در reg_dst لود میکند.
POP reg_dst
POP = 31
اجرا را به آدرس addr پرش میکند. مشابه GOTO!
JP addr
JP = 40
فقط اگر مقدار reg1 <reg2 برقرار باشد به آدرس addr بروید.
JL reg_1, reg_2, addr
JL = 41
آدرس دستورالعمل بعدی CALL را به پشته push میکند و سپس به آدرس addr میپرد.
CALL addr
CALL = 42
آخرین شماره را از پشته pop میکند، فرض کنید یک آدرس باشد و به آن آدرس میرود.
RET
RET = 50
مقدار موجود در رجیستر را روی صفحه چاپ میکند.
PRINT reg
PRINT = 60
ماشین مجازی ما را متوقف میکند. پردازنده مجازی پس از مواجه شدن با HALT دستورالعملها را اجرا نمیکند.
HALT
HALT = 255
شبیه سازی CPU
ما میتوانیم با استفاده از مشخصات پردازنده مجازی، آن را در نرمافزار (در جاوااسکریپت) شبیه سازی کنیم.
همانطور که در بالا ارائه شد، در ماشینهای واقعی برنامهها در حافظه ذخیره میشوند. در شبیه ساز خود، از حافظه با استفاده از یک ساختار آرایهای ساده پیاده سازی خواهیم کرد.
در واقع ما فقط یک برنامه واحد را در حافظه قرار خواهیم داد.
let program = [11,0,10,42,6,255,30,0,11,0,0,11,1,1,11,3,1,60,1,10,2,0,20,
2,1,60,2,10,0,1,10,1,2,11,2,1,20,3,2,31,2,30,2,41,3,2,19,31,0,50];
پردازنده مجازی ما باید دستورالعملها را یکی یکی از این آرایه واکشی کرده و آنها را اجرا کند. CPU با استفاده از یک رجیستر مخصوص به نام "PC" (شمارنده برنامه) دستورالعملهایی را که نیاز به واکشی دارند، پیگیری میکند.
در واقعیت رجیستر PC یک ثبات واقعی در بسیاری از معماریهای فیزیکی پردازنده است.
هسته شبیه ساز پردازنده فقط یک عبارت "switch" بزرگ است که هر دستورالعمل را با توجه به مشخصات پردازش میکند.
let pc = 0;
let halted = false;
function run()
{
while(!halted)
{
runone();
}
}
function runone()
{
if (halted)
return;
let instr = program[pc];
switch(instr)
{
// handle each instruction according to specs
// also advance pc to prepare for the next fetch
// ...
}
}
و تمام! این ساختار شبیه ساز با شکوه پردازنده ما است. دستورالعمل پردازش نیز یک کار بسیار ساده است. این فقط به خواندن دقیق و پیاده سازی مشخصات دستورالعمل نیاز دارد.
switch(instr)
{
// movr rdst, rsrc
case 10:
pc++;
var rdst = program[pc++];
var rsrc = program[pc++];
regs[rdst] = regs[rsrc];
break;
// movv rdst, val
case 11:
pc++;
var rdst = program[pc++];
var val = program[pc++];
regs[rdst] = val;
break;
...
}
برای این کار وقت بگذارید، مشخصات را بخوانید و سعی کنید ببینید آیا میتوانید این پیاده سازی پردازنده مجازی را کامل کنید یا خیر. وقتی نتایج برنامه را مشاهده کنید، میدانید که کار بزرگی انجام دادهاید.
اگر با دقت کد نویسی کرده باشید، برنامه باید 10 عدد اول فیبوناچی را نشان دهد.
بارگیری برنامه
همانطور که در کدهای بالا مشاهده کردید، پیاده سازی شبیه ساز ما در یک ماشین مجازی ساده وجود دارد که در ابتدا برنامه را از آرایهای از بایتها لود میکند و سپس از پردازنده مرکزی مجازی میخواهد که آن را اجرا کند.
vm.load([11,0,10,42,6,255,30,0,11,0,0,11,1,1,11,3,1,60,1,10,2,0,20,
2,1,60,2,10,0,1,10,1,2,11,2,1,20,3,2,31,2,30,2,41,3,2,19,31,0,50]);
اگر هدف شما فقط بارگیری برنامهای است که ما ارائه دادهایم، در این حالت خوب است. اما اگر میخواهید برنامههای خود را به زبان ماشین طراحی کنید و آنها را اجرا کنید، ممکن است این یک روش موثر و کارآمد نباشد.
در اینجا یک تکنیک کوچک وجود دارد که میتواند برای بارگیری برنامهها با استفاده از دستورالعمل CPU سازگار با انسان، مورد استفاده قرار گیرد. فقط نیاز به تعریف چند ثابت است:
const MOVR = 10;
const MOVV = 11;
const ADD = 20;
const SUB = 21;
const PUSH = 30;
const POP = 31;
const JP = 40;
const JL = 41;
const CALL = 42;
const RET = 50;
const PRINT= 60;
const HALT = 255;
const R0 = 0;
const R1 = 1;
const R2 = 2;
const R3 = 3;
vm.load([
MOVV, R0, 10,
CALL, 6,
HALT,
// PrintFibo: (addr = 6)
PUSH, R0,
MOVV, R0, 0,
MOVV, R1, 1,
MOVV, R3, 1,
PRINT, R1,
// continue: (addr = 19)
MOVR, R2, R0,
ADD, R2, R1,
PRINT, R2,
MOVR, R0, R1,
MOVR, R1, R2,
MOVV, R2, 1,
ADD, R3, R2,
POP, R2,
PUSH, R2,
JL, R3, R2, 19,
POP, R0,
RET
]);
این ترفند، مانند این است که شما در جاوااسکریپت به زبان اسمبلی برنامه نویسی میکنید.
شما میتوانید برنامه جدید با mnemonics را در لینک زیر پیدا کنید: https://codeguppy.com/code.html?simple_vm/vm1_fibonacci
حال ما از این فرصت استفاده خواهیم کرد تا به سرعت چند ابزار معمولا هنگام کار با کد زبان ماشین را مورد استفاده قرار دهیم.
Assembler
شاید مهمترین ابزار هنگام کار با برنامههای زبان ماشین، برنامه اسمبلر باشد.
این برنامه به کاربران این امکان را میدهد تا برنامهها را به عنوان فایلهای متنی بنویسند و از زبان اسمبلی راحتتر استفاده کنند. سپس، اسمبلر با تبدیل کد منبع به دادههای باینری که توسط پردازنده قابل درک است، کار سنگین را انجام میدهد.
ما تلاش خواهیم کرد تا یک برنامه اسمبلر ساده ایجاد کنیم که وظیفه اساسی را انجام دهد. این مانند زیر کار خواهد کرد:
let code = `
// Loads value 10 in R0
// and calls Fibonacci routine
MOVV R0, 10
CALL 6
HALT
...
`;
let bytes = asm.assemble(code);
اگر میخواهید کد را ببینید، لطفاً لینک زیر را بررسی کنید:
https://codeguppy.com/code.html?simple_vm/vm3_assembler
Disassembler
ابزار مفید دیگری برای کار با زبان ماشین، disassembler است.
disassembler شکل باینری میگیرد و لیست خود را به روشی قابل خواندن توسط انسان، به زبان اسمبلی خارج میکند.
به این صورت روی برنامه زیر اجرا میشود:
let src = asm.disassemble(
[11,0,10,42,6,255,30,0,11,0,0,11,1,1,11,3,1,60,1,10,2,0,20,
2,1,60,2,10,0,1,10,1,2,11,2,1,20,3,2,31,2,30,2,41,3,2,19,31,0,50]
);
disassembler ما باید این را تولید کند:
0 11 0 10 MOVV R0, 10
3 42 6 CALL 6
5 255 HALT
6 30 0 PUSH R0
8 11 0 0 MOVV R0, 0
11 11 1 1 MOVV R1, 1
14 11 3 1 MOVV R3, 1
17 60 6 PRINT R1
19 10 2 0 MOVR R2, R0
22 20 2 1 ADD R2, R1
25 60 6 PRINT R2
27 10 0 1 MOVR R0, R1
30 10 1 2 MOVR R1, R2
33 11 2 1 MOVV R2, 1
36 20 3 2 ADD R3, R2
39 31 2 2 POP R2
41 30 2 PUSH R2
43 41 3 2 19 JL R3, R2, 19
47 31 0 2 POP R0
49 50 RET
این لیست شامل آدرسهای حافظه و دستورالعملهای باینری در برنامه ما است.
برای دیدن اجرای بسیار ساده یک disassembler، لطفا لینک زیر را بررسی کنید:
https://codeguppy.com/code.html?simple_vm/vm2_disassembler
درباره برنامه فیبوناتچی
احتمالا از خود میپرسید که چگونه با استفاده از برنامه اسمبلر، اعداد فیبوناچی را چاپ کنیم؟ ابتدا الگوریتم را در جاوااسکریپت نوشتیم و سپس مرحله به مرحله آن را به اسمبلر تبدیل کردیم:
پس از تجربه نوشتن اسمبلی، این کار در یک مرحله انجام میشود. این کار فقط به تمرین نیاز دارد.
جمعبندی
حتی اگر قبل از شبیه سازی یک سیستم کامل هنوز کار زیادی انجام ندادهاید، امیدوارم این یک مرور اساسی در مورد آنچه برای شبیه سازی از هسته سیستم - CPU انجام میشود - ارائه دهد.
به عنوان گام بعدی، شما را به ایجاد برنامههای اضافی برای این CPU مجازی دعوت میکنم و در صورت لزوم مجموعه دستورالعمل CPU را با دستورالعملهای جدید برای پشتیبانی از برنامههای خود گسترش دهید.
منتظر بازخورد و نظرات شما هستیم. خوشحال میشویم به آنها پاسخ دهیم.
دیدگاه و پرسش
در حال دریافت نظرات از سرور، لطفا منتظر بمانید
در حال دریافت نظرات از سرور، لطفا منتظر بمانید