(ویرگول عزیز درفت ۲۰۰۰ کلمهای این مطلب رو پاک کرد، اما تسلیم نشدم و از نو نوشتمش!)
در این مطلب چند قسمتی میخواهم نشان دهم کامپیوتر چگونه از ۰ و ۱ برای نمایش و پیادهسازی همهچیز استفاده میکند. قسمت اول را اینجا بخوانید.
در قسمت قبل کمی در مورد صفر و یک و اینکه لزوما هر صفر و یکی معنی عدد مبنای دو نمیدهد خواندیم. همچنین چند قرارداد مختلف برای معنی کردن رشتههایی از صفر و یک را دیدیم. مثلا ASCII یک استاندارد است که در آن هر ۸ بیت معنی یک کاراکتر خاص را میدهد.
همچنین در مورد تفاوت دستور و داده خواندیم. پردازنده یکسری دستور دارد که براساس آنها دادههای ورودی را به دادههای خروجی تبدیل میکند. در این قسمت در مورد پردازنده بیشتر میخوانیم.
در کامپیوترهای دیجیتال، فقط دو مقدار صفر منطقی و یک منطقی داریم، مثلا ۰ ولت برابر ۰ منطقی و ۵ ولت برابر یک منطقی است.
اگر بخواهیم با این دو مقدار، عددی یا حرف الفبایی را نشان دهیم از استانداردهای مخصوص خودش استفاده میکنیم مثلا مکمل دو یا IEEE 754 یا ASCII.
اما برای اینکه منطق و محاسبات را با این دو مقدار نشان دهیم باید چه کار کنیم؟ ابزاری که در اختیار داریم جبر بول یا boolean algebra است. اگر با جبر بولی آشنا نیستید، ویکیپدیای آن را مطالعه کنید. در این مدل از ریاضیات، ۲ مقدار ۰ و ۱ (یا درست و غلط) را داریم و روی آنها عملیاتهای مختلفی تعریف میشود. مثلا and و or و not.
برای توضیح این ۳ عملگر، میتوانیم از اعمال آشنای ریاضی استفاده کنیم، and معادل min است، اگر هردو عملوند ۱ باشند نتیجه ۱ میشود وگرنه صفر میشود. or هم max است، اگر هردو یا حداقل یکی برابر ۱ باشند نتیجه یک میشود وگرنه صفر. عملگر not هم برابر نقیض میشود. یعنی نقیض true میشود false و نقیض false میشود true.
در منطق بولی، برای نشان دادن محاسبات از نشانهگذاری خاصی استفاده میشود.
مثلا:
این نشانهگذاری اگرچه برای منطق و ریاضی بسیار کاربردی و خوب است، اما در کامپیوتر با توجه به نیازهای خاص معمولا از یک شیوه دیگر استفاده میشود.
بیان منطق ریاضی همیشه ساده نیست، مثلا یک تابع ریاضی در ذهنم دارم و به ازای ورودیهای مختلف میخواهم خروجیهای مختلف داشته باشد و مدام تغییرش دهم. از طرف دیگر برایم مهم نیست از چه ترکیبی از and و or ساخته شده. برای این منظور از جدول درستی یا Truth Table استفاده میشود.
این یک نمونه ساده از جدول درستی است، به ازای یک تابع دو ورودی و یک خروجی آن را رسم کردهایم. تابع رسمشده همان and است. دو ورودی p و q دارد و خروجی p^q (که نماد ریاضی برای همان p and q است) را بررسی کردهایم.
همانطور که مشخص است هر ستون نشان دهندهی یک ورودی یا خروجی تابع ماست. هر سطر نیز یک حالت ممکن است مجموعهی حالات ممکن را نشان میدهد. دو ورودی داریم، هر کدام نیز دو حالت میتوانند داشته باشند بنابراین جمعا ۴ حالت داریم برای همین ۴ سطر را در نظر گرفتهایم. در هر سطر مقدارهای ورودی مشخص هستند، مقدار خروجی تابع را نیز مشخص میکنیم.
به این ترتیب به ازای همه حالات ورودی، مقدار خروجی را داریم پس میتوان گفت جدول درستی میتواند یک تابع بولی را کاملا نشان دهد.
اما این روش هم یک عیب بسیار بزرگ دارد، با زیاد شدن تعداد ورودیهای سیستم، تعداد سطرها به شکل نمایی افزایش پیدا میکند. مثلا برای ۳۲ ورودی، دو به توان ۳۲ یا ۴ میلیارد سطر خواهیم داشت. در حالی که برای یک چیپ دیجیتال ۳۲ ورودی اصلا تعداد ورودیهای زیادی نیست (نگران نباشید، در ادامه ارتباط منطق و چیپ میرسیم.)
تا اینجا دیدیم که توابع جبری را چطور میتوان نشان داد، مثلا از جدول درستی استفاده کردیم تا یک تابع جبری ساده را نشان دهیم، اما جبر بولی و جدول درستی همه روی کاغذ و در تئوری هستند، ارتباطشان به دنیای دیجیتال چیست؟
دنیای دیجیتال به کمک ترانزیستورها، میتواند هر تابع بولیای را پیادهسازی کند. پیادهسازی یعنی میتوانیم یک جسم داشته باشیم که چند تا پایه فلزی ورودی و خروجی دارد و طبق جدول درستی داده شده کار کند. مثلا اگر دو تا ۵ ولت در ورودی دهیم، ۵ ولت در خروجی دهد وگرنه ۰ ولت دهد (تابع and)
شکل بالا یک نمونه پیاده سازی فیزیکی (البته برای اسباب بازی و نه کار صنعتی) را نشان میدهد. این اسباببازیها از اینجا قابل خریداری هستند. (اگر با قیمت ۴۷.۵ دلار راحت هستید، لطفا برای من هم بخرید.)
اما از این چیپ ها در این اندازه در صنعت استفاده نمیشود، چرا؟ چون به دنبال کوچک کردن چیپهای ساخته شده هستیم و چیپهایی که واقعا در پردازندهها استفاده میشود چندین میلیارد ترانزیستور دارند، و برای اینکه ساختشان ممکن باشد ترانزیستورها را به ابعادی در حدود چند نانومتر در میآورند (این روزها ۴ یا ۷ نانومتر لبهی تکنولوژی است) یعنی در حدود ابعاد اتم!
حتی اگر پردازندههای لبهی تکنولوژی اپل و اینتل و AMD را هم هدف قرار ندهیم، در یک تکنولوژی به کار رفته در یک موس لیزری هم استفاده از گیتهایی با این ابعاد بزرگ میسر نیست! در دنیای واقعی برای تهیهی یک گیت and میتوانیم یک چیپ بخریم که ۴ یا ۸ تا گیت and داخلش دارد و مثلا ۱۴ پایه دارد. این چیپها قیمت معقولی دارند و در کشور عزیزمان هم به راحتی قابل خریداری هستند.
در میانهی پاراگراف بالا صحبت از گیت شد، تفاوتش با یک تابع بولی که میتوانستیم با ترانزیستور پیاده سازی کنیم چیست؟
همانطور که گفتم ترانزیستور کوچکترین جزء سازندهی دیجیتالی است، ما میتوانیم هر تابعی بولی را با ترانزیستور پیادهسازی کنیم، اما پیادهسازی با ترانزیستور چندان کار سادهای نیست، مخصوصا برای توابع بزرگ.
برای همین با ترانزیستورها، توابع پایهای که بسیار مورد نیاز هست مثلا همان and و or و not را پیاده سازی میکنند، سپس با این گیتها باقی توابع را پیادهسازی میکنند. گیتها معمولا با تعداد کمی ترانزیستور ساخته میشود.
گیتها برای ساخت چیزهای دنیای دیجیتال کافی هستند، مثلا اثبات میشود که با داشتن مجموعهی and و or و not میتوانیم هر جدول درستیای را پوشش دهیم. حتی با فقط گیت nand یا فقط گیت nor هم میتوان این کار را کرد. به گیتهای nand و nor گیتهای universal میگویند.
در این تصویر گیتهای اصلی را به همراه جدول درستیشان میبینیم.
شاید بگویید که اگر به هر زحمتی هست با خود ترانزیستورها بتوانیم توابعمان را پیادهسازی کنیم، نتیجه بهینهتر (با تعداد ترانزیستور کمتر) باشد، درست است اما این یک مصالحه است! ما به دنبال راحتتر کردن طراحی خودمان هم هستیم.
در مورد اینکه چطور با خود گیتها می توان هر تابعی را پیاده سازی کرد، میتوانید از این لینک مطالعه کنید.
اما چیپ، یک قطعه ساختهشده است که پایههای رسانا دارد و براساس ورودیهایش، عملیات انجام میدهد و خروجی میدهد. تعریف خیلی دقیق نیست اما تمرکزم بر قطعه فیزیکی بودنش است. (بیشتر بخوانید.)
تا اینجا یاد گرفتیم که توابع جبر بولی را میتوان به شکل فیزیکی با کمک ترانزیستورها پیادهسازی کرد، اما این چه کمکی به ما در دنیای واقعی میکند؟ ما در دنیای واقعی میخواهیم از کامپیوترها استفاده کنیم تا برایمان محاسباتمان را انجام دهند، محاسباتی که ما نیاز داریم، نه هرچیزی که زبان کامپیوتر هست!
کامپیوتر برای اینکه محاسبات دلخواه ما را انجام دهد، باید محاسباتمان را به زبان کامپیوتر بیان کنیم، تا اینجای بحث یعنی بتوانیم هر عملیاتی که میخواهیم انجام دهیم را به زبان کامپیوتر بگوییم، بعد ورودی را به شکل مناسب بدهیم و خروجی را بگیریم. قاعدتا یادتان هست که شکل مناسب برای کامپیوتر یعنی به شکل ۰ و ۱ی باشد که روی ورودی چیپمان اعمال می شود.
بیایید با یک مثال جلو برویم، فرض کنیم میخواهیم دو عدد یک رقمی را با هم به کمک کامپیوتر جمع کنیم. اما یک رقمی در دنیای کامپیوتر یعنی یک بیتی! به بیان کامپیوتر یعنی میخواهیم یک half-adder بسازیم.
اسم half adder در مقابل full adder است، half-adder نسخهی ساده شده است که فقط ۲ ورودی دارد، دو تا عدد یک بیتی A و B و دو تا خروجی دارد.
خروجی اول S یا Sum است که مجموع را نشان میدهد، اما اگر هر دو عدد یک باشند جمعشان در یک بیت sum جا نمیشود، بنابراین یک خروجی دیگر به نام C یا همان Carry هم دارد، به نوعی میتوان گفت ۱۰ بر ۱ است که وارد رقم بعد میشود. در اینجا رقم سمتِ چپِ جمعمان را نشان میدهد. مثلا اگر هر دو ورودی یک باشند جواب ما می شود ۲ یا 10 در باینری، پس carry برابر ۱ می شود و sum برابر ۰ می شود.
سمت چپ تصویر نیز جدول درستی آن را میبینید که باز هم به علت اینکه دو ورودی دارد فقط شامل ۴ سطر است.
سمت راست تصویر پیادهسازی آن را با گیتهای AND و XOR را نشان میدهد.
شاید بگویید که جمع کردن دو عدد یک بیتی هم که دردی از ما دوا نمیکند، اما قدم به قدم باید دنیایمان را بسازیم. قدم بعدی این است که دو عدد ۳۲ بیتی بدون علامت را جمع کنیم. برای این منظور دو راه داریم، راه اول است که یک جدول درستی با ۶۴ ستون (۲ تا ورودی داریم که هر کدام ۳۲ بیت ورودی دارد) و دو به توان ۶۴ سطر را رسم کنیم که چندان کار سادهای نیست، راه دوم این است که از بلوکهای کوچکتری که ساختیم استفاده کنیم. مثلا به نحوی ۳۲ تا half adder را با هم ترکیب کنیم. البته راههای دیگر مثل طراحی کامپیوتر هم هست، چرا که نه.
این یک جمع کنندهی ۳۲ بیتی است که از ۳۲ تا جمع کنندهی full adder (همان FA در شکل) استفاده کرده. تفاوت full adder با half adder در این است که full adder توانایی ترکیب شدن برای ساخت ساختارهای بزرگتر را دارد، در واقع ۳ ورودی دارد که سومین ورودی carry in است، یعنی اگر جمع قبلی دارای carry بود، انتظار میرود carry in این بیت یک شود تا بتوانیم جمع را درست حساب کنیم. (اگر متوجه نشدید اشکالی نداره.)
گفتیم که not یک تابع تک ورودیه و ورودی برعکس میکنه، یعنی یک منطقی رو تبدیل به صفر منطقی میکنه و صفر منطقی رو تبدیل به یک منطقی میکنه.
وقتی ما میتونیم گیت not رو در دنیای واقعی داشته باشیم یکی یه شیای داریم که 0 ولت رو به عنوان ورودی میگیره و تبدیل به ۵ ولت میکنه. عجیب نیست؟ پایستگی انرژی نقض نمیشه؟
واقعیت اینه که این گیتها به عنوان ورودی و خروجی منطقیشون ۰ و ۵ ولت میگیرن، اما جدا از این داستان، نیاز دارند که به منبع انرژی هم متصل باشند، مثلا همین گیت not یک ورودی و یک خروجی منطقی داره اما یک جفت سیم فاز و نول هم داره و انرژیش از اونجا تامین میشه. (همهی گیتها به همین صورت هستند.) اما برای سادگی توی طراحی اون منبع انرژی رو اصلا رسم نمیکنیم و فقط به قسمت منطقیش میپردازیم.
در اینجا یک سخت افزار ساختیم که جمع دو عدد را حساب میکرد، اما مشکلی که دارد است است که فقط یک کار را انجام میدهد. اگر من فردا به جای جمع بخواهیم منها کنم باید از اول طراحی کنم و بدهم برایم بسازند. این که صرفه ندارد!
برای اینکار چیزی به اسم ALU ساخته میشود.
واحد محاسبه و منطق یا ALU، یک چیپ است که چندین عملیات را در خودش جا داده است، با دادن ورودیها به عنوان operand1 و operand2 و تنظیم عملکرد (function) میتونیم نتایج مختلفی را ببینیم و عملیاتهای مختلفی را برایمان انجام دهد.
مثلا ALU بالا، به ازای opcode برابر ۰۰ عملیات جمع را انجام میدهد. opcode یک کد باینری است که کارکرد ALU را مشخص میکند. با تغییر opcode کارکرد آن هم عوض میشود، مثلا تا الان جمع میکرد ولی از این به بعد ضرب میکند!
برای اطلاع از نحوهی کار مثلا عملکرد هر opcode یک alu یا کلا هر چیپی باید Datasheet مخصوص آن را بخوانیم.
تا اینجا یک پردازشگر (همان واحد منطقی) ساختیم که چند عملیات میتوانست در خودش داشته باشد و در زمان استفاده هر کدام از عملیاتها را که میخواستیم انتخاب میکردیم.
مشکل اینجاست که این عملیاتها محدود هستند، اگر فردا یک عملیات دیگر خواستیم باید چیکار کنیم؟ یک ALU جدید بسازیم؟ البته این هم یک راه است اما برای کاربر نهایی آنچنان اهمیتی ندارد.
تا اینجا هر محاسبهای که داشتیم را مستقیم به زبان سختافزار بیان میکردیم و یک سختافزار میگرفتیم که دقیقا آن کار را برای ما انجام میداد. این سختافزار دقیقا یک هدف داشت و آن را سریع و بهینه انجام میداد. به این حالت ASIC میگویند. برای وقتی که کامپیوتر ما فقط و فقط یک هدف دارد (مثلا پردازش گرافیکی کند یا بیت کوین استخراج کند!) مناسب است، چرا که آن کار ارزش دارد که برایش زمان بگذاریم و یک سختافزار بسازیم که دقیقا همان یک کاری که ما میخواهیم را انجام دهد و نه هیچ کار دیگر. (البته جزئیات زیادتری در این زمینه وجود دارد که من مسلط نیستم.)
اما مساله این است که ما گاهی یک کامپیوتر عام منظوره میخواهیم، یک کامپیوتر که هر محاسبهای داشتیم برای ما انجام دهد، حتی برای کارهایی که از قبل برایشان برنامهریزی نشده. در واقع ما یک کامپیوتر قابل برنامهریزی میخواهیم.
کامپیوتری که با آن این متن را میخوانید یک کامپیوتر قابل برنامهنویسی است، یکسری مهندس سخت افزار آن را طراحی کردهاند، سپس یکسری برنامهنویس برنامههای مورد نیاز را نوشتهاند و نهایتا برنامهها روی سیستم شما اجرا میشود و متن من را میخوانید.
برای اینکه یک کامپیوتر قابل برنامهریزی بسازیم دو راه وجود دارد. (من فقط دو راه بلدم حقیقتش!)
راه اول اینکه یک سخت افزار داشته باشیم (مثلا یک FPGA) که هر موقع ما خواستیم به شکل سختافزارهای دیگر در بیاید. مثلا وقتی میخواهیم جمع انجام دهیم به شکل جمعکننده در بیاید، وقتی میخواهیم ضرب کنیم به شکل ضرب کننده در بیاید، وقتی میخواهیم تلگرام را باز کنیم به شکل پردازندهی مخصوص تلگرام در بیاید! این روش اگرچه هنوز چندان فراگیر نشده اما وجود دارد. توجه داریم که برنامهریزی سختافزار که به شکل برنامه مورد نظر ما عمل کند اندکی زمانبر است، همچنین باید سختافزار به اندازهی کل برنامه مقصد مثلا تلگرام، المان پردازشی داشته باشد. (اگر متوجه نشدید مشکلی نیست.)
کار دیگری که میتوانیم انجام دهیم این است که یک کامپیوتر با سختافزار ثابت و غیرقابل تغییر اما کاربردی داشته باشیم مثلا همان ALUمان یکسری عملیات پایه که برای همه کاربردی است مثل ۴ عمل اصلی و and و or و ... داشته باشد.
حالا هر محاسبهای که داریم را بشکنیم به تعداد جمع و ضرب و .. تا توسط این ALU قابل انجام باشد. مثلا محاسبهی توان را تبدیل کنیم به چند عمل ضرب یا ...
کامپیوترهای امروزی ما از روش دوم استفاده میکنند، یعنی یک سختافزار (یک پردازنده) معین داریم که چند کار اصلی را میتواند انجام دهد. حالا برنامهنویسها برای این کامپیوتر برنامه مینویسند که عملیاتهای مدنظر را به کمک تواناییهای محدودش انجام دهد.
اینکه یک کامپیوتر دقیقا باید چه کارهایی را بتواند کند (مثلا همین جمع و ضرب) تا بتوان با آن هر محاسباتی را انجام داد، به بحثهای «تئوری محاسبات» برمیگردد که بحث جذابی است ولی بیشتر از مهندسی، جنبهی علمی دارد.
وقتی از ALU استفاده کردیم، فقط یکبار ورودی به آن دادیم و یک خروجی گرفتیم، اما وقتی قرار است برنامهنویس برای ما برنامهای بنویسد که چندین عملیات دارد و کامپیوتر آنها را اجرا کند تا نتیجهی دلخواه ما به دست بیاید، چند سوال پیش میآید:
به این سوالها جواب خواهیم داد اما نیاز است قبلش چند مفهوم را با هم مرور کنیم.
در نوجوانی وقتی میپرسیدم پردازنده چطوری کار میکند، میگفتند پردازنده از ALU و CU تشکیل میشود، ALU واحد محاسبات است و CU واحد کنترل، یعنی CU کنترل میکند که ALU چه کار انجام دهد. این توضیح اگرچه صحیح است اما به نظرم کمککننده نیست!
اگرچه ALU نیاز به کنترل شدن دارد تا متوجه شود کدام عملیات را باید انجام دهد (opcode) اما این تمام ماجرا نیست، خود اینکه چه دادهای باید وارد ALU شود و نتیجهش کجا برود هم باید کنترل شوند. پس سوال جدید این است که اصلا دادههایی که قرار است با آنها کار شود از کجا میآیند؟
از این شکل نترسید، اگرچه این شکل واقعا سادهشدهای از یک پردازندهی ساده است، اما هنوز هم ترسناک است اما نترسید، بلکه با صبر به یادگیری ادامه دهید!
به خطهای سبز، مسیر داده (Datapath) میگویند، یعنی دادهی ما این مسیر را طی میکند و در پردازنده میچرخد. مثلا با ALU آشنا هستیم، یک جفت داده میگیرد و یک نتیجه میدهد که همگی از جنس داده هستند.
اما ALU علاوه بر دادهها نیاز به یک opcode هم داشت، یعنی غیر از داده باید به او بگوییم چه عملیاتی انتظار داریم انجام دهد، وظیفهی واحد کنترل (Control Unit یا همین CU) است که به ALU بگوید باید چه عملیاتی انجام دهد، یعنی اصطلاحا آن را کنترل کند.
در سمت چپ تصویر یک یا دو مموری را میبینیم: Instruction Memory و Data Memory که ممکن است واقعا یک حافظه باشند یا دو حافظه. به طور خلاصه منظور این است که یک حافظه داریم که داده در آن قرار میگیرد و یک حافظه داریم که دستورات در آن قرار میگیرند.
در وسط تصویر هم رجیسترفایل را داریم که یک فایل نیست! بلکه آن هم یک حافظه است که تعداد رجیستر دارد (مثلا ۱۶ تا) هر رجیستر میتواند یک واحد داده را نگه دارد. (واحد داده یا word size منظور اندازهی معمول دادهها در پردازنده است مثلا ۶۴ بیت یا ۳۲ بیت، این اندازه در تعداد بیتهای ورودی و خروجی ALU و خیلی جاهای دیگر هم تاثیر دارد.)
رجیسترها حافظههای بسیار پرسرعت اما کم حجمی هستند که وظیفهی نگهداری مقدارهای میانی محاسبات را برعهده دارند.
خطوط آبی خطهایی هستند که اجزای مختلف پردازنده را کنترل میکنند. مثال خوبش همان opcode است. اما خود واحد کنترل از کجا میداند که چطوری باید پردازنده را کنترل کند؟ واحد کنترل از روی دستوری که به پردازنده میدهیم متوجه میشود که دقیقا چه عملیاتی باید انجام شود. نمونهی یک دستور برای پردازنده این است:
add R0, R1, R2
این دستور با دستورهای رایج در زبانهای برنامهنویسی فرق دارد، در واقع این دستور اسمبلی است. (جلوتر بیشتر آشنا میشویم.)
بر اساس این دستور که اینجا add است، واحد کنترل متوجه میشود که باید opcode مربوط به ALU را چطور تنظیم کند. اما همچنین میفهمد که مبدا و مقصد داده کجاست. R0 تا R15 رجیسترهای داخل رجیسترفایل هستند، از روی این دستور متوجه میشویم که باید دو مقدار R1 و R2 خوانده شوند (ورودیها) و نتیجهی جمعشان در رجیستر R0 نوشته شود. این اطلاعات باعث میشود که control unit به register file دستور دهد که دو داده از دو رجیستر گفته شده را لود کند و سپس نتیجهای که به آن داده میشود را در کجا ذخیره کند.
یک حافظه سریع است که تعداد بسیار کمی خانه دارد، هر خانه یک رجیستر نام دارد و مثلا از R0 تا R15 نامگذاری شدهاند. البته نامگذاریهای دیگر مثل ax, bx, cx, dx هم ممکن است و بستگی به معماری پردازنده دارد.
هدف این حافظه نگهداری مقدارهای میانی عملیاتها در پردازنده است، بنابراین برخلاف حافظههای دیگر داخل خود پردازنده تعبیه شده و پردازنده برای هر دستور به راحتی میتواند به آن دسترسی داشته باشد و از آن بخواند و در آن بنویسد.
حافظهی داده یا Data memory حافظهای است که دادههای مورد نیاز برای کارهای کاربر در آن قرار دارد. این دادهها برای اینکه توسط پردازنده مورد استفاده قرار بگیرند باید یکبار از حافظه داخل یک رجیستر از رجیسترفایل لود شوند و در مرحلهی بعدی مورد استفاده قرار بگیرند، بنابراین گاهی از اوقات پردازنده مجبور است به حافظه دستور دهد که یک داده را از حافظه لود میکند.
این حافظه از رجیستر و از خود پردازنده کندتر است بنابراین وقتی پردازنده گذرش به این حافظه میافتد باید چند مدتی معطل شود. برای این منظور برنامهنویسان سعی میکنند کمترین نیاز به این حافظه در برنامه وجود داشته باشد اما همیشه این امر ممکن نیست.
پس از انجام محاسبات هم باید نتیجهی محاسبات داخل حافظهی داده نوشته شود.
هر خانه از حافظه، بر خلاف رجیسترفایل که یک اسم داشت، یک آدرس دارد. آدرس هر خانه از حافظه یک عدد ۳۲ بیتی یا ۶۴ بیتی است. این عدد همان چیزی است که «پردازنده جدید من ۶۴ بیتی است» را مشخص میکند. امروزه اکثر کامپیوترهای خانگی آدرسهای ۶۴ بیتی دارند چرا که یک سیستم ۳۲ بیتی، با آدرس ۳۲ بیتی فقط ۲ به توان ۳۲ خانهی مختلف یعنی ۴ میلیارد آدرس مختلف را میتواند آدرس دهد: یعنی ۴ گیگابایت.
دستوراتی که برنامهنویس مینویسد، باید به نحوی به دست پردازنده برسد تا بتواند آنها را اجرا کند، حافظهی دستورات برای همین کار است. دستورات داخل حافظه به ترتیب اجرا میشوند، برای اینکه کامپیوتر بداند در لحظه باید کدام دستور را اجرا کند، یک شمارنده دارد (Program counter) که بعد از اجرای یک دستور مقدارش یکی زیاد میشود، یعنی به دستور بعدی میرویم. همچنین در مواقعی مثل اجرای یک حلقه ممکن است Program counter چند واحد به جلو یا عقب هم برود.
حافظهی اصلی یا مموری یا حافظهی موقت یا Random Access Memory یا RAM همگی به یک چیز اشاره دارند، جایی که دادههایی که در حال کار با آنها هستیم را در آن بریزیم.
عبارت رندوم-اکسس که قسمت مهمی از اسم است، به فارسی ترجمه میشود:«دسترسی تصادفی». اما دسترسی تصادفی یعنی چی؟ مگر در کامپیوتر کارها شانسی انجام میشود؟ در اینجا تصادفی به معنی شانسی نیست، بلکه به این معنی است که هر زمان بخواهیم میتوانیم از قسمت از داده در حافظه را بخوانیم. دسترسی غیر تصادفی مثلا یک نوار کاست که فقط به داده در جایی که هستیم دسترسی داریم.
در کامپیوترهای امروز حافظهی داده و دستور هردو در یک حافظه بیرون از پردازنده قرار دارند. این حافظه از حافظه کندتر است، یعنی مثلا پردازنده ممکن است ۱۰۰ عملیات انجام دهد اما حافظه فقط یک عملیات خواندن انجام داده باشد.
البته همیشه دقیقا یک نیست، مثلا گاهی برای افزایش کارایی دو مموری هماندازه و همفرکانس استفاده میکنند تا در آن واحد خواندن/نوشتن دو آدرس ممکن باشد.
تصویر بالا یک مموری را به تنهایی نشان میدهد که از قسمت رسانا داخل مادربورد قرار میگیرد.
تصویر پایین دو ماژول رم که داخل شیار مخصوص روی مادربورد قرار گرفتهاند را نشان میدهد.
حالا میتوانیم به راحتی پاسخ سوالات قبلیمان را بدهیم:
لیست دستورات را باید به شکلی خاص در حافظهی دستورات بنویسیم. همچنین باید program counter که داخل خود پردازنده است) به اولین دستور اشاره کند یعنی آدرس آن را داشته باشد. سپس پردازنده دستور به دستور اجرا میکند و قبل از اجرای هر دستور نیز مقدار program counter را یکی زیاد میکند.
اینکه چطور برنامهای قابل فهم برای پردازنده بنویسیم را جلوتر بررسی میکنیم.
پردازنده دستوری که در نقطهی program counter وجود دارد را از حافظهی دستورات میخواند (fetch)، سپس آن را بررسی میکند تا بداند کدام سیگنالهای کنترلی را باید مقداردهی کند (decode)، سپس در مرحلهی بعد واقعا دستور را اجرا میکند (execute). در همین زمان program counter هم یکی (در واقع ۴ تا، بعدا میخوانیم چرا) زیاد میشود. (بیشتر بخوانید.)
در سایکل بعدی، یعنی چند نانوثانیه بعد، پردازنده دستور واقع در program counter بعدی را اجرا میکند.
گاهی اوقات نیز program counter به جای دستور بعدی چند تا جلو یا عقب میپرد که باز هم تفاوتی نمیکند.
نتیجه هر محاسبه باید در یک رجیستر از رجیسترفایل قرار بگیرد، اما برنامهنویس میتواند بعد از خود محاسبه، نتیجه را در حافظه اصلی نیز نگهداری کند. معمولا روال به این صورت است که تا جای ممکن نتیجه محاسبات میانی در رجیسترفایل حفظ میشود، اگر دادهای برای کاربر مهم است یا نتیجه نهایی است یا تعداد رجیسترها جوابگوی کار نیست، داده در حافظهی اصلی نوشته میشود و از آن رجیستر استفاده دیگری میشود.
شاید بگویید تصویر ترسناکی که بالاتر از نحوه کار پردازنده دیدم، معماری پردازنده حساب میشود اما در کمال تعجب نه! آن تصویر ریزمعماری پردازنده است، ریزمعماری چیزی است که برای هر پردازنده مثلا intel core I7 6700K مهندسان طراحی میکنند، ریزمعماری سرعت پردازنده و مصرف انرژی و غیره را مشخص میکند. درواقع به ریزمعماری میتوانیم بگوییم «پیادهسازی»
اما پیادهسازی چی؟ چه قواعد و قوانین یا استانداردی را پیاده سازی میکنند؟ امروزه ساختن پردازنده اینطوری نیست که یک تیم بیاید برای خودش با هر قاعدهای که خواست یک قطعه تولید کند که پردازش کند، شاید اوایل دوره کامپیوتر بود، اما الان پردازنده باید طبق یکسری اصول کار کند، به این اصول معماری پردازنده میگویند. هدف ریزمعماری هم پیادهسازی سختافزاری آن معماری است، مشخص است که با تغییر معماری باید ریزمعماری متفاوتی را نیز طراحی کنیم.
معماری پردازنده در واقع دید استفادهکننده از پردازنده است، یعنی کاربر و برنامهنویس که میخواهند با پردازنده کار کنند مثلا برایش برنامه بنویسند، باید از پردازنده اطلاعات داشته باشند، مثلا اینکه چه رجیسترهایی دارد و کلا چند رجیستر دارد، چه عملیاتهایی را پشتیبانی میکند و ... اما ریزمعماری که جزئیات پیادهسازی است معمولا توسط شرکتهای بزرگ به صورت عمومی منتشر نمیشود و به نوعی اسرار شرکت است.
همچنین یکی از مهمترین چیزهایی که در معماری مشخص میشود، دستورات پشتیبانی شدهی آن پردازنده است. مثلا کد add R0, R1, R2 که در بالا دیدیم یک کد استاندارد در ARM است.
از معماریهای معروف میتوان به MIPS و ARM و x86 اشاره کرد.
همانطور که گفته شد هر معماری، علاوه بر مشخصات پردازنده، زبان آن پردازنده که همان دستورات مورد پشتیبانی است را نیز مشخص میکند. به این دستورات زبان ماشین میگویند. (مطالعه بیشتر)
دستورات میتوانند به دو شکل نمایش داده شوند، نمایش اول که تا کنون با آن آشنا شدیم، شکل اسمبلی است، به تصویر زیر نگاه کنید.
در تصویر بالا، کلمات انگلیسی به چشم میخورد که با اعداد (به شکل #1 و #2) و رجیسترها (به شکل R4 و R5) کار کرده است. هرچند دنبال کردن این کد سخت است و نیاز به کامنت دارد اما نمایش نوشتاری دارد و بیشتر برای کاربر و برنامهنویس مناسب است تا کامپیوتر.
در مقابلِ اسمبلی یک نگاه دیگر به دستورات هم داریم که به آن زبان ماشین میگویند. زبان ماشین ترجمهی نعل به نعل اسمبلی است برای اینکه توسط کامپیوتر قابل فهم باشد. این تبدیل به کمک اسمبلرها انجام میشود.
پس اسمبلر کد به زبان اسمبلی را میگیرد و کد زبان ماشین تولید میکند. اسمبلر برخلاف کامپایلر کار پیچیدهای نمیکند چرا که کامپایلر برنامهی سطح بالا مثل سی پلاس پلاس یا گولنگ را میگیرد و تبدیل به کد ماشین میکند اما اسمبلر همان برنامه زبان ماشین که نمایش اسمبلی دارد را میگیرد و قرار نیست کار محاسباتی خاصی انجام دهد.
اسمبلر برای تبدیل اسمبلی به زبان ماشین، از اطلاعاتی که داخل معماری آمده استفاده میکند. در معماری دقیقا مشخص است که این دستور اسمبلی به چه ترکیبی از صفر و یک تبدیل میشود.
قبلتر که اسمبلرها وجود نداشتند، برنامهنویسها مجبور بودند خودشان دست به کار شوند و با زبان ماشین برنامه بنویسند، یعنی باید معماری را با دقت بررسی میکردند. برای وارد کردن صفر و یکها به کامپیوتر از کارت پانچ استفاده میشد!
با فرض اینکه اسمبلر و کامپایلر وجود نداشته باشد، باید برنامهنویس رشتهای از صفر و یک بنویسد و به عنوان برنامه به کامپیوتر تحویل دهد. کامپیوتر بر اساس معماری مشخصش، این رشته را تحلیل میکند و کارهای لازم را انجام میدهد. از آنجا که نوشتن برنامه به این روش بسیار سخت است و برنامههای طولانی هم مشکلزا هستند، برنامهنویسان ترجیح میدادند پردازنده دستورات پیچیدهای را پشتیبانی کند و با یک دستور بتوانند بخش زیادی از کار را جلو ببرند.
سپس اسمبلرها به وجود آمد، حالا برنامهنویسها برنامه را به شکل متنی و با زبان اسمبلی مینوشتند و سپس اسمبلر را اجرا میکردند تا کد زبان ماشین را تولید کند. کد زبان ماشین را به عنوان برنامه به کاربر میدادند و کاربر آن را اجرا میکرد.
اما اسمبلی هم چند اشکال داشت، اشکال اول اینکه کدهای ساده هم در آن پیچیده بودند و برنامهنویسی بازهم سخت بود، اشکال دوم اینکه اگر میخواستیم از یک معماری به دیگری برویم باید برنامه را از اول مینوشتیم! برای همین زبان های برنامهنویسی مثل fortran و Cobol به وجود آمدند. این زبانها از اسمبلی سطح بالاتر بودند و به کمک کامپایلر به زبان ماشین کامپایل میشدند. کامپایلر کارش این است که کد به زبان برنامهنویسی (امروزه مثل سی پلاس پلاس و گولنگ و راسط و ...) را بگیرد و کد زبان ماشین تولید کند.
معماریهای پردازندهها به دو دسته RISC و CISC تقسیم میشوند.
به شکل ساده RISC معماری کاهشیافته است، دستورات هر کدام کار کمی انجام میدهند و برنامهنویس مجبور است برای یک کار ساده چندین دستور بنویسد. در عوض پردازنده کوچک میشود
در مقابل CISC معماری پیچیدهتر با دستورات غنی است، یعنی یک دستور به ظاهر ساده ممکن است مقدار زیادی کار انجام دهد. اگر پردازنده CISC باشد برنامهنویس میتواند با تعداد دستورات کمتر کار بیشتری انجام دهد و به نوعی پردازنده با داشتن دستورهای مختلف جای خالی کامپایلر را تا حدی پر میکند و کار برنامهنویس را کمی آسان میکند.
همانطور که متوجه شدهاید در ابتدا کامپیوترها را CISC میساختند تا کار برنامهنویسها راحتتر باشد، معماری x86 یادگار همان دوره است.
اما با گذشت زمان، دیگر کسی به صورت مستقیم اسمبلی یا زبان ماشین نمینوشت (یا خیلی به ندرت این اتفاق میافتاد) بنابراین دلیلی نداشت که پردازندهها دستورات ترکیبی و پیچیده را پشتیبانی کنند، در مقابل تمرکز بر مصرف انرژی پایینتر و هزینه پایینتر ساخت اهمیت یافت چرا که کامپیوترها همهگیر شده بودند، پس معماری RISC رواج یافت. معماری ARM نیز شاخصترین معماری از دستهی RISC است.
استفاده از یک معماری سادهتر مزیتهای زیادی دارد. در RISC لزوما تعداد دستورات کمتری وجود ندارد اما دستوراتی که وجود دارند پیچیدگی کمی دارند و انجام این دستورها برای پردازنده سخت نیست.
با توجه به نبودن دستورات پیچیده، پیادهسازی کلی پردازنده راحتتر میشود و با تعداد ترانزیستورهای کمتر میتوان دستورات موجود در RISC را پشتیبانی کرد.
تعداد ترانزیستور کمتر به معنی مصرف انرژی کمتر و مساحت کمتر چیپ و دمای کمتر است برای همین استفاده از RISC مثلا arm در موبایلها و تبلتها اولویت دارد. حتی اخیرا اپل با آوردن پردازنده M1 به لپتاپهایش عمر باتریهایش را زیاد کرده. (امیدوارم وقتی این مطلب را میخوانید ARM فراگیرتر شده باشد.)
اینجا به جای مزایای CISC در مورد مزایای x86 مینویسم چرا که به نظرم معماری CISC قالب x86 است.
کلا در RISC دستورات توانمندی داریم، اگرچه پیادهسازی این دستورات از دید پردازنده سخت است اما به کمک این دستورات توانمند برنامهنویس می تواند آنچه در ذهنش وجود دارد را راحتتر به زبان ماشین منتقل کند.
مزیت مهم x86 ارزان بودن آن است. درست است که طراحی و پیادهسازی یک پردازنده RISC آسانتر است، اما به دلیل سالها فراگیر بودن x86 (حدود ۴۰ سال!) هزینههای ساختش کاهش یافته.
امروزه کسی به CISC احتیاجی ندارد چون کامپایلرها قرار است برنامهی زبان ماشین تولید کنند و آنها برایشان آنقدر سخت نیست!
اما هنوز پردازندههای کامپیوترهای شخصی با معماری x86 ساخته میشوند. این امر دلایل متفاوتی دارد مثلا اینکه شرکتهای بزرگ سالها روی بهتر کردن پردازندههای x86 شان زمان صرف کردهاند. اگرچه در تئوری با معماری arm هم میتوان به همان کارایی و حتی بهتر رسید. در مورد قیمت ارزان هم صحبت شد.
در طولانی مدت رفتن به سمت arm برای مصرفکنندهها بهتر است چون میتوانیم کامپیوترها و لپتاپهای خنکتر با مصرف انرژی/باتری کمتری داشته باشیم. اما اگر قرار باشد یکشبه همه به arm مجهز شویم چند مشکل پیش میآید:
جدا از کامپیوترهای شخصی، انتخاب ARM در لحظهی شروع برای گوشیهای هوشمند انتخاب بسیار خوبی بود و باعث شد بتوانیم ساعتها از گوشی و تبلتمان روی باتری و بدون نیاز به فن استفاده کنیم.
در بررسی ساختار کامپیوتر، از پردازنده و مموری صحبت کردم، اما یک جزئیاتی که به آن اشاره نشد کَش است. کَش یک حافظهی سریع داخل پردازندهاست اما حجم کمتری دارد و رجیسترها سریع نیست. این حافظه در واقع بین پردازنده و مموری قرار میگیرد. کار کش افزایش سرعت دسترسی به حافظهی اصلی است. چرا که بدون وجود کش اگر بخواهیم زیاد از حافظه استفاده کنیم اجرای برنامه بسیار کند میشود اما پردازنده خانههای زیاد استفادهشدهی حافظه را در کش قرار میدهد و به این شکل سرعت کار بسیار بالا میرود.
کش بر خلاف رجیسترها و حافظهی اصلی تحت کنترل برنامهنویس نیست، بلکه خود پردازنده با هوشمندی(!) آن را مدیریت میکند. بنابراین ممکن است اصلا دو پردازنده با یک معماری مشخص یکیشان کش داشته باشد و یکی نداشته باشد. البته که کاراییشان بسیار متفاوت میشود اما به لحاظ برنامهنویسی تفاوتی ایجاد نمیشود.
تا اینجا گفتیم که پردازنده اطلاعات را از مموری بخواند و نتیجه را نیز در مموری بنویسد اما چه فایدهای دارد؟ کاربر باید چطوری این اطلاعات را ببیند؟
پاسخ این سوالات خارج از خود پردازنده است و در قسمت دیگری بررسی خواهیم کرد.
توجه: در بسیاری از جاها سادهسازی انجام شد، این سادهسازی عمدی است و به دو دلیل است: ساده شدن مطلب و ناآشنایی خودم. در صورتی که فکر میکنید قسمتی را میشد کاملتر توضیح داد میتوانید کامنت بگذارید تا به انتهای مطلب اضافه کنم.