Розуміння специфікацій EVM у Жовтій книзі
Жовта книга (відкривається в новій вкладці) — це формальна специфікація Етеріуму. За винятком змін, внесених через процес EIP, вона містить точний опис того, як усе працює. Вона написана як математична стаття, що містить термінологію, яка може бути незнайомою для програмістів. У цій статті ви дізнаєтеся, як її читати, а отже, і як читати інші пов'язані математичні статті.
Яка саме Жовта книга?
Як і майже все інше в Етеріумі, Жовта книга з часом еволюціонує. Щоб мати можливість посилатися на конкретну версію, я завантажив поточну версію на момент написання (відкривається в новій вкладці). Номери розділів, сторінок та рівнянь, які я використовую, посилатимуться на цю версію. Рекомендується тримати її відкритою в іншому вікні під час читання цього документа.
Чому EVM?
Оригінальна Жовта книга була написана на самому початку розробки Етеріуму. Вона описує оригінальний механізм консенсусу на основі доказу виконання роботи (PoW), який спочатку використовувався для захисту мережі. Однак у вересні 2022 року Етеріум відмовився від доказу виконання роботи та почав використовувати консенсус на основі доказу частки (PoS). Цей посібник буде зосереджений на частинах Жовтої книги, що визначають віртуальну машину Етеріуму (EVM). EVM залишилася незмінною після переходу на доказ частки (за винятком значення, що повертається опкодом DIFFICULTY).
9 Модель виконання
Цей розділ (с. 14-16) містить більшу частину визначення EVM.
Термін стан системи (system state) включає все, що потрібно знати про систему для її запуску. У типовому комп'ютері це означає пам'ять, вміст регістрів тощо.
Машина Тюрінга (відкривається в новій вкладці) — це обчислювальна модель. По суті, це спрощена версія комп'ютера, яка, як доведено, має таку ж здатність виконувати обчислення, як і звичайний комп'ютер (усе, що може обчислити комп'ютер, може обчислити машина Тюрінга, і навпаки). Ця модель полегшує доведення різних теорем про те, що можна обчислити, а що ні.
Термін повний за Тюрінгом (відкривається в новій вкладці) (Turing-complete) означає комп'ютер, який може виконувати ті ж обчислення, що й машина Тюрінга. Машини Тюрінга можуть потрапляти в нескінченні цикли, а EVM — ні, оскільки в неї закінчиться газ, тому вона є лише квазіповною за Тюрінгом.
9.1 Основи
У цьому розділі наведено основи EVM та її порівняння з іншими обчислювальними моделями.
Стекова машина (відкривається в новій вкладці) — це комп'ютер, який зберігає проміжні дані не в регістрах, а в стеку (відкривається в новій вкладці). Це краща архітектура для віртуальних машин, оскільки її легко реалізувати, що означає значно меншу ймовірність помилок та вразливостей безпеки. Пам'ять у стеку поділена на 256-бітні слова. Це було обрано тому, що це зручно для основних криптографічних операцій Етеріуму, таких як хешування Keccak-256 та обчислення на еліптичній кривій. Максимальний розмір стека становить 1024 елементи (1024 x 256 біт). Коли виконуються опкоди, вони зазвичай отримують свої параметри зі стека. Існують опкоди спеціально для реорганізації елементів у стеку, такі як POP (видаляє елемент з вершини стека), DUP_N (дублює N-й елемент у стеку) тощо.
EVM також має енергозалежний простір, який називається пам'яттю (memory), що використовується для зберігання даних під час виконання. Ця пам'ять організована у 32-байтні слова. Усі комірки пам'яті ініціалізуються нулями. Якщо ви виконаєте цей код Yul (відкривається в новій вкладці), щоб додати слово в пам'ять, він заповнить 32 байти пам'яті, доповнивши порожній простір у слові нулями, тобто він створить одне слово — з нулями в позиціях 0-29, 0x60 у 30 та 0xA7 у 31.
mstore(0, 0x60A7)
mstore — це один із трьох опкодів, які EVM надає для взаємодії з пам'яттю; він завантажує слово в пам'ять. Два інші — це mstore8, який завантажує один байт у пам'ять, та mload, який переміщує слово з пам'яті до стека.
EVM також має окрему енергонезалежну модель сховища (storage), яка підтримується як частина стану системи — ця пам'ять організована у масиви слів (на відміну від байтових масивів з адресацією по словах у стеку). У цьому сховищі контракти зберігають постійні дані — контракт може взаємодіяти лише з власним сховищем. Сховище організоване у вигляді відображень ключ-значення.
Хоча це не згадується в цьому розділі Жовтої книги, також корисно знати, що існує четвертий тип пам'яті. Дані виклику (calldata) — це пам'ять лише для читання з байтовою адресацією, яка використовується для зберігання значення, переданого з параметром data транзакції. EVM має спеціальні опкоди для керування calldata. calldatasize повертає розмір даних. calldataload завантажує дані в стек. calldatacopy копіює дані в пам'ять.
Стандартна архітектура фон Неймана (відкривається в новій вкладці) зберігає код і дані в одній пам'яті. EVM не дотримується цього стандарту з міркувань безпеки — спільне використання енергозалежної пам'яті уможливлює зміну програмного коду. Натомість код зберігається у сховищі.
Існує лише два випадки, коли код виконується з пам'яті:
- Коли контракт створює інший контракт (використовуючи
CREATE(відкривається в новій вкладці) абоCREATE2(відкривається в новій вкладці)), код для конструктора контракту береться з пам'яті. - Під час створення будь-якого контракту виконується код конструктора, а потім повертається код самого контракту, також із пам'яті.
Термін виняткове виконання (exceptional execution) означає виняток, який спричиняє зупинку виконання поточного контракту.
9.2 Огляд комісій
Цей розділ пояснює, як розраховуються комісії за газ. Існує три види витрат:
Вартість опкоду
Власна вартість конкретного опкоду. Щоб отримати це значення, знайдіть групу вартості опкоду в Додатку H (с. 29, під рівнянням (329)) та знайдіть групу вартості в рівнянні (326). Це дасть вам функцію вартості, яка в більшості випадків використовує параметри з Додатка G (с. 28).
Наприклад, опкод CALLDATACOPY (відкривається в новій вкладці) є членом групи Wcopy. Вартість опкоду для цієї групи становить Gverylow+Gcopy×⌈μs[2]÷32⌉. Поглянувши на Додаток G, ми бачимо, що обидві константи дорівнюють 3, що дає нам 3+3×⌈μs[2]÷32⌉.
Нам усе ще потрібно розшифрувати вираз ⌈μs[2]÷32⌉. Зовнішня частина, ⌈ <value> ⌉ — це функція стелі (ceiling function), функція, яка для заданого значення повертає найменше ціле число, що не є меншим за це значення. Наприклад, ⌈2.5⌉ = ⌈3⌉ = 3. Внутрішня частина — це μs[2]÷32. Поглянувши на розділ 3 (Умовності) на с. 3, μ — це стан машини. Стан машини визначено в розділі 9.4.1 на с. 15. Згідно з цим розділом, одним із параметрів стану машини є s для стека. Зібравши все разом, здається, що μs[2] — це позиція №2 у стеку. Поглянувши на опкод (відкривається в новій вкладці), позиція №2 у стеку — це розмір даних у байтах. Поглянувши на інші опкоди в групі Wcopy, CODECOPY (відкривається в новій вкладці) та RETURNDATACOPY (відкривається в новій вкладці), вони також мають розмір даних у тій самій позиції. Отже, ⌈μs[2]÷32⌉ — це кількість 32-байтних слів, необхідних для зберігання даних, що копіюються. Зібравши все разом, власна вартість CALLDATACOPY (відкривається в новій вкладці) становить 3 газу плюс 3 за кожне слово даних, що копіюються.
Вартість виконання
Вартість виконання коду, який ми викликаємо.
- У випадку
CREATE(відкривається в новій вкладці) таCREATE2(відкривається в новій вкладці) — конструктор для нового контракту. - У випадку
CALL(відкривається в новій вкладці),CALLCODE(відкривається в новій вкладці),STATICCALL(відкривається в новій вкладці) абоDELEGATECALL(відкривається в новій вкладці) — контракт, який ми викликаємо.
Вартість розширення пам'яті
Вартість розширення пам'яті (за необхідності).
У рівнянні 326 це значення записано як Cmem(μi')-Cmem(μi). Знову поглянувши на розділ 9.4.1, ми бачимо, що μi — це кількість слів у пам'яті. Отже, μi — це кількість слів у пам'яті до виконання опкоду, а μi' — кількість слів у пам'яті після виконання опкоду.
Функція Cmem визначена в рівнянні 328: Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ — це функція підлоги (floor function), функція, яка для заданого значення повертає найбільше ціле число, що не є більшим за це значення. Наприклад, ⌊2.5⌋ = ⌊2⌋ = 2. Коли a < √512, a2 < 512, і результат функції підлоги дорівнює нулю. Отже, для перших 22 слів (704 байти) вартість зростає лінійно залежно від кількості необхідних слів пам'яті. Після цієї точки ⌊a2 ÷ 512⌋ є додатним. Коли необхідний обсяг пам'яті достатньо великий, вартість газу пропорційна квадрату обсягу пам'яті.
Зверніть увагу, що ці фактори впливають лише на власну вартість газу — вони не враховують ринок комісій або чайові валідаторам, які визначають, скільки кінцевий користувач повинен заплатити — це лише чиста вартість виконання певної операції в EVM.
9.3 Середовище виконання
Середовище виконання — це кортеж I, який містить інформацію, що не є частиною стану блокчейну або EVM.
| Параметр | Опкод для доступу до даних | Код Solidity для доступу до даних |
|---|---|---|
| Ia | ADDRESS (відкривається в новій вкладці) | address(this) |
| Io | ORIGIN (відкривається в новій вкладці) | tx.origin |
| Ip | GASPRICE (відкривається в новій вкладці) | tx.gasprice |
| Id | CALLDATALOAD (відкривається в новій вкладці) тощо | msg.data |
| Is | CALLER (відкривається в новій вкладці) | msg.sender |
| Iv | CALLVALUE (відкривається в новій вкладці) | msg.value |
| Ib | CODECOPY (відкривається в новій вкладці) | address(this).code |
| IH | Поля заголовка блоку, такі як NUMBER (відкривається в новій вкладці) та DIFFICULTY (відкривається в новій вкладці) | block.number, block.difficulty тощо |
| Ie | Глибина стека викликів для викликів між контрактами (включно зі створенням контракту) | |
| Iw | Чи дозволено EVM змінювати стан, чи вона працює статично |
Кілька інших параметрів необхідні для розуміння решти розділу 9:
| Параметр | Визначено в розділі | Значення |
|---|---|---|
| σ | 2 (с. 2, рівняння 1) | Стан блокчейну |
| g | 9.3 (с. 14) | Залишок газу |
| A | 6.1 (с. 9) | Накопичений підстан (зміни, заплановані на момент завершення транзакції) |
| o | 9.3 (с. 14) | Вивід — повернутий результат у разі внутрішньої транзакції (коли один контракт викликає інший) та викликів функцій перегляду (коли ви просто запитуєте інформацію, тому немає потреби чекати на транзакцію) |
9.4 Огляд виконання
Тепер, коли ми маємо всі попередні дані, ми нарешті можемо почати розбиратися, як працює EVM.
Рівняння 146-151 дають нам початкові умови для запуску EVM:
| Символ | Початкове значення | Значення |
|---|---|---|
| μg | g | Залишок газу |
| μpc | 0 | Лічильник команд, адреса наступної інструкції для виконання |
| μm | (0, 0, ...) | Пам'ять, ініціалізована нулями |
| μi | 0 | Найвища використана комірка пам'яті |
| μs | () | Стек, спочатку порожній |
| μo | ∅ | Вивід, порожня множина, доки ми не зупинимося або з даними повернення (RETURN (відкривається в новій вкладці) чи REVERT (відкривається в новій вкладці)), або без них (STOP (відкривається в новій вкладці) чи SELFDESTRUCT (відкривається в новій вкладці)). |
Рівняння 152 говорить нам, що в кожен момент часу під час виконання існують чотири можливі умови, і що з ними робити:
Z(σ,μ,A,I). Z представляє функцію, яка перевіряє, чи створює операція недійсний перехід стану (див. виняткова зупинка). Якщо вона оцінюється як True, новий стан ідентичний старому (за винятком того, що газ спалюється), оскільки зміни не були застосовані.- Якщо виконуваний опкод —
REVERT(відкривається в новій вкладці), новий стан такий самий, як і старий, частина газу втрачається. - Якщо послідовність операцій завершена, про що свідчить
RETURN(відкривається в новій вкладці)), стан оновлюється до нового. - Якщо ми не перебуваємо в жодній із кінцевих умов 1-3, продовжуємо виконання.
9.4.1 Стан машини
Цей розділ детальніше пояснює стан машини. Він визначає, що w — це поточний опкод. Якщо μpc менше за ||Ib||, довжину коду, то цей байт (Ib[μpc]) є опкодом. В іншому випадку опкод визначається як STOP (відкривається в новій вкладці).
Оскільки це стекова машина (відкривається в новій вкладці), нам потрібно відстежувати кількість елементів, вилучених (δ) та доданих (α) кожним опкодом.
9.4.2 Виняткова зупинка
Цей розділ визначає функцію Z, яка вказує, коли відбувається аварійне завершення. Це булева (відкривається в новій вкладці) функція, тому вона використовує ∨ для логічного АБО (відкривається в новій вкладці) та ∧ для логічного І (відкривається в новій вкладці).
Ми маємо виняткову зупинку, якщо будь-яка з цих умов є істинною:
-
μg < C(σ,μ,A,I) Як ми бачили в розділі 9.2, C — це функція, яка визначає вартість газу. Залишилося недостатньо газу для покриття наступного опкоду.
-
δw=∅ Якщо кількість елементів, вилучених для опкоду, не визначена, то сам опкод не визначений.
-
|| μs || < δw Вичерпання стека (stack underflow), недостатньо елементів у стеку для поточного опкоду.
-
w = JUMP ∧ μs[0]∉D(Ib) Опкод —
JUMP(відкривається в новій вкладці), а адреса не єJUMPDEST(відкривається в новій вкладці). Переходи дійсні лише тоді, коли місцем призначення єJUMPDEST(відкривається в новій вкладці). -
w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) Опкод —
JUMPI(відкривається в новій вкладці), умова істинна (не нуль), тому перехід має відбутися, а адреса не єJUMPDEST(відкривається в новій вкладці). Переходи дійсні лише тоді, коли місцем призначення єJUMPDEST(відкривається в новій вкладці). -
w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || Опкод —
RETURNDATACOPY(відкривається в новій вкладці). У цьому опкоді елемент стека μs[1] — це зміщення для читання з буфера даних повернення, а елемент стека μs[2] — довжина даних. Ця умова виникає, коли ви намагаєтеся прочитати за межами буфера даних повернення. Зверніть увагу, що подібної умови немає для даних виклику або для самого коду. Коли ви намагаєтеся прочитати за межами цих буферів, ви просто отримуєте нулі. -
|| μs || - δw + αw > 1024
Переповнення стека. Якщо виконання опкоду призведе до того, що в стеку буде понад 1024 елементи, відбувається переривання.
-
¬Iw ∧ W(w,μ) Чи працюємо ми статично (¬ — це заперечення (відкривається в новій вкладці), а Iw є істинним, коли нам дозволено змінювати стан блокчейну)? Якщо так, і ми намагаємося виконати операцію зміни стану, це неможливо.
Функція W(w,μ) визначена пізніше в рівнянні 159. W(w,μ) є істинною, якщо одна з цих умов є істинною:
-
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Ці опкоди змінюють стан шляхом створення нового контракту, збереження значення або знищення поточного контракту.
-
LOG0≤w ∧ w≤LOG4 Якщо нас викликають статично, ми не можемо створювати записи логів. Усі опкоди логів знаходяться в діапазоні між
LOG0(A0) (відкривається в новій вкладці) таLOG4(A4) (відкривається в новій вкладці). Число після опкоду логу вказує, скільки тем містить запис логу. -
w=CALL ∧ μs[2]≠0 Ви можете викликати інший контракт, коли ви статичні, але якщо ви це зробите, ви не зможете переказати йому ETH.
-
-
w = SSTORE ∧ μg ≤ Gcallstipend Ви не можете виконати
SSTORE(відкривається в новій вкладці), якщо у вас немає більше ніж Gcallstipend (визначено як 2300 у Додатку G) газу.
9.4.3 Дійсність місця призначення переходу
Тут ми формально визначаємо, що таке опкоди JUMPDEST (відкривається в новій вкладці). Ми не можемо просто шукати значення байта 0x5B, оскільки воно може бути всередині PUSH (і, отже, бути даними, а не опкодом).
У рівнянні (162) ми визначаємо функцію N(i,w). Перший параметр, i, — це розташування опкоду. Другий, w, — це сам опкод. Якщо w∈[PUSH1, PUSH32], це означає, що опкод є PUSH (квадратні дужки визначають діапазон, який включає кінцеві точки). У цьому випадку наступний опкод знаходиться за адресою i+2+(w−PUSH1). Для PUSH1 (відкривається в новій вкладці) нам потрібно просунутися на два байти (сам PUSH і однобайтне значення), для PUSH2 (відкривається в новій вкладці) нам потрібно просунутися на три байти, оскільки це двобайтне значення тощо. Усі інші опкоди EVM мають довжину лише один байт, тому в усіх інших випадках N(i,w)=i+1.
Ця функція використовується в рівнянні (161) для визначення DJ(c,i), що є множиною (відкривається в новій вкладці) усіх дійсних місць призначення переходу в коді c, починаючи з розташування опкоду i. Ця функція визначається рекурсивно. Якщо i≥||c||, це означає, що ми знаходимося в кінці коду або після нього. Ми більше не знайдемо жодних місць призначення переходу, тому просто повертаємо порожню множину.
У всіх інших випадках ми розглядаємо решту коду, переходячи до наступного опкоду та отримуючи множину, починаючи з нього. c[i] — це поточний опкод, тому N(i,c[i]) — це розташування наступного опкоду. Отже, DJ(c,N(i,c[i])) — це множина дійсних місць призначення переходу, яка починається з наступного опкоду. Якщо поточний опкод не є JUMPDEST, просто поверніть цю множину. Якщо це JUMPDEST, включіть його до результуючої множини та поверніть її.
9.4.4 Нормальна зупинка
Функція зупинки H може повертати три типи значень.
- Якщо ми не знаходимося в опкоді зупинки, повертається ∅, порожня множина. За домовленістю, це значення інтерпретується як булеве false.
- Якщо ми маємо опкод зупинки, який не створює виводу (або
STOP(відкривається в новій вкладці), абоSELFDESTRUCT(відкривається в новій вкладці)), як значення повернення повертається послідовність байтів нульового розміру. Зверніть увагу, що це дуже відрізняється від порожньої множини. Це значення означає, що EVM дійсно зупинилася, просто немає даних повернення для читання. - Якщо ми маємо опкод зупинки, який створює вивід (або
RETURN(відкривається в новій вкладці), абоREVERT(відкривається в новій вкладці)), повертається послідовність байтів, указана цим опкодом. Ця послідовність береться з пам'яті, значення на вершині стека (μs[0]) є першим байтом, а значення після нього (μs[1]) — довжиною.
H.2 Набір інструкцій
Перш ніж перейти до останнього підрозділу EVM, 9.5, давайте розглянемо самі інструкції. Вони визначені в Додатку H.2, який починається на с. 30. Очікується, що все, що не вказано як таке, що змінюється за допомогою цього конкретного опкоду, залишиться незмінним. Змінні, які змінюються, позначаються як <щось>′.
Наприклад, давайте розглянемо опкод ADD (відкривається в новій вкладці).
| Значення | Мнемоніка | δ | α | Опис |
|---|---|---|---|---|
| 0x01 | ADD | 2 | 1 | Операція додавання. |
| μ′s[0] ≡ μs[0] + μs[1] |
δ — це кількість значень, які ми вилучаємо зі стека. У цьому випадку два, оскільки ми додаємо два верхні значення.
α — це кількість значень, які ми повертаємо назад. У цьому випадку одне, сума.
Отже, нова вершина стека (μ′s[0]) — це сума старої вершини стека (μs[0]) та старого значення під нею (μs[1]).
Замість того, щоб переглядати всі опкоди за допомогою «списку, від якого розбігаються очі», у цій статті пояснюються лише ті опкоди, які вводять щось нове.
| Значення | Мнемоніка | δ | α | Опис |
|---|---|---|---|---|
| 0x20 | KECCAK256 | 2 | 1 | Обчислення хешу Keccak-256. |
| μ′s[0] ≡ KEC(μm[μs[0] . . . (μs[0] + μs[1] − 1)]) | ||||
| μ′i ≡ M(μi,μs[0],μs[1]) |
Це перший опкод, який звертається до пам'яті (у цьому випадку лише для читання). Однак він може вийти за поточні межі пам'яті, тому нам потрібно оновити μi. Ми робимо це за допомогою функції M, визначеної в рівнянні 330 на с. 30.
| Значення | Мнемоніка | δ | α | Опис |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | Отримати баланс заданого акаунта. |
| ... |
Адреса, баланс якої нам потрібно знайти, — це μs[0] mod 2160. Вершина стека — це адреса, але оскільки адреси мають лише 160 біт, ми обчислюємо значення за модулем (відкривається в новій вкладці) 2160.
Якщо σ[μs[0] mod 2160] ≠ ∅, це означає, що є інформація про цю адресу. У цьому випадку σ[μs[0] mod 2160]b — це баланс для цієї адреси. Якщо σ[μs[0] mod 2160] = ∅, це означає, що ця адреса не ініціалізована, а баланс дорівнює нулю. Ви можете побачити список полів інформації про акаунт у розділі 4.1 на с. 4.
Друге рівняння, A'a ≡ Aa ∪ {μs[0] mod 2160}, пов'язане з різницею у вартості між доступом до теплого сховища (сховища, до якого нещодавно зверталися і яке, ймовірно, кешується) та холодного сховища (сховища, до якого не зверталися і яке, ймовірно, знаходиться в повільнішому сховищі, звідки його дорожче отримати). Aa — це список адрес, до яких транзакція зверталася раніше, і доступ до яких, отже, має бути дешевшим, як визначено в розділі 6.1 на с. 9. Ви можете дізнатися більше про цю тему в EIP-2929 (відкривається в новій вкладці).
| Значення | Мнемоніка | δ | α | Опис |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | Дублювати 16-й елемент стека. |
| μ′s[0] ≡ μs[15] |
Зверніть увагу, що для використання будь-якого елемента стека нам потрібно його вилучити, що означає, що нам також потрібно вилучити всі елементи стека над ним. У випадку DUP<n> (відкривається в новій вкладці) та SWAP<n> (відкривається в новій вкладці) це означає необхідність вилучити, а потім повернути до шістнадцяти значень.
9.5 Цикл виконання
Тепер, коли ми маємо всі складові, ми нарешті можемо зрозуміти, як задокументовано цикл виконання EVM.
Рівняння (164) стверджує, що за заданого стану:
- σ (глобальний стан блокчейну)
- μ (стан EVM)
- A (підстан, зміни, які мають відбутися після завершення транзакції)
- I (середовище виконання)
Новий стан — це (σ', μ', A', I').
Рівняння (165)-(167) визначають стек і його зміну через опкод (μs). Рівняння (168) — це зміна газу (μg). Рівняння (169) — це зміна лічильника команд (μpc). Нарешті, рівняння (170)-(173) вказують, що інші параметри залишаються незмінними, якщо вони не змінені опкодом явно.
Завдяки цьому EVM повністю визначена.
Висновок
Математична нотація є точною і дозволила Жовтій книзі визначити кожну деталь Етеріуму. Однак вона має деякі недоліки:
- Її можуть зрозуміти лише люди, що означає, що тести на відповідність (відкривається в новій вкладці) потрібно писати вручну.
- Програмісти розуміють комп'ютерний код. Вони можуть розуміти або не розуміти математичну нотацію.
Можливо, з цих причин новіші специфікації рівня консенсусу (відкривається в новій вкладці) написані на Python. Існують специфікації рівня виконання на Python (відкривається в новій вкладці), але вони не є повними. Доки вся Жовта книга також не буде перекладена на Python або подібну мову, Жовта книга продовжуватиме використовуватися, і вміння її читати є корисним.