Асемблер для задач симуляції

HCF, n. Mnemonic for 'Halt and Catch Fire', any of several undocumented and semi-mythical machine instructions with destructive side-effects Jargon File

З асемблером у серці - ядро ​​симулятора

У серйозного симуляторного продукту має бути багатокамерне «серце»: кілька способів виконання гостьового коду. У будь-який момент часу використовується найефективніший з них. Загалом, виділяють три технології: інтерпретація, двійкова трансляція та пряме виконання. І в кожному з них знайдеться місце для машинного коду та асемблера.

асемблер

Інтерпретатор та інтринсики

Intel C/C++ Compiler Intrinsic Equivalent LZCNT: unsigned __int32 _lzcnt_u32(unsigned __int32 src); LZCNT: unsigned __int64 _lzcnt_u64(unsigned __int64 src);

Ці ж інтринсики працюють і в GCC. Нижче я провів невеликий експеримент:

У порівнянні з рукописними секціями inline-ассемблера, інтринсики мають наступні переваги.

  1. Виклик функції набагато звичніший, його легше зрозуміти і менше шанси напортачити в ньому при написанні. Інтринсики переносять роботу з виділення вхідних та вихідних регістрів на компілятор, а також дозволяють йому провести перевірку синтаксису, відповідність типів та інші корисні речі та за необхідності повідомити про проблеми. У разі inline-коду діагностика асемблера буде набагато загадковішою. Той, кому часто доводиться виписувати clobber специфікації для GNU as (і помилятися в них), зі мною погодиться.
  2. Інтринсики не є для компілятора «чорними ящиками» inline-ассемблера, де відбуваються невідомі йому оновлення регістрів і пам'яті. Відповідно його алгоритми розподілу регістрів можуть враховувати це у процесіобробка коду процедури. В результаті легше отримати швидший код.
  3. Інтринсики мають хоч і слабку, але переносимість між компіляторами (але не господарськими архітектурами). У крайньому випадку можна написати прототипом свій варіант реалізації, якщо господарська архітектура не підтримує інструкцію безпосередньо. Приклад із практики: SSE2-інструкція CVTSI2SD xmm, r/m64 не має валідного кодування в 32-бітному режимі процесора. Відповідно немає і інтринсика, тоді як у 64-бітному режимі, для якого спочатку розроблявся якийсь інструмент, він був і код його використовував. При компіляції коду на 32-бітному хазяїні видавалась помилка. Оскільки процедура, зав'язана на цей інтринсик, не була «гарячою» (швидкість роботи програми слабко від неї залежала), була написана своя реалізація _mm_cvtsi64_sd() на Сі, яка підставлялася у разі 32-бітового складання.
З цих чи якихось інших причин компанія Microsoft припинила підтримку inline-ассемблера в MS Visual Studio 2010 та пізніших для архітектури x64. Для вставляння машинного коду у файли з Си/C++ у разі залишаються доступні лише інтринсики. Проте я пішов би проти правди, сказавши, що використання інтринсиків є панацеєю. Все ж таки необхідно доглядати за кодом, що генерується компілятором, особливо коли потрібно вичавити з нього максимум продуктивності.

Двійковий транслятор та кодогенерація

Двійковий транслятор (далі ДТ) зазвичай працює швидше інтерпретатора, тому що перетворює цілі блоки гостьового машинного коду в еквівалентні їм блоки господарського машинного коду, які потім, у разі гарячого коду, багаторазово запускаються. Інтерпретатор же (якщо в ньому не реалізовано кешування) змушений обробляти кожну гостьову інструкцію, що зустрілася, з нуля, навітьякщо він зовсім недавно з нею працював. І, на відміну від інтерпретатора, який можна від початку і до кінця написати, не вникаючи особливо господарської архітектури, ДП вимагатиме знання і асемблера, і кодувань машинних інструкцій. При перенесенні свого симулятора на нову господарську систему істотну частину його, яка відповідає саме за кодогенерацію, доведеться переписати. Такою є ціна швидкості роботи. У цій статті я опишу один із простих способів побудови так званогошаблонного транслятора. Якщо буде інтерес, то якось іншим разом я постараюся розповісти про більш просунутий спосіб двійкової трансляції. Отримавши від декодера інформацію про гостьову інструкцію, ДП генерує для неї шматочок машинного коду -капсулу. Для кількох інструкцій, що виконуються послідовно, створюється блок трансляції, що складається з їх капсул, записаних послідовно. В результаті, коли в гостьовій системі управління передається на першу інструкцію, що транслюється, для симуляції цієї і наступних команд достатньо виконати код з блоку трансляції. Як згенерувати код для гостьової інструкції, знаючи її опкод та значення операндів? По опкоду симулятор вибираєшаблон- заготівлю господарського машинного коду, що реалізує необхідну семантику. Від процедур, які зазвичай створюються компілятором, її відрізняє відсутність прологу та епілогу, оскільки ми безпосередньо «склеюємо» такі шаблони в єдиний блок трансляції. Однак цього ще недостатньо для того, щоб позначити блок трансляції готовий. Залишилося невиконаним ще одне завдання - передати значення операндів як аргументи шаблону, таким чином його спеціалізувавши і перетворивши на капсулу. До того ж передавати операнди найчастіше треба саме на етапі трансляції: вони вже відомі. Тобто треба «зашити» їх прямо вгосподарський код капсули. З неявними операндами (наприклад, значеннями, що лежать на стеку) це не вийде, і їх, звичайно, доведеться обробляти на етапі симуляції, витрачаючи при цьому час. Якщо розмірність множини (= число комбінацій) явних операндів невелика, їх можна «вшити» у групу шаблонів для цієї інструкції — по одному на кожну комбінацію. У результаті кожного гостьового опкода доведеться вибирати з N шаблонів відповідно до того, які значення прийняли операнди у кожному даному випадку. На жаль, не все так просто. Насправді найчастіше генерувати шаблони для всіляких значень операндів неможливо через комбінаторного вибуху їх числа. Так, триперандна команда на архітектурі з 32 регістрами вимагатиме по 32×32×32 = 2?⁵ блоків коду. А якщо гостьова архітектура має операнди-літерали (а всі важливі мають) шириною так в 32 біти, то доведеться зберігати 2? варіантів капсули. Треба щось вигадати. Насправді немає потреби зберігати купу майже однакових шаблонів — усі вони містять одні й самі господарські інструкції. При варіації гостьових операндів у них лише змінюються деякі господарські операнди (але іноді і довжина інструкції, див. мій попередній пост), що описують, де зберігається стан, що моделюється, або який передається літерал. При формуванні капсули із шаблону треба «просто» пропатчити біти або байти за відповідними усуненнями:

задач
Питання знавцям: які архітектури в прикладі вище використовуються як гостьова та хазяйська?

Таким чином, для кожної гостьової інструкції у складі симулятора з ДП достатньо одного шаблону господарського машинного коду та однієї процедури, що виправляє вихідні операнди на правильні. Природно, для коректного патчингу шаблону треба знати усунення всіх операндів щодо його початку, тобторозбиратися в кодуванні команд господарської системи. Фактично треба або реалізовувати свій власний енкодер, або якимось чином навчитися виокремлювати потрібну інформацію із роботи стороннього інструменту. Загалом процес шаблонної трансляції представлений на наступному малюнку.

асемблер

Пряме виконання та віртуалізація

Третій аналізований мною механізм симуляції - пряме виконання. Принцип його роботи безпосередньо випливає з назви - симулювати гостьовий код, без змін запускаючи його на господаря. Вочевидь, що це спосіб потенційно дає найвищу швидкість симуляції; проте він і «найпримхливіший». Необхідне виконання таких вимог.

  1. Архітектура гостя та господаря має збігатися. Іншими словами, не вийде безпосередньо моделювати код для ARM на MIPS і навпаки; принаймні це буде вже не пряме виконання.
  2. Господарська архітектура має задовольняти умови ефективної віртуалізації.

Припустимо, що гостьова архітектура задовольняє зазначені умови, наприклад, це Intel IA-32/Intel 64 з розширеннями Intel VT-x. Наступне завдання, що виникає при додаванні підтримки прямого виконання в симулятор це написання модуля ядра (драйвера) операційної системи. Без нього не обійтися: симулятор необхідно виконувати привілейовані інструкції та маніпулювати системними ресурсами, такими як таблиці сторінок, фізична пам'ять, переривання та інше. З місця користувача до них не дотягнутися. З іншого боку, повністю «окопатися» в ядрі шкідливо: програмування та налагодження драйверів значно витратніше за часом і нервами, ніж написання прикладних програм. Тому в ядро ​​зазвичай виносять тільки мінімум функціональності симулятора, до якого звертаються через інтерфейси.системних дзвінків. Всі відомі мені віртуальні машини і симулятори, що задіють пряме виконання, так і влаштовані: модуль ядра + додаток користувача, що його використовує. Оскільки модуль ядра пишеться до певної ОС, необхідно розуміти, що при перенесенні програми на іншу ОС його доведеться переписувати, можливо досить сильно. Це ще одна причина, щоб мінімізувати його розмір. В принципі, використання асемблера в ядрі виправдано приблизно в таких же умовах, як і в юзерленді, тобто коли без нього не обійтися. Віртуальні машини працюють із системними структурами, такими як VMCS (virtual machine control structure), контрольні, налагоджувальні та модель-специфічні регістри, які доступні лише через спеціалізовані інструкції. Найрозумнішим було б використовувати для них інтринсики, але… Не всі машинні інструкції мають готові інтринсики. У компіляторах, призначених для збирання переважно користувальницького коду, для потреб письменників драйверів якось забувають. Для звернення до них доводиться використовувати вбудований (inline) асемблер. У вихідному коді віртуальної машини KVM, наприклад, є таке визначення функції читання полів VMCS:

Мова асемблера грає ключову роль розробки симуляторних рішень. Він використовується в різних компонентах моделей, а також у процесі їхнього тестування. Сам код на асемблері у складному проекті, який використовує також мови високого рівня, може бути представлений трьома способами.

  1. Інтринсики - обгортки окремих машинних інструкцій з інтерфейсом звичайних функцій C/C++.
  2. Асемблерні вставки — специфічні для вибраного компілятора/ассемблера фрагменти асемблерного коду, узгоджені з навколишнім кодом високого рівня.
  3. Файли, повністю написаніна асемблері - використовувані у тих (рідкісних) випадках, коли зручніше висловити якусь послідовність дій цілком на асемблері. Із зовнішнім світом вони взаємодіють або через інтерфейс функцій (самостійно реалізуючи ABI тієї платформи, для якої вони призначені), або не взаємодіючи (у разі незалежних юніт-тестів).