Ассемблер для задач симуляции. Часть 1: гостевой ассемблер
Instructions, registers, and assembler directives are always in UPPER CASE to remind you that assembly programming is a fraught endeavorgolang.org/doc/asm
На Хабре да и в Интернете в целом есть довольно много информации про использование языков ассемблера для всевозможных архитектур. Пролистав доступные материалы, я обнаружил, что чаще всего освещаемые в них области использования ассемблера и родственных технологий следующие: Встраиваемые (embedded) системы. Декомпиляция, обратная разработка (reverse engineering), компьютерная безопасность. Высокопроизводительные вычисления (HPC) и оптимизация программ. И конечно же, в каждой из этих областей существуют специфические требования, а значит свои понятия об инструментах и «свой» ассемблер. Эмбедщики смотрят в код через редактор и дебаггер, реверс-инженеры видят его в декомпиляторах вроде IDA и radare2 и отладчиках ICE, а HPC-спецы — через профилировщики, такие как Intel® VTune™ Amplifier, xperf или perf.И захотелось мне рассказать об ещё одной области программирования, в которой ассемблеры частые спутники. А именно — об их роли при разработке программных моделей вычислительных систем, в простонародье именуемых симуляторами.Задачи этой и последующих статей следующие.Показать ещё одну грань использования языка ассемблера и программирования в машинных кодах. Проиллюстрировать все три способа использования ассемблера в современных программах: через интринсики, через ассемблерные вставки и через отдельные файлы. Раззадорить самого себя на обновления собственных записок о том, как можно писать симуляторы центральных систем. Задача программной модели вычислительного устройства, такого как центральный процессор, состоит в правильной имитации работы каждой машинной инструкции, встречающейся в процессе работы компьютера.Программист, работающий над симулятором, сталкивается с необходимостью использовать ассемблер как минимум три раза: при разборе машинного кода инструкций, при написании кода, моделирующего их поведение, а также при отладке своей модели.Туда и обратно: декодирование Первое, что необходимо сделать с машинной инструкцией после извлечения её из памяти — это узнать её функцию, а также какими аргументами она оперирует.Декодирование (в симуляции) — перевод машинного слова, считанного из памяти программы, во внутреннее представление симулятора, облегчающее последующее моделирование. В процессе декодирования из потока в общем-то безликих нулей и единиц вычленяются битовые поля, описанные в спецификации, их значения сравниваются с допустимыми, значения некоторых полей комбинируются в одно целое. В целом, повышается уровень абстракции доступной информации об инструкции: вместо смещения от текущей инструкции — абсолютный адрес для перехода, вместо обрубков литерального аргумента — уже собранная и правильно расширенная знаком константа, вместо месива префиксов, переопределяющих смысл друг друга и инструкции в целом — точная информация о ширине адреса данных и ширине операндов, и т.д. О генерации декодеров по описанию Задача (софтварного) декодирования машинных инструкций по структуре напоминает парсинг строк при разборе языков высокого уровня (то, что выполняется во фронтэндах компиляторов). И там и там на входе язык с известной грамматикой, а на выходе — промежуточное представление, соответствующее разобранной фразе. В обоих случаях грамматики часто сложны для того, чтобы реализовывать разбор для них вручную, поэтому используются генераторы кода из описаний на DSL (domain specific language).Для языков высокого уровня довольно давно разработаны и успешно используются генераторы парсеров: лексеры в связке со сканерами. Тут и Lex/YACC, и ANTLR и множество других инструментов для всевозможных целевых языков.Для машинных языков тоже существуют генераторы декодеров: SimGen, ISDL, The New Jersey Machine-Code Toolkit.Что меня удивило, так это отсутствие проектов по использованию классических генераторов парсеров для описания грамматики машинных языков. Всегда используется что-то своё, самодельно-велосипедное, хоть и эффективное. Машинные языки не являются слишком простыми, чтобы задействование Yacc оказалось «стрельбой из пушки по воробьям». Вряд ли они слишком сложны для того, чтобы выразительности ANTLR оказалось недостаточно.Вопрос меня так заинтересовал, что я даже начал дискуссию на форуме ANTLR, но внятного ответа там не получил. Дизассемблирование — перевод информации об инструкции из машинного представления в текстовую строку, удобную для чтения, обработки и запоминания человеком — во мнемонику. В отличие от результатов декодирования, обрабатываемых бездушной машиной и потому обязанных быть однозначными, результат дизассемблирования должен быть понятным людям. При этом даже допускается вносить лёгкую неоднозначность. Например, один и тот же мнемонический опкод «PUSH» для архитектуры Intel® IA-32 будет использоваться для довольно разрозненной группы машинных команд, часть из которых работает с регистрами общего назначения, часть — с сегментными регистрами, часть — с операндами в памяти, а часть — с литеральными константами. Машинный код и семантика у всех вариантов PUSH очень различны, тогда как мнемоническая запись будет похожей.Не секрет, что даже синтаксис, используемый для мнемонического представления, может быть разным; об этом подробнее поговорим чуть ниже.В симуляторе дизассемблирование полезно при реализации встроенного отладчика, который позволяет даже в отсутствие исходного кода приложения разобраться, правильно ли оно работает, и правильно ли модель исполняет инструкции.(За)кодирование (encoding) — обратное декодированию преобразование из внутреннего представления в машинный код. Умение кодировать инструкции целевой архитектуры — основное и обязательное для программ-ассемблеров, тогда как в симуляторе оно редко требуется. Для симулятора способность к кодогенерации важна, если он «сам себя пишет», т.е. является двоичным транслятором. При этом требуется создавать код не гостевой (симулируемой, целевой), а хозяйской архитектуры. Подробнее об этом — во второй части статьи.Ассемблирование — перевод инструкции из мнемонической записи в промежуточное представление (или же сразу в машинный код). Это — зона ответственности всевозможных программ-ассемблеров: MASM, TASM, GAS, NASM, YASM, WASM, …Поскольку при ассемблировании в дело замешаны мнемоники, стоит ожидать неоднозначностей в преобразовании. И действительно, ассемблер вправе выбрать для некоторой мнемоники любой допустимый и удовлетворяющий ей машинный код. Чаще всего он выбирает самый компактный формат. В следующем листинге я с помощью дизассемблера objdump иллюстрирую, во что преобразуется векторная инструкция VADDPS с различными аргументами, поданная на вход ассемблеру GNU as: $ cat vaddps1.s # исходный код, меняется только первый операнд-регистр vaddps %ymm0, %ymm1, %ymm1 vaddps %ymm1, %ymm1, %ymm1 vaddps %ymm2, %ymm1, %ymm1 vaddps %ymm3, %ymm1, %ymm1 vaddps %ymm4, %ymm1, %ymm1 vaddps %ymm5, %ymm1, %ymm1 vaddps %ymm6, %ymm1, %ymm1 vaddps %ymm7, %ymm1, %ymm1
vaddps %ymm8, %ymm1, %ymm1 vaddps %ymm9, %ymm1, %ymm1 vaddps %ymm10, %ymm1, %ymm1 vaddps %ymm11, %ymm1, %ymm1 vaddps %ymm12, %ymm1, %ymm1 vaddps %ymm13, %ymm1, %ymm1 vaddps %ymm14, %ymm1, %ymm1 vaddps %ymm15, %ymm1, %ymm1
$ as vaddps1.s # ассемблирую $ objdump -d a.out # дизассемблирую
a.out: file format pe-x86–64
Disassembly of section .text:
0000000000000000 <.text>: 0: c5 f4 58 c8 vaddps %ymm0,%ymm1,%ymm1 # Двухбайтный VEX 4: c5 f4 58 c9 vaddps %ymm1,%ymm1,%ymm1 8: c5 f4 58 ca vaddps %ymm2,%ymm1,%ymm1 c: c5 f4 58 cb vaddps %ymm3,%ymm1,%ymm1 10: c5 f4 58 cc vaddps %ymm4,%ymm1,%ymm1 14: c5 f4 58 cd vaddps %ymm5,%ymm1,%ymm1 18: c5 f4 58 ce vaddps %ymm6,%ymm1,%ymm1 1c: c5 f4 58 cf vaddps %ymm7,%ymm1,%ymm1 20: c4 c1 74 58 c8 vaddps %ymm8,%ymm1,%ymm1 # Трёхбайтный VEX 25: c4 c1 74 58 c9 vaddps %ymm9,%ymm1,%ymm1 2a: c4 c1 74 58 ca vaddps %ymm10,%ymm1,%ymm1 2f: c4 c1 74 58 cb vaddps %ymm11,%ymm1,%ymm1 34: c4 c1 74 58 cc vaddps %ymm12,%ymm1,%ymm1 39: c4 c1 74 58 cd vaddps %ymm13,%ymm1,%ymm1 3e: c4 c1 74 58 ce vaddps %ymm14,%ymm1,%ymm1 43: c4 c1 74 58 cf vaddps %ymm15,%ymm1,%ymm1 В этом примере я менял один из source-регистров, перебирая все его варианты, от YMM0 до YMM15. Инструкции с первыми восемью регистрами YMM0-YMM7 могли быть закодированы с помощью более короткого двухбайтового префикса VEX, и GAS выбрал именно этот формат. Тогда как для диапазона YMM8-YMM15 инструкции могли быть представлены только с помощью трёхбайтового VEX, и потому получились на байт длиннее. В принципе, ничто не мешало использовать во всех случаях трёхбайтный VEX, но нет: $ cat vaddps2.s .byte 0xc5, 0xf4, 0×58, 0xc8 # инструкция с двухбайтным VEX, представленная в виде строки байт .byte 0xc4, 0xe1, 0×74, 0×58, 0xc8 # инструкция с трёхбайтным VEX
$ as vaddps2.s $ objdump.exe -d a.out
a.out: file format pe-x86–64
Disassembly of section .text:
0000000000000000 <.text>: 0: c5 f4 58 c8 vaddps %ymm0,%ymm1,%ymm1 4: c4 e1 74 58 c8 vaddps %ymm0,%ymm1,%ymm1 # Та же мнемоника, но другой машинный код В этом примере я показываю, что один и тот же мнемонический VADDPS с первым регистром YMM0 может быть представлен как минимум двумя последовательностями машинного кода.И таких трюков, которые проделывают ассемблеры разных архитектур, много. Например, многие RISC архитектуры не имеют машинного представления для операции «скопировать регистр X в регистр Y», поэтому ассемблер преобразует мнемонику mov r1, r2 в код, соответствующий add 0, r1, r2, т.е. «сложить r1 с нулём и результат поместить в r2». Другой пример: ассемблер для архитектуры IA-64 (Intel® Itanium) должен упаковать несколько инструкций в один 128-битный машинный бандл. Однако не все инструкции свободно сочетаются друг с другом: нельзя взять и поместить их вместе из-за конфликта по вычислительным ресурсам, которые они потребляют. Приходится ассемблеру или сигнализировать об ошибке, или пытаться раскидать инструкции по разным бандлам. Второй подход требует от ассемблера знаний о числе и организации исполнительных узлов внутри VLIW-процессора; это больше похоже на работу, выполняемую компилятором.Уже упомянутое выше неудобство при использовании ассемблеров — это то, что вариантов мнемонических записей инструкций существует безобразно много (я оставляю в стороне различия ассемблеров, не связанные с архитектурой, такие как возможности макропроцессирования, поддерживаемые выходные форматы ELF, PE и прочий «сахар»). Сколько инструментов, столько и форматов. Даже в пределах одной целевой архитектуры различаться в записи может почти всё: именование опкодов, именование регистров, порядок операндов, способ записи адресов. Что уж говорить про разные архитектуры! Я ещё раз хочу подчеркнуть, что недоопределённость мнемонической записи отличает дизассемблирование от декодирования и делает первое непригодным для задач промежуточного представления.С одной стороны, «правильным» можно считать синтаксис, используемый исконным вендором аппаратуры, ассемблер для которой хочется использовать. Как написано в документации к процессору, так ассемблер и должен выглядеть.С другой стороны, де-факто для многих архитектур общей записью является ассемблер в т.н. AT&T нотации, принятый по умолчанию в инструментарии GNU binutils. Его я использую в этой статье, даже для примеров архитектур Intel. Как-то привык к нему больше. GNU as способен генерировать код для очень большого числа систем, и практично уметь понимать именно эту нотацию.
Существует ли стандарт на язык ассемблера? Хорошие новости: оказывается, стандарт есть — IEEE 694–1985 — IEEE Standard for Microprocessor Assembly Language. Плохие новости: он оказался никому не нужен, и находится в статусе «отозван». Не получилось увязать в одну книжку всё многообразие форматов для CISC, RISC, VLIW, DSP и ещё чёрт знает каких архитектур. А на практике? А на практике нужно уметь распознавать и читать всё — и нотацию Intel, и нотацию AT&T, и нотацию вашего любимой или нелюбимой программы-ассемблера.Связь между представлениями машинного кода и преобразующими их процессами проиллюстрирована на следующем рисунке.Временно пропустим самое интересное — разработку симуляционного ядра; оставим его на десерт. Перейдём сейчас к другой важной задаче при создании программного симулятора, а именно к его тестированию.Тестирование Каким образом можно протестировать симулятор процессора? Конечно, можно пытаться запускать и отлаживать сразу софт, скомпилированный для него, в том числе BIOS, ОС и прикладные программы. Однако путь это непродуктивный: отладка будет похожа на ночной кошмар. Стратегически правильнее сначала убедиться, что отдельные инструкции симулируются правильно. То есть проверить свой код на юнит-тестах.Какие операции должны быть в юнит-тесте на машинную инструкцию? Установить регистры и память устройств в известное входное состояние. Записать в память машинный код инструкции и установить указатель инструкций (регистр PC, IP, RIP, IC) на её начало. Дать команду симулятору на исполнение одной инструкции. Считать конечное состояние регистров и памяти. Сравнить состояние с ожидаемым. Если есть различия, то искать ошибку или в тесте, или в реализации симулирующей процедуры. Каждый такой тест будет проверять один аспект работы инструкции. Для наиболее полной проверки потребуется множество таких тестов: часть из них будет проверять «нормальную» работу, другая — ситуации, в которых должно генерироваться исключения (и они должны проверять, что исключение действительно произошло), а третьи — «краевые случаи» в работе инструкции, которые вроде бы не должны возникать в нормальных программах, но на практике по закону Мёрфи будут «стрелять» постоянно.Тогда как обыкновенные приложения запускаются под управлением той или иной операционной системы, для юнит-тестов она не нужна. Более того, вредна: на загрузку ОС требуется время, затем, она ограждает процессы от доступа к системным ресурсам, планировщик задач норовит запустить что-то своё и т.д. И вообще ОС будет считать себя хозяйкой системы. А ведь это мы тут симулятором командуем! На помощь приходит ассемблер. В большинстве случаев для юнит-теста нам нужно загрузить исходные значения в регистры и память, исполнить исследуемую инструкцию и сравнить изменившиеся значения в регистрах и памяти с эталонными. Это всё вполне формулируется на языке ассемблера; языки более высокого уровня при этом часто менее удобны, так как могут или не иметь средств для выражения требуемой функциональности, или же услужливо «оптимизировать» результирующий код, переставляя и подменяя машинные команды.Оный исходный файл ассемблера с тестом затем следует оттранслировать в ELF или даже «сырой» образ памяти, загрузить в симулятор, выставить указатель инструкций на первую, определиться с тем, что является условием окончания теста (предопределённое число исполненных команд, «волшебная» инструкция, достижение точка отладки, попытка доступа к устройству и т.д.) и что является условием успеха в тесте (установка флага, известное состояние процессора).Конечно, я немного лукавлю. Минимальное работоспособное окружение для юнит-теста подготовить не всегда просто. Часто требуется наличие включенной виртуальной памяти, а значит, и настроенных для неё таблиц страниц, прав доступа и прочих радостей. Возможность возникновения исключений и прерываний требует хотя бы минимальной настройки таблиц прерываний (в архитектуре Intel IA-32 — IDT и GDT). А проверка работы для инструкций, связанных с виртуализацией, без помощи ОС означает ручную настройку структур виртуальной машины (в случае с Intel IA-32 это VMCS).С другой стороны, единожды созданное окружение можно многократно переиспользовать во всех тестах, а то, как его настраивать, можно подглядеть и в операционных системах. Ну или документацию на процессор прочесть.Продолжение следует На этом на сегодня всё. В следующей статье я покажу место ассемблера при построении ядра симулятора, непосредственно занимающегося моделированием гостевого кода.Спасибо за внимание!