أساسيات الحاسب والبرمجة الأدلة التقنية

البوابات المنطقية Logic Gates: من الترانزستور إلى الجامع

نبني من بوابة NAND واحدة دائرة تجمع رقمين وأخرى تحفظ الناتج، ونرى في محاكي Python لماذا للمعالج سرعة قصوى ولماذا يحتاج إلى ساعة.

البوابات المنطقية Logic Gates: من الترانزستور إلى الجامع

كل مبرمج يسمع منذ أول درس أن «الحاسب لا يفهم إلا الصفر والواحد»، ثم يكتب total = price + tax ويرى النتيجة الصحيحة دون أن يسأل: كيف تجمع قطعة من السيليكون رقمين؟ فلا يوجد داخل المعالج شخص صغير يعرف جدول الجمع، ولا يوجد برنامج يجمع، لأن البرامج نفسها تحتاج من ينفذها. والإجابة أن الجمع، ومعه المقارنة والتخزين وكل ما يفعله المعالج، مبني من دوائر صغيرة جداً اسمها البوابات المنطقية Logic Gates، وكل بوابة مبنية من بضعة ترانزستورات، وكل ترانزستور مفتاح يفتح ويغلق، وإذا فهمت هذه الطبقات الثلاث فهمت لماذا للمعالج سرعة قصوى، ولماذا يحتاج إلى ساعة Clock، ولماذا يتحول الرقم 7 إلى ‎-8 عندما تضيف إليه 1 في أربعة بتات.

وفي مقال كيف يعمل المعالج CPU من الداخل مررنا على هذه الطبقات في صورة واحدة سريعة، من الترانزستور إلى البوابة إلى الجامع إلى وحدة الحساب، أما هنا فسوف نبنيها خطوة خطوة بأنفسنا: سوف نأخذ عملية جمع واحدة بسيطة، 6 + 7، ونبني الدائرة التي تحسبها من بوابة NAND واحدة مكررة، ثم نبني الدائرة التي تحفظ الناتج بعد أن تختفي المدخلات، ونشغل كل ذلك في محاكي صغير بلغة Python ونرى جداول الحقيقة من مخرجه الحقيقي. وسوف نناقش في هذا المقال ما يلي:

  • لماذا يعمل الحاسب بحالتين فقط، ومن أين جاءت فكرة أن الدوائر الكهربائية تحسب المنطق.
  • الترانزستور Transistor كمفتاح، وكيف تبنى بوابة NAND من أربعة ترانزستورات بتقنية CMOS.
  • البوابات الأساسية وجداول الحقيقة Truth Tables، ولماذا تكفي NAND وحدها لبناء كل شيء.
  • من البوابات إلى الجامع Adder: نصف جامع ثم جامع كامل ثم جامع لأربعة بتات، وماذا يحدث عند الفيضان Overflow.
  • زمن الانتشار Propagation Delay، ولماذا يحدد أطول طريق في الدائرة سرعة المعالج.
  • الدوائر التي تتذكر: القفل SR Latch والقلاب D Flip-Flop والساعة Clock.
  • تجربة عملية بمحاكي بوابات في Python، فيها مشكلة ظهرت أثناء التشغيل، وتجربة «ماذا يحدث إذا» على دائرتين.

لماذا الصفر والواحد؟ النظام الثنائي Binary في الدوائر

قد يتساءل البعض: لماذا لا يستخدم الحاسب عشرة مستويات من الجهد الكهربائي، لكل رقم من 0 إلى 9 مستوى، فيحفظ في السلك الواحد أكثر مما يحفظه بحالتين؟ والإجابة على ذلك أن الكهرباء داخل الجهاز ليست دقيقة أبداً: الجهد يتغير قليلاً مع الحرارة، والأسلاك المتجاورة تؤثر في بعضها، ومزود الطاقة نفسه يتذبذب، فإذا كان الفرق بين الرقم 4 والرقم 5 جزءاً صغيراً من الفولت فأي تشويش بسيط يحول أحدهما إلى الآخر. أما بحالتين فقط فالمسافة بينهما كبيرة: كل جهد قريب من الصفر يعني 0، وكل جهد قريب من جهد التشغيل يعني 1، وما بينهما منطقة لا يسمح للدائرة أن تبقى فيها، فتتحمل الدائرة تشويشاً كبيراً دون أن تخطئ. وهذا الهامش هو الذي يجعل مليارات الترانزستورات تعمل معاً دون أخطاء، وقد شرحنا كيف تمثل الأرقام بهذه البتات في مقال الأنظمة العددية للمبرمجين.

والفكرة التي تربط المنطق بالكهرباء أقدم من الحاسب نفسه: ففي 1854 نشر George Boole كتاب An Investigation of the Laws of Thought الذي عامل فيه العبارات المنطقية كمتغيرات قيمتها صح أو خطأ تجمعها عمليات مثل «و» و«أو» و«ليس»، وهذا ما نسميه اليوم الجبر البولياني Boolean Algebra، وهو نفس النوع bool الذي تستخدمه في كل لغة برمجة. ثم في 1937 كتب Claude Shannon رسالة الماجستير في MIT بعنوان A Symbolic Analysis of Relay and Switching Circuits، وبين فيها أن دوائر المفاتيح الكهربائية، وكانت حينها مرحلات Relays ميكانيكية، يمكن وصفها وتصميمها بجبر Boole نفسه: مفتاحان على التوالي يعملان كعملية «و»، ومفتاحان على التوازي يعملان كعملية «أو». ومن هنا أصبح تصميم دائرة تحسب شيئاً مسألة منطقية تكتبها على الورق قبل أن تلمس سلكاً واحداً.

الترانزستور Transistor: مفتاح بلا أجزاء متحركة

المرحلات الميكانيكية كانت بطيئة وتستهلك، وأنابيب الفراغ التي جاءت بعدها كانت سريعة ولكنها كبيرة وتسخن وتحترق باستمرار، إلى أن اكتشف فريق Bell Labs الترانزستور في 1947، ونال William Shockley وJohn Bardeen وWalter Brattain عليه جائزة نوبل في الفيزياء 1956. والترانزستور الذي يهمنا هنا هو نوع اسمه MOSFET، وفكرته في أبسط صورة أنه مفتاح كهربائي له ثلاثة أطراف: طرفان يمر بينهما التيار، المصدر Source والمصرف Drain، وطرف ثالث اسمه البوابة Gate يتحكم في المرور، فإذا وضعت على البوابة جهداً موجباً تكونت قناة بين الطرفين ومر التيار، وإذا أزلته اختفت القناة وانقطع التيار. والفرق المهم عن المرحل أنه لا يوجد شيء يتحرك، فالمفتاح يفتح ويغلق بتغيير الشحنات داخل السيليكون، وهذا يحدث مليارات المرات في الثانية. والصورة التالية تبين ترانزستورات منفصلة حقيقية، ثم شريحة من السبعينيات تجمع أربع بوابات NAND في غلاف واحد:

ثلاث لوحات: على اليسار صورة لثلاثة ترانزستورات منفصلة بأرجل معدنية فوق مسطرة بالسنتيمتر، وفي الوسط صورة شريحة سوداء مكتوب عليها SN7400N و7344 ولها أربعة عشر طرفاً، وعلى اليمين رسم للشريحة من الأعلى فيه أربع بوابات NAND والأطراف مرقمة من 1 إلى 14
ترانزستورات منفصلة، ثم شريحة 7400 فيها أربع بوابات NAND. صورة الترانزستورات: Vahid alpha، بترخيص CC BY 3.0، وصورة الشريحة: Mister rf، بترخيص CC BY-SA 4.0، من Wikimedia Commons (الترانزستورات والشريحة)، والرسم على اليمين من عرب رووت

في الصورة أعلاه لاحظ التالي:

  • كل ترانزستور منفصل على اليسار مفتاح واحد بثلاثة أطراف، وحجمه بالسنتيمتر، وهكذا كانت الدوائر تبنى في الستينيات: ترانزستور بجانب ترانزستور على لوحة.
  • الشريحة في الوسط من سلسلة 7400 التي صنعتها Texas Instruments، ورقم 7344 المطبوع عليها هو تاريخ الصنع: الأسبوع 44 من 1973، وفي داخلها كما في الرسم أربع بوابات NAND مستقلة، والطرفان 14 و7 للكهرباء والأرض، بحسب صفحة الشريحة عند الشركة التي ما زالت تبيعها حتى اليوم.
  • هذه الشريحة من تقنية قديمة اسمها TTL تستخدم نوعاً آخر من الترانزستورات، ولكن الوظيفة المنطقية نفسها، أي NAND، هي التي سوف نبنيها الآن بتقنية CMOS المستخدمة في كل معالج حديث.

بوابة NAND بتقنية CMOS: أربعة ترانزستورات

في تقنية CMOS، اختصاراً لـ Complementary MOS، نستخدم نوعين متكاملين من الترانزستورات: النوع nMOS يوصل عندما تكون بوابته 1، والنوع pMOS يوصل عندما تكون بوابته 0، أي أنه عكس الأول تماماً. والبوابة المنطقية تبنى من شبكتين: شبكة من pMOS تصل المخرج بالكهرباء، أي بالقيمة 1، وشبكة من nMOS تصل المخرج بالأرض، أي بالقيمة 0، وترتبان بحيث تعمل واحدة منهما فقط في كل حالة. والصورة التالية تبين بوابة NAND بهذه الطريقة:

ثلاث لوحات: الأولى تبين أن nMOS يوصل عندما البوابة 1 وأن pMOS يوصل عندما البوابة 0، والثانية دائرة فيها ترانزستوران pMOS على التوازي بين خط الكهرباء والمخرج وترانزستوران nMOS على التوالي بين المخرج والأرض، في حالة A = 1 وB = 1 حيث المفتاحان السفليان موصلان والمخرج 0، والثالثة جدول بالحالات الأربع والطريق الموصل في كل منها
NAND من أربعة ترانزستورات: pMOS على التوازي إلى الكهرباء، وnMOS على التوالي إلى الأرض

في الصورة أعلاه لاحظ التالي:

  • الترانزستوران nMOS على التوالي: لا يتصل المخرج بالأرض إلا إذا كان الاثنان موصلين، أي عندما A = 1 وB = 1 معاً، وهذا هو «و» Shannon بالضبط: مفتاحان على التوالي.
  • الترانزستوران pMOS على التوازي: يكفي أن يكون أحد المدخلين 0 حتى يتصل المخرج بالكهرباء فيصبح 1.
  • النتيجة: المخرج 0 فقط عندما يكون المدخلان 1، وفي كل الحالات الأخرى 1، وهذا عكس AND، ولذلك اسمها NAND، اختصاراً لـ Not AND.
  • لا يوجد طريق مباشر من الكهرباء إلى الأرض في أي حالة ثابتة، فإما أن الشبكة العلوية توصل وإما السفلية، وهذا هو سبب انتشار CMOS: البوابة لا تستهلك طاقة تقريباً وهي ثابتة، وتستهلك عندما تتغير قيمتها فقط، ولذلك يزيد استهلاك المعالج وحرارته كلما زاد تردده، لأنه يغير قيم الترانزستورات مرات أكثر في الثانية.

ولاحظ أن NAND أبسط من AND في هذه التقنية، فالبوابة AND تحتاج NAND ثم بوابة عكس NOT بعدها، أي ستة ترانزستورات بدل أربعة، وهذا أحد أسباب أن مصممي الدوائر يحبون NAND، والسبب الآخر سوف نراه في القسم التالي.

البوابات الأساسية Logic Gates وجداول الحقيقة Truth Tables

البوابة المنطقية دائرة لها مدخل أو أكثر ومخرج واحد، وكل مدخل ومخرج إما 0 وإما 1، ووصف البوابة الكامل هو جدول الحقيقة Truth Table، أي جدول فيه كل تركيبات المدخلات الممكنة والمخرج لكل منها، ولأن المدخلين لهما أربع تركيبات فقط فالجدول دائماً أربعة صفوف. والصورة التالية تبين البوابات الست الأساسية برموزها المعتمدة في الرسوم الهندسية وجداول حقيقتها، وتحت كل بوابة عدد بوابات NAND التي احتاجتها في التجربة:

ست بطاقات لست بوابات: NOT بمثلث ودائرة صغيرة، وAND بشكل نصف دائرة، وOR بشكل مقوس، وNAND بشكل AND مع دائرة، وNOR بشكل OR مع دائرة، وXOR بشكل OR مع خط مقوس إضافي، ولكل منها جدول الحقيقة وعدد بوابات NAND، وفي الأسفل رسم لبوابة XOR من أربع بوابات NAND مرقمة
البوابات الأساسية: الرمز وجدول الحقيقة وعدد ال NAND، وفي الأسفل XOR من أربع NAND

وكل بوابة من هذه البوابات تعرفها من البرمجة دون أن تنتبه:

  • NOT: تعكس المدخل، وهي ! في المنطق و~ على البتات في C وJavaScript وPython.
  • AND: المخرج 1 فقط عندما يكون المدخلان 1، وهي && و&، وتستخدمها في الأقنعة Masks لاستخراج بتات معينة من رقم.
  • OR: المخرج 1 إذا كان أحد المدخلين 1 على الأقل، وهي || و|، وتستخدمها لرفع أعلام Flags في رقم واحد.
  • XOR: المخرج 1 عندما يختلف المدخلان، وهي ^، وتظهر في التشفير وفي حساب Checksum، وسوف نرى بعد قليل أنها هي نفسها مجموع بتين.
  • NAND وNOR: عكس AND وعكس OR، ولا يوجد لهما رمز مباشر في أغلب اللغات، ولكنهما الأهم في العتاد.

وقد شرحنا استخدام هذه العمليات على البتات في الكود في مقال الأنظمة العددية، والفرق هنا أننا نرى كل عملية كدائرة حقيقية لها زمن وتكلفة.

لماذا تكفي NAND وحدها؟ الاكتمال الوظيفي Functional Completeness

البوابة NAND لها خاصية مهمة جداً: يمكن بناء أي بوابة أخرى منها وحدها، ومن ثم أي دائرة رقمية مهما كانت، وهذا اسمه الاكتمال الوظيفي Functional Completeness. والفكرة تبنى خطوة خطوة كما في التجربة:

  1. NOT من NAND واحدة: إذا أدخلت نفس الإشارة في المدخلين فالمخرج NAND(x, x) هو عكس x، فإذا كان x = 1 كان المخرج 0 وإذا كان 0 كان 1.
  2. AND من اثنتين: NAND ثم NOT، أي عكس العكس.
  3. OR من ثلاث: بقاعدة De Morgan التي تقول إن «A أو B» تساوي «ليس (ليس A و ليس B)»، فنعكس كل مدخل ثم نمررهما في NAND.
  4. XOR من أربع: كما في أسفل الصورة، الأولى تحسب NAND(A, B)، والثانية والثالثة تقارنان نتيجتها بكل مدخل، والرابعة تجمع النتيجتين.

وقد يتساءل البعض: إذا كانت NAND تكفي، فلماذا تصمم المعالجات ببوابات متنوعة؟ والإجابة أن بناء كل شيء من NAND يضيع ترانزستورات وزمناً، فالبوابة OR التي تبنى من ثلاث NAND تمر الإشارة فيها في بوابتين متتاليتين، بينما يمكن بناؤها مباشرة بستة ترانزستورات. ولذلك تستخدم أدوات التصميم مكتبة من الخلايا الجاهزة Standard Cells فيها عشرات الأنواع، وتبقى قيمة NAND في الفكرة: لو كان عندك نوع واحد فقط من القطع لاستطعت بناء الحاسب كله، وهذا بالضبط ما يفعله مشروع Nand2Tetris الذي يبدأ من NAND وينتهي بحاسب يشغل لعبة.

من البوابات إلى الحساب: الجامع Adder

الآن نعود إلى السؤال الأول: كيف تجمع الدائرة 6 + 7؟ والجمع بالثنائي يتبع نفس الطريقة التي تعلمتها في المدرسة بالعشري: تجمع العمود الأيمن، فإذا تجاوز المجموع الرقم الأكبر كتبت الآحاد وحملت واحداً إلى العمود التالي، والفرق أن الأرقام في كل عمود 0 أو 1 فقط، فالجدول كله أربع حالات.

نصف الجامع Half Adder: بتان فقط

لنأخذ عموداً واحداً فيه بتان A وB، فالحالات هي: 0 + 0 = 0، و0 + 1 = 1، و1 + 0 = 1، و1 + 1 = 10 بالثنائي، أي المجموع 0 والمنقول Carry يساوي 1. والآن انظر إلى عمود المجموع وحده: هو 1 عندما يختلف البتان و0 عندما يتساويان، وهذا جدول XOR بالضبط. وعمود المنقول Carry، أي ما ننقله إلى العمود التالي: هو 1 فقط عندما يكون البتان 1، وهذا جدول AND. أي أن بوابتين فقط، XOR وAND، تكفيان لجمع بتين، وهذه الدائرة اسمها نصف الجامع Half Adder، وسميت «نصف» لأنها لا تقبل حملاً قادماً من عمود سابق.

الجامع الكامل Full Adder: ثلاثة بتات

في كل عمود غير الأول نجمع ثلاثة بتات: بتي الرقمين والمنقول القادم من العمود الذي قبله، والدائرة التي تفعل ذلك اسمها الجامع الكامل Full Adder، والطريقة البسيطة لبنائها أن نستخدم نصفي جامع: الأول يجمع A وB، والثاني يجمع الناتج مع المنقول القادم Cin، ثم بوابة OR تجمع بتي المنقول، لأن بتي المنقول لا يمكن أن يكونا 1 في نفس الوقت. والصورة التالية تبين نصف الجامع ثم الجامع الكامل ثم الخطوة التالية:

ثلاث لوحات: نصف جامع من بوابتي XOR وAND، ثم جامع كامل من نصفي جامع وبوابة OR، ثم أربعة جوامع كاملة FA3 وFA2 وFA1 وFA0 في سلسلة، على كل منها بتا الرقمين والمنقول القادم، والمنقول يمر من اليمين إلى اليسار بالقيم 0 ثم 1 ثم 1 ثم 0، والمجموع 1101
من نصف الجامع إلى جامع أربعة بتات: المثال 6 + 7 = 13 كما خرج من التجربة

الجامع المتسلسل Ripple-Carry Adder: أربعة بتات

لجمع رقمين من أربعة بتات نضع أربعة جوامع كاملة بجانب بعضها، لكل بت جامع، ونوصل حمل كل جامع بمدخل المنقول في الجامع الذي بعده، ونضع 0 في حمل الجامع الأول. وهذه الدائرة اسمها Ripple-Carry Adder، لأن المنقول يتموج من البت الأيمن إلى الأيسر. وفي مثالنا 0110 + 0111، أي 6 + 7:

  • البت 0: 0 + 1 + 0 = 1، فالمجموع 1 والمنقول 0.
  • البت 1: 1 + 1 + 0 = 10، فالمجموع 0 والمنقول 1.
  • البت 2: 1 + 1 + 1 = 11، فالمجموع 1 والمنقول 1.
  • البت 3: 0 + 0 + 1 = 1، فالمجموع 1 والمنقول 0.

فالناتج 1101، وهو 13، وهذا ما أعاده المحاكي في التجربة. والدائرة التي في المعالج لجمع رقمين من 64 بت هي نفس الفكرة بأربعة وستين جامعاً، بتصميم أسرع كما سوف نرى.

ومن هنا تستطيع أن تحسب حجم الدائرة بنفسك: الجامع لأربعة بتات في التجربة احتاج 60 بوابة NAND، وكل NAND بتقنية CMOS أربعة ترانزستورات، أي 240 ترانزستوراً، والجامع لأربعة وستين بتاً بنفس الطريقة 960 بوابة، أي 3840 ترانزستوراً. وهذا رقم صغير جداً بجانب مليارات الترانزستورات في المعالج الحديث، وهذا يخبرك أين تذهب هذه المليارات: ليس في وحدات الحساب نفسها، وإنما في الذاكرة المؤقتة Cache التي تأخذ معظم مساحة الشريحة، وفي الدوائر التي تتوقع القفزات وتعيد ترتيب التعليمات وتشغل عدة وحدات حساب في نفس الوقت، كما رأينا في مقال المعالج من الداخل. أي أن الجمع نفسه رخيص، والغالي هو أن تجعل المعالج لا ينتظر البيانات التي يجمعها.

ماذا يحدث عند الفيضان Overflow؟

في التجربة جمعنا 9 + 9 فكان الناتج carry 1 sum 0010، أي أن البتات الأربعة فيها 2، والمنقول الأخير فيه 1، والقيمة الكاملة 18. فإذا كان المتغير في برنامجك أربعة بتات فقط فأين يذهب المنقول الأخير؟ لا يذهب إلى أي مكان، فالدائرة تخرجه على سلك منفصل، والمعالج يحفظه في علم Flag خاص اسمه Carry Flag، ثم يتجاهله برنامجك غالباً، فيرى 2 بدل 18، وهذا هو الفيضان Overflow الذي شرحناه في مقال الأنظمة العددية.

والشيء الجميل هنا أن نفس الدائرة تجمع الأرقام السالبة دون أي تعديل إذا كانت ممثلة بالمتمم الثنائي Two's Complement: فإذا جمعت 0111 + 0001 كان الناتج 1000، وهو 8 إذا اعتبرت الأرقام بدون إشارة، و‎-8 إذا اعتبرتها بإشارة، والدائرة لا تعرف الفرق ولا تهتم، والذي يقرر المعنى هو نوع المتغير في برنامجك. ولهذا السبب بالضبط اختارت كل المعالجات تقريباً المتمم الثنائي لتمثيل الأرقام السالبة: لأن الجامع نفسه يعمل للنوعين، ولا تحتاج دائرة طرح منفصلة، فالطرح هو جمع مع عكس الرقم وإضافة 1.

زمن الانتشار Propagation Delay: لماذا للبوابة سرعة؟

حتى الآن عاملنا البوابة كأنها تعطي الناتج فوراً، ولكن هذا غير صحيح: عندما يتغير المدخل تحتاج الترانزستورات إلى وقت لتشحن أو تفرغ الأسلاك التي بعدها، والزمن من تغير المدخل حتى يستقر المخرج اسمه زمن الانتشار Propagation Delay. وشريحة SN7400 التي رأيتها في الصورة مثلاً تذكر في ورقة مواصفاتها زمناً نموذجياً بين 7 و11 نانوثانية لكل بوابة بحسب اتجاه التغير، وفي المعالجات الحديثة الزمن أصغر بكثير، ولكنه ليس صفراً أبداً.

وزمن الدائرة كلها هو زمن أطول طريق تمر فيه الإشارة من مدخل إلى مخرج، وهذا الطريق اسمه المسار الحرج Critical Path. وفي الجامع المتسلسل أطول طريق هو طريق المنقول: البت الأخير لا يعرف قيمته حتى يصله المنقول من البت الذي قبله، وذلك لا يعرف حتى يصله من الذي قبله، وهكذا. وقد قسنا ذلك في المحاكي بعدد الخطوات، حيث الخطوة هي زمن بوابة NAND واحدة، والصورة التالية تبين النتيجة:

رسم أعمدة لعدد الخطوات حتى يستقر الناتج: لجامع من 15 NAND تبلغ أسوأ حالة 16 خطوة لأربعة بتات ثم 32 و64 و128 و256 لأربعة وستين بتاً، ولجامع من 9 NAND تبلغ 9 خطوات لأربعة بتات و129 لأربعة وستين، والحالة السهلة 4 خطوات دائماً
أسوأ حالة يزيد زمنها مع عدد البتات بخط مستقيم، والحالة السهلة ثابتة

في الصورة أعلاه لاحظ التالي:

  • الحالة السهلة 0 + 1 استقرت في 4 خطوات مهما كان عدد البتات، لأنه لا يوجد حمل ينتقل.
  • أسوأ حالة 0111…1 + 1 يمر فيها المنقول في كل البتات، فأخذت 16 خطوة لأربعة بتات و256 لأربعة وستين، أي أن الزمن يتضاعف مع عدد البتات.
  • الدائرة يجب أن تنتظر أسوأ حالة دائماً، لأنها لا تعرف مسبقاً أي الأرقام سوف تأتي، ولذلك يحدد أسوأ طريق زمن كل عملية جمع.

ولو أخذنا الرقم من ورقة مواصفات SN7400 كمثال، أي نحو 10 نانوثوان للبوابة، فجامع 64 بت متسلسل من هذه البوابات بأفضل تصميم في التجربة، 129 خطوة، يحتاج أكثر من 1.2 ميكروثانية لعملية جمع واحدة، بينما المعالج الحديث الذي يعمل بتردد 4 GHz يكمل الدورة الواحدة في ربع نانوثانية. والفرق يأتي من شيئين: ترانزستورات أصغر وأسرع بكثير، وتصميمات لا تنتظر المنقول بتاً بعد بت، وإنما تحسب مسبقاً هل سيولد كل جزء من الرقم حملاً أم سيمرره، وهذا اسمه Carry-Lookahead، وهي تضيف بوابات أكثر مقابل طريق أقصر. وهذه المقايضة بين عدد البوابات والزمن هي أكثر قرار يتكرر في تصميم الدوائر، تماماً كالمقايضة بين الذاكرة والسرعة في البرمجة.

ومن هنا تفهم أيضاً معنى تردد المعالج: الساعة تعطي الدوائر وقتاً محدداً في كل نبضة لتستقر، فإذا كان أطول طريق في المعالج يحتاج 0.3 نانوثانية فلا يمكن أن تكون الدورة أقصر من ذلك، ولو رفعت التردد أكثر لقرأ المعالج النتيجة قبل أن تستقر فيحصل على قيم خاطئة. وهذا ما يحدث لمن يرفع تردد معالجه Overclocking أكثر من اللازم: لا يبطئ الجهاز، وإنما تظهر أخطاء غريبة وتوقفات مفاجئة، لأن بعض الدوائر لم تنته في الوقت المتاح.

الدوائر التي تتذكر: المنطق التتابعي Sequential Logic

كل الدوائر السابقة من نوع اسمه المنطق التركيبي Combinational Logic: مخرجها يعتمد فقط على مدخلاتها الحالية، فإذا أزلت المدخلات اختفى الناتج. ولكن الحاسب يحتاج أن يحفظ الناتج: المسجلات Registers داخل المعالج، وعداد البرنامج، وال Cache، كلها تحفظ قيماً بعد أن تختفي المدخلات التي صنعتها. والسؤال: كيف تتذكر دائرة مصنوعة من بوابات لا تتذكر؟ والإجابة فكرة واحدة: أن نوصل مخرج البوابة بمدخلها، أي حلقة تغذية راجعة Feedback.

القفل SR Latch: بوابتان تحرس كل منهما الأخرى

خذ بوابتي NAND، ووصل مخرج كل منهما بأحد مدخلي الأخرى، فيبقى لكل بوابة مدخل حر: الأول اسمه S_n للضبط Set، والثاني R_n للمسح Reset، والحرف n يعني أنهما يعملان بالصفر Active-Low، أي أن المدخل يؤثر عندما يكون 0. والصورة التالية تبين الدائرة والحالات كما خرجت من المحاكي:

على اليسار بوابتا NAND مخرج كل منهما موصل بمدخل الأخرى، ومدخلان S_n وR_n ومخرجان Q وQn، وعلى اليمين جدول الحالات set وhold وreset وhold وboth 0 بقيم المدخلات والمخرجات، وفي الأسفل شريط أحمر فيه ست قيم متتالية 00 و11 و00 و11 و00 و11 تبين أن الدائرة لا تستقر
القفل SR Latch: الحلقة نفسها هي التي تحفظ البت، والحالة الممنوعة تجعل الدائرة تتذبذب

في الصورة أعلاه لاحظ التالي:

  • set: عندما يصبح S_n = 0 يصبح مخرج البوابة الأولى Q = 1 مهما كان مدخلها الآخر، لأن NAND تعطي 1 إذا كان أحد مدخليها 0، ثم يدخل Q = 1 في البوابة الثانية مع R_n = 1 فيصبح Qn = 0.
  • hold: نعيد S_n إلى 1، فلا يتغير شيء: البوابة الأولى ترى 1 و Qn = 0 فتبقى 1، والثانية ترى 1 و Q = 1 فتبقى 0. أي أن الدائرة تذكرت أننا ضبطناها، رغم أن المدخلين الآن مثل حالة البداية، وهذا هو البت المحفوظ.
  • reset ثم hold: نفس الشيء بالعكس، Q = 0 ويبقى كذلك.
  • both 0: إذا جعلت المدخلين 0 معاً أصبح المخرجان 1، أي أن Q وعكسه متساويان، وهذه حالة لا معنى لها، ولذلك تسمى الحالة الممنوعة.

والمشكلة الأكبر تظهر عندما ترفع المدخلين من 0 إلى 1 في نفس اللحظة تماماً: البوابتان تريان 1 و1 فتصبحان 0 معاً، ثم تريان 0 فتصبحان 1 معاً، وهكذا إلى الأبد، كما ترى في المخرج 00 11 00 11 00 11. وفي المحاكي يحدث هذا لأن كل البوابات لها نفس الزمن بالضبط، أما في الدائرة الحقيقية فتختلف البوابتان قليلاً في سرعتهما، فتفوز إحداهما بعد فترة لا تعرف طولها مسبقاً، وتستقر الدائرة على 0 أو 1 بشكل لا يمكن توقعه. وهذه الحالة لها اسم في تصميم الدوائر، وهو عدم الاستقرار Metastability، وهي سبب حقيقي لأعطال يصعب تكرارها في الدوائر التي تقرأ إشارة من مصدر لا يتبع نفس الساعة، ولذلك يمر مثل هذا المدخل عادة في قلابين متتاليين قبل استخدامه ليعطي الدائرة وقتاً لتستقر.

القلاب D Flip-Flop والساعة Clock

القفل SR له مدخلان وحالة ممنوعة، وهذا غير مناسب لتخزين بت في معالج، فالأسهل أن يكون للدائرة مدخل بيانات واحد D وإشارة تقول «احفظ الآن». وأول خطوة هي القفل D Latch: نضع أمام القفل SR بوابتين تحولان المدخل D إلى S وR متعاكسين دائماً، فلا تحدث الحالة الممنوعة أبداً، ونضيف مدخل تمكين Enable، فإذا كان 1 تبع المخرج المدخل D، وإذا كان 0 حفظ آخر قيمة.

ولكن القفل D له عيب في الدوائر الكبيرة: طوال الوقت الذي يكون فيه التمكين 1 يكون القفل «شفافاً»، أي أن أي تغيير في D يمر إلى المخرج مباشرة، فإذا كان مخرج القفل يرجع بعد بعض الحسابات إلى مدخله، كما في عداد يضيف 1 إلى قيمته، فقد يدور الناتج في الحلقة عدة مرات في نبضة واحدة. والحل هو القلاب D Flip-Flop: قفلان متتاليان، الأول Master يتبع D عندما تكون الساعة 0، والثاني Slave ينسخ الأول عندما تصبح الساعة 1، فلا يتغير المخرج إلا في اللحظة التي ترتفع فيها الساعة من 0 إلى 1، وهذه اللحظة اسمها الحافة الصاعدة Rising Edge. والصورة التالية تبين ذلك من مخرج التجربة:

مخطط زمني بأربعة خطوط على ثماني خطوات: الساعة CLK والمدخل D والقفل الأول master والمخرج Q، وثلاثة أعمدة مظللة عند الحواف الصاعدة للساعة في الخطوات 2 و5 و8، والمخرج Q يتغير فقط عند هذه الأعمدة رغم أن D يتغير في أوقات أخرى
القلاب D: الناتج يتغير عند الحافة الصاعدة للساعة فقط

في الصورة أعلاه لاحظ التالي:

  • الخطوة 2: الساعة ارتفعت و D = 1، فأصبح Q = 1.
  • الخطوة 3: D أصبح 0 والساعة ما زالت 1، ومع ذلك بقي Q = 1، لأن القفل الأول مغلق ما دامت الساعة 1.
  • الخطوة 4: الساعة نزلت، فتبع القفل الأول D وأصبح 0، ولكن Q بقي 1، لأن القفل الثاني الآن هو المغلق.
  • الخطوة 5: الساعة ارتفعت مرة أخرى، فنسخ Q القيمة 0 من القفل الأول.
  • الخطوة 6: D أصبح 1 والساعة 0، فتبعه القفل الأول، ولكن D رجع إلى 0 في الخطوة 7 قبل أن ترتفع الساعة، فلم ير Q هذا التغيير أبداً.

وهذا بالضبط ما يحتاجه المعالج: كل المسجلات تتغير في نفس اللحظة عند كل حافة صاعدة، وبين حافتين تعمل الدوائر التركيبية، مثل الجامع، على القيم المحفوظة وتستقر، ثم تحفظ النتيجة في الحافة التالية. وضع 64 قلاباً بجانب بعضها على نفس الساعة فتحصل على مسجل Register من 64 بت، مثل المسجلات التي رأيناها في مقال المعالج من الداخل، ووصل مخرج مسجل بجامع يضيف 1 ثم أرجع الناتج إلى المسجل فتحصل على عداد يزيد واحداً مع كل نبضة، وهو عداد البرنامج Program Counter في أبسط صورة. وفي المحاكي احتاج القلاب الواحد 11 بوابة NAND، أي 44 ترانزستوراً لكل بت، بينما احتاج الجامع الكامل 15، وهذا يفسر لماذا لا تبنى الذاكرة الكبيرة من القلابات: مسجل من 64 بت يحتاج آلاف الترانزستورات، وذاكرة من عدة جيجابايتات بنفس الطريقة لا يمكن أن تتسع في أي شريحة. ولاحظ أن هذه الدوائر ليست هي ذاكرة RAM، فخلية RAM الديناميكية مكثف وترانزستور واحد كما شرحنا في مقال الذاكرة RAM من الداخل، والقلابات أكبر بكثير وأسرع، ولذلك تستخدم للمسجلات وخطوط التنفيذ.

التجربة العملية Lab: محاكي بوابات منطقية في Python

كل جداول الحقيقة والأرقام التي رأيتها في المقال خرجت من محاكي صغير كتبناه بمكتبة Python القياسية فقط، ونشغله داخل Container من python:3.13.17-slim حتى تحصل على نفس النتيجة. وفكرة المحاكي بسيطة: الدائرة قائمة من بوابات NAND فقط، لكل بوابة مدخلان واسم لمخرجها، وفي كل خطوة تنظر كل البوابات إلى القيم الحالية في نفس الوقت وتحسب مخرجها، فالخطوة الواحدة تساوي زمن بوابة واحدة، ونكرر الخطوات حتى لا يتغير شيء. والسكربت كاملاً كما يلي:

# A tiny logic simulator: every gate is a NAND, every NAND takes one time step.
#   docker run --rm -v "$PWD":/lab python:3.13.17-slim python /lab/gates.py
import itertools, sys


class Circuit:
    def __init__(self):
        self.gates = {}                 # output name -> (input a, input b)
        self.sig = {}                   # signal name -> 0/1

    def nand(self, a, b, name=None):
        name = name or f"g{len(self.gates)}"
        self.gates[name] = (a, b)
        self.sig.setdefault(name, 1)    # a NAND with unknown inputs starts at 1
        return name

    # every other gate is built from NAND only
    def not_(self, a):     return self.nand(a, a)
    def and_(self, a, b):  return self.not_(self.nand(a, b))
    def or_(self, a, b):   return self.nand(self.not_(a), self.not_(b))
    def xor(self, a, b):
        n = self.nand(a, b)
        return self.nand(self.nand(a, n), self.nand(b, n))

    def set(self, **values):
        self.sig.update(values)

    def step(self):
        """All gates look at the current signals at the same time (one gate delay)."""
        new = {g: 1 - (self.sig[a] & self.sig[b]) for g, (a, b) in self.gates.items()}
        changed = [g for g in new if new[g] != self.sig[g]]
        self.sig.update(new)
        return changed

    def settle(self, limit=None):
        # a circuit without loops is stable after at most one step per gate, so a fixed limit such as 100
        # stops a long carry chain too early; anything still changing after len(gates) steps has a loop
        limit = limit or len(self.gates) + 1
        for t in range(1, limit + 1):
            if not self.step():
                return t - 1            # steps until nothing changed
        return None                     # still changing after `limit` steps


def half_adder(c, a, b):
    return c.xor(a, b), c.and_(a, b)


def full_adder(c, a, b, cin):
    s1, c1 = half_adder(c, a, b)
    s, c2 = half_adder(c, s1, cin)
    return s, c.or_(c1, c2)


def full_adder9(c, a, b, cin):
    """The same full adder with 9 NANDs: the NAND of a and b is shared by the XOR and the carry."""
    n1 = c.nand(a, b)
    x = c.nand(c.nand(a, n1), c.nand(b, n1))            # a XOR b
    n5 = c.nand(x, cin)
    s = c.nand(c.nand(x, n5), c.nand(cin, n5))           # (a XOR b) XOR cin
    return s, c.nand(n5, n1)                             # carry = (a AND b) OR (x AND cin)


def ripple_adder(c, n, fa=full_adder):
    carry, sums = "cin", []
    for i in range(n):
        s, carry = fa(c, f"a{i}", f"b{i}", carry)
        sums.append(s)
    return sums, carry


def table(title, names, fn):
    print(f"== {title}")
    print("  ".join(names[:-1]) + " | " + names[-1])
    for bits in itertools.product((0, 1), repeat=len(names) - 1):
        print("  ".join(str(b) for b in bits) + " | " + str(fn(*bits)))


def gate_table(title, build, n_in=2):
    c = Circuit(); ins = ["x", "y"][:n_in]
    out = build(c, *ins)
    def run(*bits):
        c.set(**dict(zip(ins, bits))); c.settle(); return c.sig[out]
    table(f"{title} ({len(c.gates)} NAND)", ins + ["out"], run)


if __name__ == "__main__":
    part = sys.argv[1] if len(sys.argv) > 1 else "all"

    if part in ("all", "gates"):
        gate_table("NAND", lambda c, x, y: c.nand(x, y))
        gate_table("NOT", lambda c, x: c.not_(x), 1)
        gate_table("AND", lambda c, x, y: c.and_(x, y))
        gate_table("OR", lambda c, x, y: c.or_(x, y))
        gate_table("XOR", lambda c, x, y: c.xor(x, y))

    if part in ("all", "fa"):
        c = Circuit(); s, co = full_adder(c, "a", "b", "cin")
        def fa(a, b, cin):
            c.set(a=a, b=b, cin=cin); c.settle(); return f"{c.sig[co]}{c.sig[s]}"
        table(f"full adder ({len(c.gates)} NAND), output = carry sum", ["a", "b", "cin", "co s"], fa)

    if part in ("all", "adder"):
        c = Circuit(); sums, cout = ripple_adder(c, 4)
        print(f"== 4-bit ripple-carry adder: {len(c.gates)} NAND")
        def add(x, y):
            c.set(cin=0, **{f"a{i}": x >> i & 1 for i in range(4)}, **{f"b{i}": y >> i & 1 for i in range(4)})
            c.settle()
            return sum(c.sig[s] << i for i, s in enumerate(sums)), c.sig[cout]
        for x, y in [(5, 3), (6, 7), (9, 9), (15, 1)]:
            r, co = add(x, y)
            print(f"{x:04b} + {y:04b} = carry {co} sum {r:04b}  ({x} + {y} = {co * 16 + r})")
        wrong = [(x, y) for x in range(16) for y in range(16) if (lambda r: r[1] * 16 + r[0])(add(x, y)) != x + y]
        print(f"checked all 256 pairs against Python: {len(wrong)} wrong")

    if part in ("all", "delay"):
        print("== gate delays until the sum is stable (start from 0 + 0)")
        for n, fa in [(4, full_adder), (8, full_adder), (16, full_adder), (32, full_adder), (64, full_adder),
                      (4, full_adder9), (64, full_adder9)]:
            c = Circuit(); sums, cout = ripple_adder(c, n, fa)
            c.set(cin=0, **{f"a{i}": 0 for i in range(n)}, **{f"b{i}": 0 for i in range(n)}); c.settle()
            c.set(a0=1, b0=0, **{f"a{i}": 1 for i in range(1, n)}); c.settle()        # 0111...1 + 0
            c.set(b0=1); t_worst = c.settle()                                          # + 1: the carry runs through every bit
            c.set(**{f"a{i}": 0 for i in range(n)}, b0=0); c.settle()
            c.set(a0=1); t_easy = c.settle()                                           # 0 + 1: no carry at all
            print(f"{fa.__name__:<11} {n:>2} bits: {len(c.gates):>4} NAND, 0+1 settles in {t_easy:>3} steps, 111..1+1 settles in {t_worst:>3} steps")

    if part in ("all", "latch"):
        print("== SR latch from two NANDs (inputs are active-low: S_n=0 sets, R_n=0 resets)")
        c = Circuit(); c.sig.update(S_n=1, R_n=1)
        c.nand("S_n", "Qn", "Q"); c.nand("R_n", "Q", "Qn")
        c.sig.update(Q=0, Qn=1)
        for label, s, r in [("set", 0, 1), ("hold", 1, 1), ("reset", 1, 0), ("hold", 1, 1), ("both 0", 0, 0)]:
            c.set(S_n=s, R_n=r); t = c.settle()
            print(f"{label:<7} S_n={s} R_n={r} -> Q={c.sig['Q']} Qn={c.sig['Qn']}  (stable after {t} steps)")
        c.set(S_n=1, R_n=1); t = c.settle(limit=20)
        trace = []
        c.set(S_n=0, R_n=0); c.settle(); c.set(S_n=1, R_n=1)
        for _ in range(6):
            c.step(); trace.append(f"{c.sig['Q']}{c.sig['Qn']}")
        print(f"release both at once -> settle result: {t}; Q,Qn over 6 steps: {' '.join(trace)}")

    if part in ("all", "dlatch"):
        print("== D flip-flop (two D latches: master open when CLK=0, slave open when CLK=1)")
        c = Circuit(); c.sig.update(D=0, CLK=0)
        def d_latch(d, en, q, qn):
            s_n = c.nand(d, en); r_n = c.nand(c.not_(d), en)
            c.nand(s_n, qn, q); c.nand(r_n, q, qn)
            c.sig.update({q: 0, qn: 1})
        nclk = c.not_("CLK")
        d_latch("D", nclk, "M", "Mn")       # master follows D while the clock is low
        d_latch("M", "CLK", "Q", "Qn")      # slave copies the master when the clock goes high
        c.settle()
        for clk, d in [(0, 1), (1, 1), (1, 0), (0, 0), (1, 0), (0, 1), (0, 0), (1, 0)]:
            c.set(CLK=clk, D=d); c.settle()
            print(f"CLK={clk} D={d} -> master={c.sig['M']} Q={c.sig['Q']}")

في الكود أعلاه لاحظ التالي:

  • الكلاس Circuit يعرف نوعاً واحداً فقط من البوابات، nand، وكل الدوال الأخرى not_ وand_ وor_ وxor تبني من NAND، فإذا أعطاك المحاكي جدولاً صحيحاً فقد أثبتت التجربة الاكتمال الوظيفي لهذه البوابات.
  • الدالة step تحسب كل المخرجات الجديدة من القيم القديمة ثم تحدثها معاً، ولو حدثنا كل بوابة فور حسابها لأصبح ترتيب البوابات في القائمة يؤثر في النتيجة، وهذا لا يحدث في الدائرة الحقيقية.
  • الدالة settle تكرر الخطوات حتى لا يتغير شيء، وتعيد عدد الخطوات، أو None إذا لم تستقر، وسوف نرى أن هذا الحد كان سبب المشكلة التي ظهرت.
  • الدالة full_adder9 نسخة ثانية من الجامع الكامل بتسع بوابات، نستخدمها في تجربة «ماذا يحدث إذا».
  • القفل والقلاب مبنيان من نفس NAND، والفرق الوحيد أن مخرجات بعض البوابات موصلة بمدخلات بوابات سابقة، فالمحاكي نفسه لا يعرف شيئاً اسمه «ذاكرة».

ونشغله بالأمر التالي من مجلد lab:

docker run --rm -v "$PWD":/lab python:3.13.17-slim python /lab/gates.py

والمخرج سوف يكون كما يلي:

== NAND (1 NAND)
x  y | out
0  0 | 1
0  1 | 1
1  0 | 1
1  1 | 0
== NOT (1 NAND)
x | out
0 | 1
1 | 0
== AND (2 NAND)
x  y | out
0  0 | 0
0  1 | 0
1  0 | 0
1  1 | 1
== OR (3 NAND)
x  y | out
0  0 | 0
0  1 | 1
1  0 | 1
1  1 | 1
== XOR (4 NAND)
x  y | out
0  0 | 0
0  1 | 1
1  0 | 1
1  1 | 0
== full adder (15 NAND), output = carry sum
a  b  cin | co s
0  0  0 | 00
0  0  1 | 01
0  1  0 | 01
0  1  1 | 10
1  0  0 | 01
1  0  1 | 10
1  1  0 | 10
1  1  1 | 11
== 4-bit ripple-carry adder: 60 NAND
0101 + 0011 = carry 0 sum 1000  (5 + 3 = 8)
0110 + 0111 = carry 0 sum 1101  (6 + 7 = 13)
1001 + 1001 = carry 1 sum 0010  (9 + 9 = 18)
1111 + 0001 = carry 1 sum 0000  (15 + 1 = 16)
checked all 256 pairs against Python: 0 wrong
== gate delays until the sum is stable (start from 0 + 0)
full_adder   4 bits:   60 NAND, 0+1 settles in   4 steps, 111..1+1 settles in  16 steps
full_adder   8 bits:  120 NAND, 0+1 settles in   4 steps, 111..1+1 settles in  32 steps
full_adder  16 bits:  240 NAND, 0+1 settles in   4 steps, 111..1+1 settles in  64 steps
full_adder  32 bits:  480 NAND, 0+1 settles in   4 steps, 111..1+1 settles in 128 steps
full_adder  64 bits:  960 NAND, 0+1 settles in   4 steps, 111..1+1 settles in 256 steps
full_adder9  4 bits:   36 NAND, 0+1 settles in   4 steps, 111..1+1 settles in   9 steps
full_adder9 64 bits:  576 NAND, 0+1 settles in   4 steps, 111..1+1 settles in 129 steps
== SR latch from two NANDs (inputs are active-low: S_n=0 sets, R_n=0 resets)
set     S_n=0 R_n=1 -> Q=1 Qn=0  (stable after 2 steps)
hold    S_n=1 R_n=1 -> Q=1 Qn=0  (stable after 0 steps)
reset   S_n=1 R_n=0 -> Q=0 Qn=1  (stable after 2 steps)
hold    S_n=1 R_n=1 -> Q=0 Qn=1  (stable after 0 steps)
both 0  S_n=0 R_n=0 -> Q=1 Qn=1  (stable after 1 steps)
release both at once -> settle result: None; Q,Qn over 6 steps: 00 11 00 11 00 11
== D flip-flop (two D latches: master open when CLK=0, slave open when CLK=1)
CLK=0 D=1 -> master=1 Q=0
CLK=1 D=1 -> master=1 Q=1
CLK=1 D=0 -> master=1 Q=1
CLK=0 D=0 -> master=0 Q=1
CLK=1 D=0 -> master=0 Q=0
CLK=0 D=1 -> master=1 Q=0
CLK=0 D=0 -> master=0 Q=0
CLK=1 D=0 -> master=0 Q=0

في المخرج أعلاه لاحظ التالي:

  • جداول البوابات: كل جدول خرج مطابقاً للتعريف، وعدد ال NAND بجانب كل اسم: NOT من واحدة، وAND من اثنتين، وOR من ثلاث، وXOR من أربع.
  • الجامع الكامل: الصفوف الثمانية تعطي co s وهي في الحقيقة مجموع البتات الثلاثة بالثنائي، فالصف 1 1 1 أعطى 11، أي 3، وقد احتاج 15 NAND لأننا بنيناه من قطع جاهزة: نصفي جامع وOR.
  • الجامع لأربعة بتات: 60 NAND، و0110 + 0111 = carry 0 sum 1101 هو المثال الذي تابعناه، و15 + 1 أعطى carry 1 sum 0000، أي أن البتات الأربعة كلها صفر والمنقول وحده يحمل الرقم 16.
  • السطر checked all 256 pairs against Python: 0 wrong: جربنا كل التركيبات الممكنة لرقمين من أربعة بتات وقارنا كل ناتج بعملية الجمع في Python، فلم يخطئ المحاكي في أي منها، وهذا أفضل من الثقة بأربعة أمثلة.
  • أسطر الزمن: أسوأ حالة تضاعفت مع عدد البتات من 16 إلى 256، بينما بقيت الحالة السهلة 4 خطوات، وهي الأرقام التي رسمناها في صورة زمن الانتشار.
  • القفل: stable after 2 steps في set وreset لأن الإشارة تمر في البوابتين واحدة بعد الأخرى، و0 steps في hold لأن لا شيء تغير، ثم settle result: None عند رفع المدخلين معاً، أي أن الدائرة لم تستقر خلال 20 خطوة.
  • القلاب: Q تغير في الأسطر التي أصبحت فيها CLK=1 بعد CLK=0 فقط، وهي الأسطر التي رسمناها في المخطط الزمني.

المشكلة التي ظهرت: حد التكرار أوقف المنقول قبل أن يصل

في النسخة الأولى من السكربت كانت الدالة settle تتوقف بعد 100 خطوة كحد ثابت، والسبب واضح: الدائرة التي فيها حلقة، مثل القفل في الحالة الممنوعة، قد لا تستقر أبداً، ولا نريد للمحاكي أن يعلق. وعمل كل شيء بشكل صحيح حتى وصلنا إلى قياس الزمن، فكان المخرج كما يلي:

16 bits:  240 NAND, 0+1 settles in   4 steps, 111..1+1 settles in  64 steps
Traceback (most recent call last):
  File "/lab/gates.py", line 113, in <module>
    print(f"{n:>2} bits: {len(c.gates):>4} NAND, 0+1 settles in {t_easy:>3} steps, 111..1+1 settles in {t_worst:>3} steps")
                                                                                                       ^^^^^^^^^^^^
TypeError: unsupported format string passed to NoneType.__format__

الجامع ذو 32 بتاً يحتاج 128 خطوة في أسوأ حالة كما رأينا بعد الإصلاح، فتوقف settle عند 100 وأعاد None، أي «لم تستقر»، ثم فشلت طباعة الرقم. والدرس هنا أعمق من الخطأ نفسه: المحاكي لا يستطيع بالنظر إلى خطوة واحدة أن يميز بين دائرة ما زالت تحسب ودائرة تتذبذب إلى الأبد، والحد الثابت يخلط بينهما. والحل أن نجعل الحد يعتمد على الدائرة نفسها، فالدائرة التي ليس فيها حلقة تستقر بعد عدد من الخطوات لا يتجاوز عدد بواباتها، لأن أطول طريق فيها لا يمر في نفس البوابة مرتين، ولذلك أصبح الحد الافتراضي len(self.gates) + 1، وبقي حد صغير صريح، 20 خطوة، لاختبار القفل الذي نعرف أنه قد يتذبذب. وهذه نفس المشكلة التي تواجهها أدوات تصميم الدوائر الحقيقية، فهي تحسب زمن الدوائر التركيبية من أطول طريق، وتتعامل مع الحلقات بشكل منفصل عبر القلابات والساعة.

ماذا يحدث إذا بنينا الجامع بتسع بوابات بدل خمس عشرة؟

الجامع الكامل الذي بنيناه من نصفي جامع وOR يحسب NAND(A, B) مرتين: مرة داخل XOR ومرة داخل AND، فإذا شاركنا هذه البوابة بينهما ورتبنا المنقول بطريقة De Morgan نحصل على الدالة full_adder9، تسع بوابات فقط. والنتيجة في مخرج التجربة:

  • العدد: الجامع لأربعة بتات أصبح 36 NAND بدل 60، أي أقل بنسبة 40%.
  • الزمن: أسوأ حالة لأربعة بتات أصبحت 9 خطوات بدل 16، ولأربعة وستين بتاً 129 بدل 256، لأن المنقول الآن يمر في بوابتين فقط في كل بت بدل أربع.
  • الصحة: النسخة الجديدة تمر بنفس اختبار الأزواج لأنها تحسب نفس الدالة، والذي تغير هو شكل الدائرة فقط.

أي أن تصميماً أذكى للدالة نفسها وفر في المساحة والزمن معاً، ولكن الزمن ما زال يزيد بخط مستقيم مع عدد البتات، فالتحسين لم يغير طبيعة المشكلة، وإنما قلل ثمن كل خطوة فيها، وتغيير طبيعتها يحتاج تصميماً مختلفاً مثل Carry-Lookahead.

والتجربة الثانية في نفس المخرج هي القفل عندما نرفع المدخلين معاً، وقد رأينا أنه يتذبذب 00 11 00 11 في المحاكي، لأن البوابتين متطابقتان تماماً، وهذا التطابق لا يوجد في العتاد الحقيقي، فالنتيجة هناك قيمة عشوائية بعد زمن غير معروف، وهي حالة عدم الاستقرار Metastability التي تكلمنا عنها.

أخطاء شائعة Common Mistakes في فهم البوابات المنطقية

  1. «البوابة تعطي الناتج فوراً»: لكل بوابة زمن انتشار، والدائرة كلها بسرعة أبطأ طريق فيها، وهذا ما يحدد التردد الأقصى للمعالج.
  2. الحكم على سرعة دائرة من حالة سهلة: رأينا أن 0 + 1 يستقر في 4 خطوات دائماً، بينما أسوأ حالة 256 خطوة، والدائرة يجب أن تنتظر الأسوأ.
  3. الخلط بين XOR وOR: OR تعطي 1 عندما يكون المدخلان 1، وXOR تعطي 0، وهذا هو الفرق بين «أو» في الكلام اليومي و«أحدهما فقط».
  4. الخلط بين && و& في الكود: الأولى على قيمتين منطقيتين وتتوقف عند أول قيمة خاطئة، والثانية على كل بت من الرقمين معاً كما تفعل 64 بوابة AND بجانب بعضها.
  5. «المعالج يفرق بين الأرقام الموجبة والسالبة عند الجمع»: نفس الجامع يجمع النوعين، والفرق في كيف يقرأ برنامجك البتات، ولذلك يظهر الفيضان بصمت في الكود.
  6. تحديد حد ثابت لتكرار محاكاة أو انتظار دون التفكير في حجم المسألة: الحد 100 كان مناسباً لأربعة بتات وخاطئاً لاثنين وثلاثين.
  7. اعتبار القفل والقلاب شيئاً واحداً: القفل شفاف طوال فترة التمكين، والقلاب يتغير عند الحافة فقط، والفرق يحدد هل تعمل الحلقات في الدائرة بشكل صحيح.
  8. رفع تردد المعالج دون حساب: عندما تقصر النبضة عن أطول طريق في الدائرة لا يصبح المعالج أبطأ، وإنما يعطي نتائج خاطئة.

جدول القرار Decision Table: أي دائرة تحل أي مشكلة؟

الجدول التالي يجمع ما سبق في صورة يسهل الرجوع إليها، من المشكلة إلى الدائرة إلى ما تقابله في البرمجة:

المشكلةالدائرةالنوعما يقابلها في الكود
عكس بتNOTتركيبي Combinational!x، ~x
استخراج بتات أو التحقق من شرطينANDتركيبيx & mask، a && b
رفع أعلام FlagsORتركيبيflags | READ
مقارنة بتين أو مجموع بدون حملXORتركيبيa ^ b
جمع رقمينجامع Adder من جوامع كاملةتركيبيa + b
حفظ بت حتى إشعار آخرالقفل SR Latchتتابعي Sequentialمتغير منطقي يحفظ حالة
حفظ قيمة في لحظة محددةالقلاب D Flip-Flopتتابعيإسناد reg = value في نهاية كل خطوة
حفظ رقم كاملمسجل Register من قلاباتتتابعيمتغير في مسجل المعالج
العد مع كل نبضةمسجل + جامع يضيف 1تتابعيpc = pc + 1

تدريب عملي

  • أضف إلى الكلاس Circuit دالة nor مبنية من NAND فقط، واطبع جدول حقيقتها بالدالة gate_table، وتوقع قبل التشغيل كم NAND سوف تحتاج.
  • ابن دالة subtract(x, y) تستخدم ripple_adder نفسه، بعكس بتات y بالبوابة NOT ووضع cin=1، وتحقق من كل الأزواج كما فعل السكربت مع الجمع.
  • في قسم latch غير limit=20 إلى limit=21 ولاحظ هل يتغير الناتج، والسبب أن التذبذب دوري وليس بطيئاً.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • الحاسب يعمل بحالتين لأن المسافة الكبيرة بينهما تتحمل التشويش، وجبر Boole ورسالة Shannon جعلا تصميم الدوائر مسألة منطق.
  • الترانزستور مفتاح بلا أجزاء متحركة، وبوابة NAND بتقنية CMOS أربعة ترانزستورات لا يمر فيها تيار ثابت، ومن NAND وحدها يمكن بناء أي دائرة.
  • XOR وAND يجمعان بتين، ونصفا جامع وOR يجمعان ثلاثة، وأربعة جوامع كاملة في سلسلة تجمع رقمين من أربعة بتات، ونفس الدائرة تجمع الموجب والسالب بالمتمم الثنائي.
  • لكل بوابة زمن، وأطول طريق يحدد سرعة الدائرة، والمنقول في الجامع المتسلسل يجعل الزمن يزيد مع عدد البتات، وتصميم أذكى قلل الزمن إلى النصف تقريباً في التجربة.
  • الحلقة من مخرج إلى مدخل تصنع الذاكرة: القفل يحفظ بتاً، والقلاب يحفظه عند حافة الساعة فقط، والمسجلات والعدادات قلابات بجانب بعضها.

وفي نفس المرحلة نكمل بإذن الله إلى المعالج الذي يجمع كل هذه الدوائر، ثم إلى الذاكرة:

دليلكيف يعمل المعالج CPU من الداخل ولماذا يهمك ذلكنتابع تعليمة واحدة داخل نواة المعالج، ونرى لماذا يصبح نفس الكود أبطأ بعشر مرات عندما تغير ترتيب المرور على البيانات.دليلكيف تعمل الذاكرة RAM من الداخل، ولماذا تحتاج التحديث Refreshنفتح وحدة الذاكرة من الشريحة إلى الخلية، لتفهم ما تعنيه الأرقام المكتوبة عليها عندما تشتري جهازاً أو تختار سيرفراً.دليلمكونات الحاسب Computer Components: كيف تعمل معاً؟نفتح جهازاً حقيقياً ونتابع المعالج والذاكرة والأقراص واللوحة الأم وهي تعمل معاً، ثم نقيس كم ينتظر برنامجك ليحصل على البيانات من كل جزء.دليلالأنظمة العددية للمبرمجين: من Binary إلى Floating Pointلماذا يظهر القرص 465 بدل 500، ويصبح العداد سالباً فجأة، ولا يساوي 0.1 + 0.2 القيمة 0.3؟ الإجابة في طريقة تخزين الأرقام.دليلFloating Point من الداخل: كيف يخزن الحاسب الكسوركيف يحفظ معيار IEEE 754 الكسور في البتات، ولماذا تكبر الفجوة بين الأرقام وتضيع خانات المعرفات، ومتى تحتاج النوع العشري.دليلماذا يحدث عندما تشغل برنامج Hello World؟نتتبع برنامج C صغيراً من المحرر إلى الشاشة، فترى كل طبقة يمر بها في الجهاز حتى تظهر الجملة أمامك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بمحاكي بوابات من NAND فقط بلغة Python 3.13 (Docker Image python:3.13.17-slim): جداول الحقيقة، والجامع الكامل، وجامع أربعة بتات مع اختبار كل الأزواج، وزمن الانتشار من 4 إلى 64 بتاً، والقفل SR والقلاب D، مع صور حقيقية من Wikimedia Commons ورسوم من عرب رووت.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.