محاضرة 1 — قريباً
أي processor يحتاج "لغة" يفهمها — هاي اللغة تُسمى Instruction Set Architecture (ISA). هي تحدد ثلاثة أشياء: الأوامر المتاحة، الـ Registers (المخازن المؤقتة)، وكيف يتعامل المعالج مع الذاكرة.
شركات مثل Intel وARM تمتلك ISAs خاصة بها وتطلب رسوم ترخيص لاستخدامها. RISC-V مختلف تماماً — هو معيار مفتوح المصدر مجاني 100%، أي شركة أو فرد يقدر يبني عليه معالجه الخاص بدون دفع أي رسوم. هذا ما يجعله مهماً بشكل متزايد في الصناعة والبحث العلمي.
مثال عملي: في هذا الكورس نستخدم لوحة MD307 التي تحتوي على microcontroller اسمه CH32V307 — وهو يعمل على RISC-V. كذلك Raspberry Pi Pico 2 يستخدم RISC-V. لأن كلاهم يستخدم نفس الـ ISA، يمكنهم تشغيل نفس الكود في بعض الحالات!
RISC-V مصمم ليكون مرناً — يستخدم في أجهزة صغيرة جداً وفي مراكز البيانات الضخمة. لذلك هو مقسم إلى Base Instruction Set يحتوي 47 أمر فقط، وعدد كبير من الـ Extensions الاختيارية.
ريموت التلفزيون يحتاج RV32I فقط (32-bit base). الآلة الحاسبة تحتاج RV64I إضافة إلى F وD للـ floating point. الساعة الذكية تحتاج C (Compressed instructions) لتوفير الطاقة. أما المعالج في الـ lab (CH32V307) فيدعم RV32I + M + A + F + C + Zicsr + Zifencei — لكن في هذا الكورس نركز فقط على RV32I + M + Zicsr.
الـ ISA يحدد ما يقدر الـ Hardware يفعله — "المعالج لازم يدعم 32 register". هذا مثل قانون البناء الذي يحدد كيف تُبنى المباني.
الـ ABI (Application Binary Interface) يحدد كيف يتواصل الكود مع بعضه على مستوى الـ Software — "لما دالة تُرجع نتيجة، حطها في register x10". هذا مثل قواعد السير الداخلية في المبنى. كلاهم ضروريان: ISA يضمن إن المعالج يفهم الأوامر، ABI يضمن إن برامج مختلفة تتعاون صح.
أي معالج يدعم RV32I لازم يدعم 33 register: 32 general-purpose register (x0-x31) وواحد للـ Program Counter (pc). كل register عبارة عن 32-bit.
أهم register هو x0 (zero) — هذا register خاص مُقيَّد بالـ hardware ليكون صفراً دائماً. حتى لو حاولت تكتب فيه يُهمَل. هذا يبسّط الـ Hardware كثيراً ويتيح تقليص عدد الأوامر.
الـ ABI يعطي كل register اسماً يصف وظيفته: t0-t6 للبيانات المؤقتة، a0-a7 للـ function arguments والنتائج، s0-s11 للقيم المحفوظة، sp للـ Stack Pointer، ra لعنوان العودة. يمكنك استخدام الاسم الرقمي (x5) أو اسم الـ ABI (t0) — كلاهم يشير لنفس الـ register.
| Register | ABI Name | الاستخدام |
|---|---|---|
| x0 | zero | دايماً = 0 (hardwired!) |
| x1 | ra | Return Address |
| x2 | sp | Stack Pointer |
| x5-x7 | t0-t2 | Temporary — مش محفوظ بعد الدالة |
| x10-x17 | a0-a7 | Arguments / Return Value ⭐ |
| x18-x27 | s2-s11 | Saved Registers — محفوظة بعد الدالة |
| pc | pc | Program Counter — عنوان الأمر الجاي |
RISC تعني "Reduced Instruction Set Computer". الفلسفة بسيطة: أوامر قليلة وبسيطة أفضل من أوامر كثيرة ومعقدة. كل أمر بسيط يمكن تنفيذه في clock cycle واحد، مما يسهّل الـ Pipelining ويجعل الـ Hardware أبسط وأسرع وأرخص.
Assembly Language هي أقرب لغة للمبرمج من الـ Machine Code. تحتوي على كل الأوامر الحقيقية، وأيضاً Pseudo Instructions وهي اختصارات يترجمها الـ Assembler تلقائياً لأوامر حقيقية. مثلاً mv x1, x2 تتحول لـ add x1, x2, x0 لأن RISC-V ما عنده أمر mv حقيقي — لكن جمع أي قيمة مع الصفر يعطي نفس النتيجة.
في RISC-V، الأوامر الحسابية تعمل على Registers فقط — لا تستطيع أن تجمع رقماً من الذاكرة مع register مباشرة. إذا أردت الحساب على بيانات في الذاكرة، لازم تمر بثلاث خطوات: أولاً Load (جيب البيانات من الذاكرة للـ register)، ثانياً احسب، ثالثاً Store (احفظ النتيجة من الـ register للذاكرة).
هذا يبدو أطول من Intel x86 الذي يسمح بـ add r0, M(r1) مباشرة، لكن البساطة تجعل الـ Hardware أسرع وأقل أخطاءً وأسهل في الـ Pipeline.
| الأمر | الحجم | النوع | الوصف |
|---|---|---|---|
| lb rd, offset(rs1) | 8-bit | signed | sign extend → 32-bit |
| lh rd, offset(rs1) | 16-bit | signed | sign extend → 32-bit |
| lw rd, offset(rs1) | 32-bit | — | كامل |
| lbu rd, offset(rs1) | 8-bit | unsigned | zero extend → 32-bit |
| lhu rd, offset(rs1) | 16-bit | unsigned | zero extend → 32-bit |
| sb rs2, offset(rs1) | 8-bit | — | يكتب أدنى byte |
| sh rs2, offset(rs1) | 16-bit | — | يكتب أدنى halfword |
| sw rs2, offset(rs1) | 32-bit | — | يكتب الكلمة كاملة |
لنفترض عندنا متغيرين: unsigned char a = 251 و signed char b = -5. في الذاكرة، كلاهم يُخزَّن كـ 0xFB — نفس الـ bits تماماً! إذاً كيف يعرف المعالج الفرق؟
الجواب هو في أمر الـ Load نفسه. عندما تستخدم lbu (Load Byte Unsigned)، المعالج يفهم الرقم كـ unsigned ويحشي الـ 24 bit المتبقية بأصفار → نتيجة: 0x000000FB = 251. أما lb (Load Byte Signed)، يشوف إن الـ MSB = 1 (يعني سالب في Two's Complement) ويحشي الـ 24 bit بـ 1 → نتيجة: 0xFFFFFFFB = -5.
القاعدة: الـ MSB يحدد الإشارة في Two's Complement. Sign Extension يكرر الـ MSB في كل الـ bits الأعلى عشان يحافظ على نفس القيمة عند التكبير من 8-bit لـ 32-bit. هذا فهم وليس حفظ — لو شفت MSB=1 في رقم signed تعرف فوراً إنه سالب.
عندما يعمل المعالج يمر بدورة ثابتة من 4 مراحل لكل أمر. أولاً Fetch: يجيب الأمر من العنوان الموجود في PC ثم يزيد PC بـ 4 (لأن كل أمر = 4 bytes). ثانياً Decode: يفهم الأمر — هل هو add؟ load؟ branch؟ ثالثاً Execute: ينفذ العملية الفعلية. رابعاً Writeback: يحفظ النتيجة في register أو ذاكرة.
بالنظرية = 4 cycles لكل أمر. بالواقع = ~1 cycle لكل أمر بسبب Pipelining (خارج نطاق الكورس). المهم أن تفهم إن PC يزيد بـ 4 بعد كل أمر ويشير دائماً للأمر القادم.
أمر addi عنده 12-bit فقط للرقم الثابت (immediate)، يعني أكبر قيمة = 2047. لو أردنا تحميل رقم أكبر مثل 1,000,000 ماذا يحدث؟
الـ Assembler يقسمه تلقائياً لأمرين: lui (Load Upper Immediate) يضع الـ 20 bits العليا في الـ register، ثم addi يضيف الـ 12 bits السفلى. 1,000,000 بالـ hex = 0xF4240، فيصير: lui t0, 0xF4 (يضع 0x000F4000) ثم addi t0, t0, 0x240 (يضيف 576) = 0x000F4240 = 1,000,000.
بدل استخدام عناوين مطلقة، نستخدم labels لتعريف المتغيرات. في قسم .data نعرّف المتغيرات، وفي قسم .text نكتب الكود. للوصول لمتغير نستخدم la (Load Address) لنجيب عنوانه، ثم lb/lw لنقرأ قيمته.
لأن الذاكرة تعمل بكلمات (words) من 32-bit، هناك قيود على عناوين الوصول. إذا أردت قراءة word كاملة (4 bytes) من عنوان لا يقبل القسمة على 4، المعالج سيحتاج قراءتين منفصلتين مما يعقّد الـ Hardware — لذلك معظم المعماريات ومنها RISC-V تمنع ذلك وتعطي Exception.
القاعدة: lw/sw يحتاج عنوان يقبل القسمة على 4، lh/sh يحتاج قسمة على 2، lb/sb بدون قيود. مشكلة شائعة: تعرّف byte ثم word مباشرة بعدها — الـ byte يأخذ عنواناً واحداً، فالـ word ستبدأ من عنوان لا يقبل القسمة على 4 → كراش!
الحل: استخدم .align 2 قبل أي word. هذا يجبر الـ Assembler على إدراج bytes فاضية عشان العنوان التالي يكون قابلاً للقسمة على 2² = 4.
في RISC-V، عندنا أمرين فقط للقفز غير الشرطي: jal و jalr. كلمة "Link" في اسمهم تعني إنهم يحفظون عنوان العودة — يعني العنوان الجاي بعد الأمر الحالي — عشان نقدر نرجع بعد ما نخلص من القفز. هاد مهم جداً لما نستدعي دوال.
الفرق بين jal و jalr هو إن jal تقفز بناءً على offset محسوب من مكانك الحالي (PC)، بينما jalr تقفز لعنوان موجود في register. jal تقدر تقفز +/- 1MB فقط (لأن الـ offset 20-bit)، لكن jalr تقدر تقفز لأي عنوان في الذاكرة — مفيد لو الكود في مكان بعيد مثل FLASH memory.
| الأمر | المعنى | النتيجة |
|---|---|---|
| jal rd, offset | Jump and Link | rd = PC+4 ثم PC += offset |
| jalr rd, offset(rs1) | Jump and Link Register | rd = PC+4 ثم PC = rs1+offset |
المبرمج نادراً ما يستخدم jal و jalr مباشرة — بدلاً من ذلك نستخدم Pseudo Instructions أوضح وأسهل للقراءة. كلها تتحول تلقائياً لـ jal أو jalr:
j label (Jump) يتحول لـ jal zero, offset. لاحظ إن العنوان يُكتب في zero — يعني يُهمَل — لأننا ما نحتاج نرجع. نستخدمه للـ loops الأبدية أو القفزات البسيطة.
call label يتحول لـ jal ra, offset. هنا العنوان يُحفظ في ra (register x1) عشان نقدر نرجع بـ ret.
jr rs (Jump Register) يتحول لـ jalr zero, 0(rs). مثل j بس العنوان في register — نستخدمه لو العنوان بعيد أكثر من 1MB.
ret (Return) يتحول لـ jalr zero, 0(ra). يقفز للعنوان المحفوظ في ra — هكذا نرجع من الدالة.
li t0, 0x00000100 ثم jr t0Branching يعني القفز بناءً على شرط معين. على عكس بعض المعماريات القديمة التي تستخدم "flag register" منفصل، RISC-V يجعل الأمر أبسط: كل أمر branch يقارن مباشرة بين register-ين ويقرر القفز أم لا — هاد يبسّط الـ Hardware ويقلل مشاكل الـ Pipeline.
عندنا 6 أوامر حقيقية فقط: beq, bne, blt, bge وإصداراتهم unsigned (bltu, bgeu). ربما تتساءل: ليش ما في bgt أو ble؟ الجواب بسيط — لأن (a > b) = (b < a). بمجرد عكس ترتيب الـ operands نحصل على نفس النتيجة. لهذا bgt هو Pseudo Instruction يتحول لـ blt مع عكس الترتيب.
| الأمر الحقيقي | الشرط |
|---|---|
| beq rs1, rs2, label | rs1 = rs2 |
| bne rs1, rs2, label | rs1 ≠ rs2 |
| blt rs1, rs2, label | rs1 < rs2 (signed) |
| bge rs1, rs2, label | rs1 ≥ rs2 (signed) |
هناك أيضاً Pseudo Instructions للمقارنة مع الصفر مثل beqz, bnez, bgtz, bltz — كلها تتحول لأوامر حقيقية باستخدام zero register.
تنبيه مهم: ما في Immediate versions للـ branch! لا تكتب bgt t1, 5, end — ما يشتغل. لازم تحط الرقم في register أولاً: li t2, 5 ثم bgt t1, t2, end.
الـ registers عددها 32 فقط. أحياناً نحتاج نحفظ قيماً مؤقتاً لما تنتهي الـ registers المتاحة. الـ Stack هو منطقة ذاكرة مخصصة لهذا الغرض. sp (register x2) يشير دايماً لرأس الـ Stack.
قاعدة مهمة: الـ Stack ينمو للأسفل — يعني لما نضيف قيمة (PUSH) ننقص sp، ولما نأخذ قيمة (POP) نزيد sp. في RISC-V ما في أوامر PUSH وPOP مدمجة — نبنيها بأنفسنا من sw/lw وaddi.
لماذا لازم ننقص sp أولاً ثم نحفظ؟ لأن sp يشير دايماً لآخر قيمة محفوظة. لو حفظنا أولاً كتبنا فوق قيمة موجودة! إذاً لازم نحجز مكان جديد أولاً بتنقيص sp، ثم نكتب في المكان الجديد.
Assembly ما عندها functions مدمجة — نبنيها بأنفسنا. بس لو كل مبرمج عمل طريقته الخاصة، الكود ما يتوافق مع بعضه ولا مع مكتبات C. لهذا ABI يحدد قواعد موحدة يتبعها الجميع.
القاعدة بسيطة: الـ parameters تُحط في a0-a7 بالترتيب قبل استدعاء الدالة. النتيجة ترجع في a0. عنوان العودة يُحفظ تلقائياً في ra بأمر call. والدالة ترجع بأمر ret.
لو نسينا ret ماذا يحدث؟ البرنامج لا يرجع للمكان الصح — يكمل ينفذ الكود الجاي في الذاكرة بشكل عشوائي مما يؤدي لنتائج غير متوقعة أو كراش.
المشكلة: لما نستدعي دالة، ما نعرف أي registers تغيّرها. لو وضعنا قيمة مهمة في t2 ثم استدعينا دالة تستخدم t2، القيمة ضاعت!
الـ ABI يحل هاي المشكلة بتقسيم الـ registers لنوعين. الأول هو Caller-Saved (t0-t6, a0-a7, ra) — هاي registers الدالة المستدعاة تقدر تغيّرها بحرية. إذا كنت تحتاجها بعد الـ call، أنت مسؤول عن حفظها. الثاني هو Callee-Saved (s0-s11) — هاي registers الدالة ملزمة بحفظها قبل استخدامها وإرجاعها قبل ret. مضمون ما تتغير بعد الـ call.
هاد الضمان مش تقني — هو اتفاقية (convention). أي دالة مكتوبة صح تلتزم بهاد القانون. لو دالة ما تتبع ABI = هي المخطئة وليس كودك.
الحل 1 — Stack: احفظ t2 قبل الـ call في Stack وأعده بعدها.
الحل 2 — s registers (أنظف): انقل القيمة لـ s0 قبل الـ call. الدالة ملزمة بعدم تغيير s0.
لما دالة تستدعي دالة ثانية، call سيكتب فوق ra! هذا يعني إنك لو رجعت بـ ret ستقفز للمكان الغلط. الحل: احفظ ra في الـ Stack في بداية الدالة (Prologue) وأعده قبل ret (Epilogue).
الأسلوب الأنظف لكتابة الدوال: استخدم t registers للحسابات المؤقتة. لو تحتاج قيمة تبقى بعد call استخدم s register. ولو استخدمت s register احفظه في Prologue وأعده في Epilogue.
الـ Array هو قائمة من متغيرات نفس النوع مخزنة بشكل متتالي في الذاكرة. للوصول لعنصر معين نحتاج عنوان البداية وال offset (الفرق بالـ bytes) من ذلك العنوان.
لو الـ array من bytes، الـ offset = رقم العنصر مباشرة. لو من words (4 bytes لكل عنصر)، الـ offset = رقم العنصر × 4. هاد ضروري لأن الذاكرة عبارة عن سلسلة bytes وكل word تأخذ 4 منها.
للتنقل بين عناصر array في loop، نحسب الـ offset بضرب index × حجم العنصر. بدل mul نستخدم slli (Shift Left) لأن الضرب بـ 4 = shift left بـ 2. هاد أسرع لأن shift أبسط للـ Hardware.
لما نرسل array لدالة، ما نرسل العناصر كلها — بدل ذلك نرسل العنوان والحجم. الدالة تستخدم الـ addi على الـ base address للتنقل بين العناصر.
li t0,5 | li t1,3 | li t2,7 | mv a0,t0 | mv a1,t1 | call max | mv a1,t2 | call maxمحاضرة 4 — قريباً
محاضرة 5 — قريباً
محاضرة 6 — قريباً
محاضرة 7 — قريباً
محاضرة 8 — قريباً
محاضرة 9 — قريباً
محاضرة 10 — قريباً
محاضرة 11 — قريباً
محاضرة 12 — قريباً