مقاله های سیسوگ, توصیه شده

پیاده سازی یک CPU هشت بیتی ساده به همراه شبیه‌ساز و اسمبلر با زبان C

پیاده سازی یک CPU هشت بیتی ساده به همراه شبیه‌ساز و اسمبلر با زبان C

ساختن شبیه ساز CPU هشت بیتی

برای ساختن شبیه ساز که مانند یک CPU فیزیکی عمل کند. ابتدا نیاز هست که با نحوه کار با CPU یا پردازنده مرکزی آشنا شویم.

مقدمه
واحد CPU یا پردازنده مرکزی بخش اصلی یک کامپیوتر میباشد. نه فقط کامپیوترهای شخصی بلکه تقریبا تمام دستگاه های الکترونیکی اطراف ما. این پردازنده هسته، اکثر محاسبات کمپیوتر، کنترل ورودی/خروجی های عمومی (GPIO) برقراری ارتباط با RAM یا حافظه تصادفی و… میباشد. و برای شبیه سازی یک پردازنده با C اول نیاز است که با نحوه کارکرد CPU آشنا شویم.

پردازنده مرکزی چیست؟

مهم ترین بخش یک کامپیوتر پردازنده مرکزی یا CPU میباشد که و که وظیفه اجرای دستور های موجود در کامپیوتر را بر عهده دارد. و بخش های داخلی CPU معماری آن را به وجود می آورند. که در ادامه با این اجزای داخلی بیشتر آشنا می شویم…

 

پردازنده مرکزی

تفاوت CPU و میکروکنترلر این پروژه

یک CPU از بخش های بسیار پیچیده ای ساخته شده است و opcode های بسیار بیشتری نسبت به میکرو کنترلر دارد، بخش های مانند CU برای بخش کنترل، بخش ALU که بخش منطقی و حسابی CPU می باشد و یا timer ها در CPU که وظیفه کنترل و ایجاد وقفه ها را دارند، یکی دیگر از تفاوت های یک CPU یا (Microprocessor) با Microcontroller تفاوت آنها در مقدار دستور ها یا اندازه ROM می باشد، یک میکروکنترلر از ROM بزرگتری برخوردار است در حالی که یک میکروپروسسور دارای RAM بیشتری می باشد.

عموما یک میکروکنترلر مشابه به CPU می باشد اما بسیار ساده تر و در این بخش ما سعی داریم که یک میکروکنترلر که چهار عملیات NOP، BSF، BCF و GOTO را انجام می دهد را با زبان برنامه نویسی C پیاده سازی می کنیم.

اجزای داخلی CPU و نحوه کارکرد آنها

اجزای داخلی CPU و نحوه کارکرد آنها

  • حافظه دسترسی تصادفی (RAM)

این حافظه محل نگهداری دستور ها برای دسترسی سریع میباشد. با توجه به دستور داخل CPU ممکن هست که نتیجه آن در آدرس داده شده در RAM نگهداری شود یا اینکه مقداری از RAM خوانده شود، البته RAM یک حافظه فرار است به معنی این که اگر CPU روشن نباشد (نبود برق) تمامی داده های داخل RAM از بین میرود!

  • حافظه فقط خواندنی (ROM)

این حافظه محل نگهداری دستور های CPU میباشد، و CPU با خواندن این داده ها عملیات های مورد نظر را انجام میدهد، بر خلاف RAM تمام داده (دستور) های ذخیره شده در این CPU با نبود برق از بین نمیرود، برای مثال آنها مانند فلش مموری ها هستند البته برای CPU و دستور های مربوط به آن.

  • رجیستر ها (Registers)

رجیستر ها ساختاری مشابه به RAM را دارند اما محل نگهداری داده های “خروجی” CPU هستند، داده های که برای GPIO یا ورودی / خروجی های عمومی استفاده می شود، و راه های ارتباطی با دستگاه های خارجی را به وجود می آورند، مثل چاپگر و…، و دلیلی که از RAM استفاده نمی شود این است که استفاده از RAM سرعت انتقال / دریافت را کاهش می دهد و برای همین رجیستر ها که ساختاری مشابه با RAM را دارند مورد استفاده قرار می گیرند.

  • ساعت (Clock)

ساعت سیستم با ایجاد کردن سیگنال تمامی بخش های مختلف CPU را باهم همگام میکند (sync) تا تمامی دستور ها مرحله به مرحله انجام شوند.

چگونه CPU دستور ها را اجرا میکند؟

مثل تمامی پروسه ها CPU نیز در یک حلقه با هر سیگنال ساعت که شمارنده برنامه دریافت می کند یک دستور را ار ROM دریافت میکند (Fetch)، نوع دستور را مشخص میکند (Decode) و بعد با توجه به نوع دستور آن را اجرا میکند (Execute) و بعد دستور بعدی از ROM و این چرخه با توجه به دستور ها و شمارنده برنامه ادامه دارد…، در این مرحله بخش های دیگری از CPU هستند که به آنها می پردازیم…

  • شمارنده برنامه (PC)

شمارنده برنامه یا Program Counter یک شمارنده هست که مقدار آن با توجه به سیگنال ساعت تغییر میکند، این شمارنده در هر زمان با توجه به دستور اجرا شده به آدرسی از ROM اشاره میکند و CPU از این طریق متوجه میشود که در هر سیگنال کدام مقدار را ار ROM بخواند و آن را اجرا کند.

  • بخش دریافت (Fetch)

بخش Fetch وظیفه دریافت دستور را از ROM دارد که آدرس دریافتی را PC یا شمارنده برنامه مشخص میکند و بعد از دریافت مقدار از ROM آن دستور را برای شناسایی به بخش Decode (رمز گشایی) می فرستد.

  • بخش رمزگشایی (Decode)

این بخش وظیفه مشخص کردن نوع دستور را دارد، که مشخص میشود که این دستور یک فرایند حسابی است یا مقدار آن باید در RAM  یا یکی از Register ها ذخیره شود یا این مقدار نیاز به تغییر مکان داده ها باشد و…، که بعد از مشخص شدن نوع دستور، دستور وارد مرحله اجرا یا (Execute) می شود.

  • بخش اجرا (Execute)

بخش Execute وظیفه اجرا دستور داده شده را دارد، که با توجه به نوع دستور ممکن است که مقدار یکی از رجیستر ها یا RAM تغییر کند یا مقداری برای ذخیره سازی به مکانی دیگر فرستاده شود و یا این که ممکن است منجر به تغییر مقدار شمارنده برنامه (PC) شود، که این کار بیشتر برای ایجاد یک چرخه در CPU و یا عبور از بخشی از دستور های ROM یا تغییر مقداری از ورودی/خروجی های عمومی (GPIO) شود.

ورودی یا خروجی های عمومی (GPIO)

پایه های ورودی / خروجی عمومی وظیفه انتقال یا دریافت داده ها را از وسیله یا دستگاه های خارجی را دارند، و مقدار آنها ممکن است با توجه به آدرس هایی خاصی از رجیستر ها باشند که برای این کار رزرو شده اند.

شناسایی کد عملیات (opcode)

با توجه به مقدار ورودی به decode از ROM مکانی که داده های به صورت هگز (Hexadecimal) ذخیره میشوند، بخش decode مقدار ورودی هگز (از 000 تا FFF) را شناسایی میکند و با توجه به کد عملیاتی آن نوع دستور را مشخص می کند!

کد عملیاتی (opcode) و باینری

کد عملیاتی (opcode) و باینری

 

یکی از راه های شناسای کد های عملیاتی، تشخیص کد از روی باینری آنهای میباشد، که معمولا 3 تا 4 دستور اول کد باینری (از چپ) بیانگر نوع کد عملیاتی میباشد، در ادامه مثال های بیشتری را برای تشخیص کد های عملیاتی بررسی می کنیم.

کد عملیاتی BSF و تشخیص آن

  • کد عملیاتی

نحوه استفاده از دستور BSF به شکل زیر می باشد:

 

BSF addr, bit

که در اینجا addr بیانگر آدرس رجیستر و bit، شماره بیت میباشد (راست به چپ).

وظیفه این دستور این است که بیت داده شده در آدرس داده شده را برابر با 1 قرار می دهد.

  • تشخیص

برای مثال مقدار ورودی به بخش decode که محل مشخص کردن کد عملیاتی است برابر است با 0x506 که مقدار هگز ورودی میباشد، حالا اگر این مقدار به باینری (binary) تبدیل بشود، معادل زیر را دارد:

0b010100000110

که در این کد، چهار بیت اول (از سمت چپ) بیانگر opcode بخش BSF میباشد. که برابر است با 0101 و 3 بیت بعدی برای شماره بیت و 5 بیت آخر برای آدرس، به صورت کلی:

0101 bbbf ffff

که مقدار های f برای آدرس و b  برای شماره بیت!

 

کد عملیاتی BSF

 

کد عملیاتی BCF و تشخیص آن

  • کد عملیاتی

این کد رفتاری مانند BSF دارد اما مقدار مشخص شده را برابر با 0 قرار می دهد

برای مثال دستور:

BCF addr, bit

مقدار بیت مشخص شده در آدرس داده شده را برابر با 0 قرار می دهد!

  • تشخیص

برای مثال ورودی بخش decode برابر است با مقدار هگز 0x406 که شکل باینری آن به صورت زیر می باشد:

010000000110

که این دستور نیز مانند دستور BSF چهار بیت اول (از سمت چپ) بیانگر مقدار opcode برای دستور BCF میباشد که برابر است با 0100، در نهایت شکل کلی به صورت زیر می باشد:

0100 bbbf ffff

 

کد عملیاتی BCF

کد عملیاتی GOTO و تشخیص آن

  • کد عملیاتی

این کد برای تغییر مقدار شمارنده برنامه PC استفاده می شود که ساختار زیر را دارد:

GOTO label

که مترجم یا assembler مقدار label را برابر با آدرس آن قرار می دهد، دستور GOTO مقدار PC را تغییر میدهد و این میتواند برای ایجاد حلقه ها یا عبور از بخشی از دستور ها که نیاز به اجرا شدن ندارند مفید باشد.

  • تشخیص

برخلاف BSF و BCF کد عملیاتی GOTO فقط شامل 3 بیت اول است (از چپ) و ادامه آن برای آدرس می باشد.

برای مثال ورودی برابر است با 0xa00 که مقدار باینری آن برابر است با:

1010 0000 0000

که مقدار 101 بیانگر GOTO می باشد و ادامه بیت ها برای آدرس GOTO است.

کد عملیاتی GOTO

 

کد عملیاتی NOP و تشخیص آن

  • کد عملیاتی

کد عملیاتی NOP یا No Operation بیشتر برای ایجاد وقفه در برنامه استفاده می شود، و برای انجام عملیات خاصی نیست، هر بار که دستور NOP موجود باشد میکروکنترلر عملیاتی را انجام نمی دهد و PC به آدرس بعدی اشاره میکند

  • تشخیص

مقدار هگز ورودی آن همیشه یکسان و برابر است با 0x000 که مقدار باینری زیر را دارد:

0000 0000 0000

کد عملیاتی NOP

 

ساخت اسمبلر و تبدیل کد های اسمبلی به کد ماشین

پردازنده مرکزی کدهای ما که به صورت اسمبلی (Assembly) نوشته می شود را متوجه نمی شود و برای این کار ما نیاز به یک مترجم (Assembler) داریم که کد های نوشته شده به اسمبلی را به دستور های برای ماشین (هگز) تبدیل کند.

کاری که اسمبلر انجام می دهد این است که تمام کد های داده شده به اسمبلی را خط به خط بررسی می کند. و بعد آنها را با توجه به opcode دستور به دستور های دوتایی هگز تبدیل میکند (FFF).

برای مثال به کد اسمبلی زیر توجه کنید:

ساخت اسمبلر

  • در خط (1) مقدار GPIO  برابر می شود با 0x06 و assembler یک متغیر ثابت بنام GPIO با مقدار 0x06 را در خود ایجاد می کند، که این دستور فقط مختص به assembler میباشد و دستوری برای ذخیره در فایل خروجی نیست، و فقط برای جایگذاری مقدار های GPIO با مقدار 0x06 می باشد.
  • در خط (2) یک برچسب (label) به نام start تعریف می شود، و label ها عموما به صورت یک اسم و در ادامه علامت دو نقطه به وجود می آیند، هر label با یک مقدار در assembler ذخیره می شود که هر اسم label با تعداد دستور های قبل از خود ذخیره می شود، در اینجا چون دستوری قبل از label برای خروجی وجود ندارد پس مقدار آن برابر با 0 است.
  • در خط (3) دستور BSF استفاده شده است، با مقدار GPIO برای آدرس و مقدار 0 برای بیت 0 (اولی از راست), که assembler مقدار GPIO را با مقدار اصلی که در خط 1 مشخص شده جایگذاری می کند که برابر با 0x06 بود، این دستور اولین دستور برای تبدیل به کد ماشین می باشد. پس assembler با توجه به opcode دستور مقدار باینری را ایجاد می کند که به صورت:

دستور مقدار باینری

و بعد از آن و انجام عملیات bitwise ها مقدار را در یک لیست برای مجموع دستور ها ذخیره می کند!

کاری که این دستور انجام می دهد برای قرار دادن اولین بیت از رجیستر 0x06 به 1 است.

  • خط (4) دارای یک دستور NOP به معنی بدون عملیات می باشد و در اینجا برای ایجاد وقفه مورد استفاده قرار می گیرد، و مقدار آن که برابر بود با 0x000 یا 000000000000 را به لیست دستور ها اضافه می کند.
  • در خط (5) دستور BCF مورد استفاده قرار گرفته که دارای آدرس رجیستر GPIO که برابر بود با 0x06 و اولین بیت از سمت راست که برابر است با 0 و عملیات bitwise صورت گرفته در این به شکل زیر می باشد که bit برای بیت داده شده و reg برای آدرس رجیستر است:

 

عملیات bitwise

و در نهایت مقدار محاسبه شده به لیست دستور ها اضافه می شود.

کاری که این دستور انجام می دهد این است که اولین بیت از سمت راست از رجیستر GPIO را برابر با 0 (clear) قرار می دهد.

  • و در نهایت در خط (6) دستور GOTO استفاده شده است، که در اینجا GOTO مقدار start را دارد که نام label در خط 1 است، و مقدار start را که ذخیره کرده بود (برابر با 0) را قرار می دهد و عملیات انجام شده به شکل زیر می باشد:

پیاده سازی یک CPU هشت بیتی ساده به همراه شبیه‌ساز و اسمبلر با زبان C

و بعد assembler نتیجه را به لیست دستور ها اضافه می کند. کاری که این دستور انجام میدهد این است که مقدار PC در میکروکنترلر را برابر با اندازه داده شده (0) قرار می دهد و این باعث ایجاد یک چرخه می شود که دستور ها از BSF تا GOTO مداوم تکرار شوند.

نتیجه کلی دستور ها به شکل زیر می باشد:

نتیجه کلی دستور های اسمبلی

و بعد از این کار assembler تمامی دستور های لست دستور ها را به صورت هگز های دوتایی به صورت باینری در یک فایل ذخیره می کند مثل blink.bin که bin بیانگر باینری است (که این فایل تمام دستور های شبیه ساز می باشد (ROM بخش میکروکنترلر)) و اگر فایل را با hex-editor ها یا برنامه هایی که برای دیدن هگز هستند باز کنیم چیزی شبیه به شکل زیر را دارد:

دیدن هگز

 

کد مربوط به این بخش:

https://github.com/empitrix/assembler

شبیه سازی میکروکنترلر در زبان C

برای این کار اول نیاز است که متغیرهای شبیه ساز را بررسی کنیم:

 اولین متغیر برای ذخیره سازی دستور های ROM می باشد به بیان دیگر یک متغیر برای ROM این میکروکنترلر که به صورت زیر می باشد:

یک متغیر برای ROM میکروکنترلر

 

که این متغیر دارای ساز 128 برای ذخیره دستور ها می باشد و unsigned short برای بیان این که این متغیر مقادیر بین 0x0000 تا 0xFFFF را ذخیره میکند یا به عبارت دیگر برابر با 256 (از 00 تا FF) برای دستور ها (فایل خروجی از assembler).

دو متغیر بعدی برای RAM و Register ها می باشند:

در این شبیه ساز از دو متغیر با تایپ uint8_t که بیانگر unsigned char یا از 0 تا 255 (مجموع 256) است استفاده شده است، که می توانند مقدار های هگز بین 0x00 تا 0xFF را در خود ذخیره کنند.

پیاده سازی یک CPU هشت بیتی ساده به همراه شبیه‌ساز و اسمبلر با زبان C

 

که آدرس RAM از 0x10 شروع می شود و در 0x1F پایان میابد، (در مجموع 10 عدد)

متغیر PC یا شمارنده برنامه نیز به صورت زیر تعریف می شود:

پیاده سازی یک CPU هشت بیتی ساده به همراه شبیه‌ساز و اسمبلر با زبان C

خواندن دستورات و ایجاد ROM

برای ایجاد ROM اول نیاز است که فایل مربوط که همان خروجی مترجم (assembler) را بخوانیم و array مربوط به ROM را آپدیت کنیم که به صورت زیر انجام می شود:

خواندن دستورات و ایجاد ROM

 

  • خط های 1 و 2 برای خواند فایل میباشد که آدرس آن در بخش out.bin قرار میگیرد که همان خروجی فایل Assembler می باشد.
  • خط های 3 و 4 برای بررسی اینکه آیا آدرس فایل ایجاد شده موجود است یا نه می پردازد، و در صورت موجود نبودن از برنامه خارج می شود.
  • خط های 6 تا 8 برای تعریف متغیر های مورد نیاز میباشد که value برای نگه داری نتیجه نهایی (دستور) و idx برای شمارنده مقدار ROM های مقدار دهی شده می باشد که از 0 شروع می شود و buffer برای نگهداری مقدار های خوانده شده از ROM میباشد که 2 عدد برای 00 تا FF می باشد.
  • خط های 10 تا 13 برای آپدیت کردن متغیر rom می باشد و در خط 10 تا زمانی که ممکن باشد با توجه به اندازه buffer مقدار هایی را برمی گرداند و در buffer ذخیره می کند و هر 2 مقدار buffer به روز می شود و در خط 11 با merge کردن یا بهم چسباندن دو مقدار و ذخیره آن در value یک دستور rom که از 0x000 تا 0xFFF می باشد را به وجود می آورد و در value ذخیره می کند
  • در خط 12 مقدار محاسبه شده value برای ذخیره در rom استفاده می شود و idx به مقدار 1 افزایش مییابد تا به دستور بعدی برای ذخیره در لیست دستور های rom اشاره کند
  • در خط 15 فایل بسته می شود تا اطلاعات آن آسیبی نبیند و دوباره قابل استفاده باشد.

تشخیص OpCode از روی دستور دریافت شده (Decode)

برای اینکار با یک حلقه هر دستور rom را مورد بررسی قرار می دهیم

 

تشخیص OpCode از روی دستور دریافت شده (Decode)

 

 

برای این کار نیاز به دو مقدار opcode و operand از هر دستور (instruction به مخفف inst) نیاز داریم

در خط دوم، ما مقدار inst را 8 خانه به راست جابه جا کردیم (مقدار کل: 12) و از 4 دستور اول (از راست) را برای opcode استفاده می کنیم، و در خط سوم از 8 خانه اول از سمت راست برای مقدار عملوند یا operand استفاده میکنیم

بررسی opcode و operand برای اجرا کردن

با تعریف کردن enum زیر برای opcode ها میتوان که به بررسی آنها کمک کرد، که هرکدام با مقدار عددی باینری opcode مقدار دهی شده اند:

ریف کردن enum برای opcode

و حالا با استفاده از متغیر های opcode و operand می توان کد switch/case زیر را برای تشخیص آنها نوشت:

ن کد switch/case

 

با دادن opcode به switch میتوان کار تشخیص را بسیار راحت تر کرد

  • خط های 4 و 5 برای کد NOP می باشد و نیازی به اجرای چیزی ندارد
  • خط 7 تا 10 برای تشخیص BCF می باشد و بخش operand به دو بخش bit_n و reg_n تقسیم می شود که بخش bit_n برای شماره بیت ها (3 تا) و بخش reg_n برای آدرس رجیستر (5 تا) می باشد و در متغیر های مربوطه ذخیره می شوند
  • خط 12 تا 15 نیز برای دستور BSF می باشد که همانند BCF بخش reg_n که برای آدرس رجیستر و بخش bit_n که برای شماره بیت می باشد در متغیر های مربوطه ذخیره می شود
  • خط 17 تا 19 برای دستور GOTO می باشد و بخش operand که در کد قبلی مشخص شده برابر شده با مقدار PC و مقدار PC به روز می شود، تا دستور های بعدی که برای Decode فرستاده می شوند را مشخص کند. (در این بخش GOTO اجرا می شود)
  • در خط 21 و 22 برای دستور های ناشناخته می باشد که آنها را در نظر نمیگیریم

اجرای دستور ها و به آپدیت کردن register ها

در بخش execute که محل اجرای دستور ها می باشد با متغیر های از قبل تعیین شده و با استفاده از آنها دستور ها را اجرا می کنیم:

آپدیت کردن register ها

 

در این بخش بار دیگر از switch/case استفاده میکنیم تا بخش های مربوط به اجرا شدن را بررسی کنیم.

  • از خط 3 تا 5 که محل اجرای BSF می باشد، در خط 4 با توجه به reg_n و bit_n عدد بیت داده شده در رجیستر مشخص شده برابر می شود با 1 و بقیه بیت ها دست نخورده باقی می مانند
  • از خط 8 تا 10 که محل اجرای دستور BCF میباشد، در خط 9 با توجه به داده های قبلی bit_n و reg_n با استفاده از bitwise ها مقدار بیت مشخص شده، در رجیستر داده شده برابر می شود با 0 و بقیه بیت ها دست نخورده باقی می مانند

 

تا این بخش دستور های BSF، BCF، NOP و GOTO مورد بررسی قرار گرفتند و با نحوه اجرا شدن آنها آشنا شدیم.

کد های مربوط به این بخش:

https://github.com/empitrix/8bitcpu

برای چه نیاز به یک شبیه ساز CPU (میکروکنترلر) داریم؟

برای آشنایی با نحوه کارکرد CPU و آموزش، ساختن یک شبیه ساز (emulator) گزینه مناسبی می باشد، و با نحوه ایجاد کد ماشین و اجرا شدن آن در میکروکنترلر بیشتر آشنا می شویم، و از آنجایی که میکرو کنترلر ها پردازنده های ساده تری نسبت به CPU هستند، تلاش برای ساختن آنها می تواند بسیار آموزنده باشد، جدای از این یک شبیه ساز میتواند کنترل بیشتری برای تست کردن را به ما بدهد یا میتوان از آنها زمانی که سیستم واقعی موجود نباشد استفاده کرد. 

تفاوت بین Emulator و Simulator

یک Simulator مشابه به چیزی هست که از روی آن ساخته می شود اما تمامی رفتار های آن را ندارد و بیشتر برای بررسی یک قطعه یا بخش … مورد استفاده قرار می گیرد، در حالی که یک Emulator تمامی رفتار های مشابه را دارد و دقیقا به صورتی که یک دستگاه یا قطعه کار می کند، رفتار میکند و همان نتایج را به موجود می آورد ولی simulator این کار را انجام نمی دهد و بیشتر برای مطالعه آن دستگاه / محصول می باشد.

💡درباره من

من مهدی هستم و دلیل انجام این پروژه تمرین برای یادگیری زبان C و آشنایی بیشتر با نحوه انجام کار میکروکنترلر ها و آشنایی بیشتر با machine code و نحوه اجرای دستور ها در میکروکنترلر ها بود.

از جمله آشنای با نحوه تبدیل کد های assembly به کد ماشین و اجرای آنها توسط یک پردازنده، که بسیار سرگرم کننده است.

کدهای این پروژه

کدهای این پروژه

کد مربوط به بخش assembler را که برای سیستم های linux است را میتوان از لینک زیر دریافت کرد

https://github.com/empitrix/assembler

و کد مربوط به شبیه ساز میکروکنترلر 8 بیتی را میتوان از لینک github زیر دریافت کرد:

https://github.com/empitrix/8bitcpu

که برنامه های ورودی این پروژه را میتوان با assembler به وجود آورد.

انتشار مطالب با ذکر نام و آدرس وب سایت سیسوگ، بلامانع است.

شما نیز میتوانید یکی از نویسندگان سیسوگ باشید.   همکاری با سیسوگ

1 دیدگاه در “پیاده سازی یک CPU هشت بیتی ساده به همراه شبیه‌ساز و اسمبلر با زبان C

  1. Avatar for Ali Ali گفت:

    عالی❤
    بیشتر از این چیزها بزار🫡🙏🏻

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *