Deep EVM #2: نموذج الذاكرة — المكدس والذاكرة والتخزين وcalldata
Engineering Team
مواقع البيانات الأربعة
تحتوي EVM على أربعة مواقع مميزة للبيانات، لكل منها خصائص تكلفة ودورة حياة مختلفة:
- المكدس — مؤقت لكل استدعاء. مجاني للعمليات (3 غاز لـ PUSH/DUP/SWAP). عمق أقصى 1024. هذا هو “سجل العمل” الخاص بك.
- الذاكرة — مؤقتة لكل استدعاء. مصفوفة بايت خطية تتوسع عند الطلب. القراءة/الكتابة 3 غاز، لكن التوسيع يكلف بشكل تربيعي.
- التخزين — دائم عبر المعاملات. أزواج مفتاح-قيمة (32 بايت -> 32 بايت). الأغلى: 2100-22100 غاز لكل عملية.
- Calldata — للقراءة فقط، بيانات الإدخال المرسلة مع المعاملة. 16 غاز لكل بايت غير صفري، 4 لكل بايت صفري.
المكدس بالتفصيل
المكدس هو بنية LIFO (آخر ما يدخل أول ما يخرج) بعمق أقصى 1024 عنصر. كل عنصر بعرض 256 بت بالضبط. لا يمكنك دفع قيمة 8 بت — يتم توسيعها دائماً إلى 256 بت بأصفار بادئة.
المكدس خاص بسياق الاستدعاء الحالي. عندما يستدعي العقد A العقد B عبر CALL، يحصل B على مكدس جديد فارغ. عندما يعود B، يُهمل مكدسه ويستمر A بمكدسه الأصلي.
الذاكرة: التوسيع التربيعي
الذاكرة عبارة عن مصفوفة بايت تبدأ فارغة وتتوسع في أجزاء من 32 بايت. يتم تهيئة كل ذاكرة جديدة بأصفار.
تكلفة الذاكرة ليست خطية. صيغة الغاز لتوسيع الذاكرة هي:
تكلفة_الذاكرة(الحجم) = (الحجم² / 512) + (3 × الحجم)
حيث الحجم بوحدات من 32 بايت. هذا يعني أن أول بضعة كيلوبايتات رخيصة، لكن تخصيص ميغابايتات يصبح باهظاً بشكل فلكي.
// أمثلة على تكلفة الذاكرة:
// 32 بايت: 3 غاز
// 1 كيلوبايت: 98 غاز
// 10 كيلوبايت: 9,800 غاز
// 1 ميغابايت: شيء ضخم جداً
مؤشر الذاكرة الحرة
يحتفظ Solidity بـ “مؤشر الذاكرة الحرة” في الموضع 0x40. هذا يشير إلى البايت التالي غير المخصص في الذاكرة. يقوم المترجم بتحديثه عند تخصيص الذاكرة.
// ما يفعله Solidity خلف الكواليس:
assembly {
let ptr := mload(0x40) // قراءة مؤشر الذاكرة الحرة
mstore(ptr, value) // كتابة في الذاكرة الحرة
mstore(0x40, add(ptr, 0x20)) // تقدم المؤشر بـ 32 بايت
}
التخزين: أغلى موقع بيانات
التخزين هو خريطة مفتاح-قيمة حيث كل من المفتاح والقيمة بحجم 32 بايت. إنه المكان الوحيد الذي تستمر فيه البيانات بين المعاملات.
تكاليف الغاز (بعد EIP-2929):
- SLOAD بارد: 2100 غاز (أول قراءة في المعاملة)
- SLOAD دافئ: 100 غاز (قراءات لاحقة)
- SSTORE (0 إلى غير صفري): 22100 غاز (تخصيص فتحة جديدة)
- SSTORE (غير صفري إلى غير صفري): 5000 غاز (تحديث)
- SSTORE (غير صفري إلى 0): 5000 غاز - 4800 استرداد
تخطيط التخزين
iقوم Solidity بتخطيط متغيرات الحالة لفتحات تخزين بشكل حتمي:
contract Example {
uint256 public a; // فتحة 0
uint256 public b; // فتحة 1
mapping(address => uint256) public balances; // فتحة 2 (أساس)
}
للمصفوفات الديناميكية والتعيينات، يتم حساب الفتحة الفعلية باستخدام keccak256:
// فتحة الرصيد لعنوان:
slot = keccak256(abi.encode(address, 2)) // keccak256(مفتاح ++ فتحة_التعيين)
Calldata: بيانات الإدخال
Calldata هي بيانات الإدخال الخام المرسلة مع المعاملة. إنها للقراءة فقط — لا يمكنك الكتابة إلى calldata.
أكواد التشغيل:
- CALLDATALOAD(offset) — تحميل 32 بايت من calldata بدءاً من الإزاحة
- CALLDATASIZE — حجم calldata بالبايت
- CALLDATACOPY(destOffset, offset, length) — نسخ calldata إلى الذاكرة
Calldata أرخص من الذاكرة لتمرير البيانات لأنك تدفع فقط تكلفة الغاز الجوهري (16 غاز/بايت غير صفري) ولا تحتاج لنسخها إلى الذاكرة إلا عند الحاجة.
الأنماط العملية
تخزين مؤقت لقراءات التخزين
أكثر نمط تحسين غاز شيوعاً: قراءة التخزين مرة واحدة وتخزين القيمة مؤقتاً في متغير ذاكرة.
// سيء: قراءتان من التخزين (2100 + 100 = 2200 غاز)
function bad() external view returns (uint256) {
return myValue + myValue;
}
// جيد: قراءة واحدة من التخزين (2100 غاز)
function good() external view returns (uint256) {
uint256 cached = myValue;
return cached + cached;
}
calldata مقابل memory في معاملات الدوال
للمعاملات الخارجية للقراءة فقط، استخدم calldata بدلاً من memory:
// أرخص: يقرأ مباشرة من calldata
function process(uint256[] calldata data) external {
// ...
}
// أغلى: ينسخ calldata إلى الذاكرة
function process(uint256[] memory data) external {
// ...
}
الخلاصة
فهم مواقع البيانات الأربعة لـ EVM هو أساسي لكتابة عقود ذكية فعالة من حيث الغاز. المكدس مجاني لكن محدود بعمق 16. الذاكرة رخيصة لكن تتوسع بشكل تربيعي. التخزين دائم لكن باهظ. Calldata للقراءة فقط لكنها الأرخص لبيانات الإدخال. اختيار الموقع الصحيح يحدد ما إذا كان عقدك يكلف $0.50 أو $50 للتنفيذ.