- PVSM.RU - https://www.pvsm.ru -
Instructions, registers, and assembler directives are always in UPPER CASE to remind you that assembly programming is a fraught endeavor
golang.org/doc/asm [1]
На Хабре да и в Интернете в целом есть довольно много информации про использование языков ассемблера для всевозможных архитектур. Пролистав доступные материалы, я обнаружил, что чаще всего освещаемые в них области использования ассемблера и родственных технологий следующие:
И конечно же, в каждой из этих областей существуют специфические требования, а значит свои понятия об инструментах и «свой» ассемблер. Эмбедщики смотрят в код через редактор и дебаггер, реверс-инженеры видят его в декомпиляторах вроде IDA и radare2 и отладчиках ICE, а HPC-спецы — через профилировщики, такие как Intel® VTune™ Amplifier, xperf
или perf
.
И захотелось мне рассказать об ещё одной области программирования, в которой ассемблеры частые спутники. А именно — об их роли при разработке программных моделей вычислительных систем, в простонародье именуемых симуляторами.
Задачи этой и последующих статей следующие.
Задача программной модели вычислительного устройства, такого как центральный процессор, состоит в правильной имитации работы каждой машинной инструкции, встречающейся в процессе работы компьютера.
Программист, работающий над симулятором, сталкивается с необходимостью использовать ассемблер как минимум три раза: при разборе машинного кода инструкций, при написании кода, моделирующего их поведение, а также при отладке своей модели.
Первое, что необходимо сделать с машинной инструкцией после извлечения её из памяти — это узнать её функцию, а также какими аргументами она оперирует.
Декодирование (в симуляции) — перевод машинного слова, считанного из памяти программы, во внутреннее представление симулятора, облегчающее последующее моделирование. В процессе декодирования из потока в общем-то безликих нулей и единиц вычленяются битовые поля, описанные в спецификации, их значения сравниваются с допустимыми, значения некоторых полей комбинируются в одно целое. В целом, повышается уровень абстракции доступной информации об инструкции: вместо смещения от текущей инструкции — абсолютный адрес для перехода, вместо обрубков литерального аргумента — уже собранная и правильно расширенная знаком константа, вместо месива префиксов, переопределяющих смысл друг друга и инструкции в целом — точная информация о ширине адреса данных и ширине операндов, и т.д.
Дизассемблирование — перевод информации об инструкции из машинного представления в текстовую строку, удобную для чтения, обработки и запоминания человеком — во мнемонику. В отличие от результатов декодирования, обрабатываемых бездушной машиной и потому обязанных быть однозначными, результат дизассемблирования должен быть понятным людям. При этом даже допускается вносить лёгкую неоднозначность. Например, один и тот же мнемонический опкод «PUSH» для архитектуры Intel® IA-32 будет использоваться для довольно разрозненной группы машинных команд, часть из которых работает с регистрами общего назначения, часть — с сегментными регистрами, часть — с операндами в памяти, а часть — с литеральными константами. Машинный код и семантика у всех вариантов PUSH очень различны, тогда как мнемоническая запись будет похожей.
Не секрет, что даже синтаксис, используемый для мнемонического представления, может быть разным; об этом подробнее поговорим чуть ниже.
В симуляторе дизассемблирование полезно при реализации встроенного отладчика, который позволяет даже в отсутствие исходного кода приложения разобраться, правильно ли оно работает, и правильно ли модель исполняет инструкции.
(За)кодирование (encoding) — обратное декодированию преобразование из внутреннего представления в машинный код. Умение кодировать инструкции целевой архитектуры — основное и обязательное для программ-ассемблеров, тогда как в симуляторе оно редко требуется. Для симулятора способность к кодогенерации важна, если он «сам себя пишет», т.е. является двоичным транслятором. При этом требуется создавать код не гостевой (симулируемой, целевой), а хозяйской архитектуры. Подробнее об этом — во второй части статьи.
Ассемблирование — перевод инструкции из мнемонической записи в промежуточное представление (или же сразу в машинный код). Это — зона ответственности всевозможных программ-ассемблеров: MASM, TASM, GAS, NASM, YASM, WASM, <вставьте ваш любимый ASM>…
Поскольку при ассемблировании в дело замешаны мнемоники, стоит ожидать неоднозначностей в преобразовании. И действительно, ассемблер вправе выбрать для некоторой мнемоники любой допустимый и удовлетворяющий ей машинный код. Чаще всего он выбирает самый компактный формат. В следующем листинге я с помощью дизассемблера 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, 0x58, 0xc8 # инструкция с двухбайтным VEX, представленная в виде строки байт
.byte 0xc4, 0xe1, 0x74, 0x58, 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
способен генерировать код для очень большого числа систем, и практично уметь понимать именно эту нотацию.
А на практике? А на практике нужно уметь распознавать и читать всё — и нотацию Intel, и нотацию AT&T, и нотацию вашего любимой или нелюбимой программы-ассемблера.
Связь между представлениями машинного кода и преобразующими их процессами проиллюстрирована на следующем рисунке.
Временно пропустим самое интересное — разработку симуляционного ядра; оставим его на десерт. Перейдём сейчас к другой важной задаче при создании программного симулятора, а именно к его тестированию.
Каким образом можно протестировать симулятор процессора? Конечно, можно пытаться запускать и отлаживать сразу софт, скомпилированный для него, в том числе BIOS, ОС и прикладные программы. Однако путь это непродуктивный: отладка будет похожа на ночной кошмар. Стратегически правильнее сначала убедиться, что отдельные инструкции симулируются правильно. То есть проверить свой код на юнит-тестах.
Какие операции должны быть в юнит-тесте на машинную инструкцию?
Каждый такой тест будет проверять один аспект работы инструкции. Для наиболее полной проверки потребуется множество таких тестов: часть из них будет проверять «нормальную» работу, другая — ситуации, в которых должно генерироваться исключения (и они должны проверять, что исключение действительно произошло), а третьи — «краевые случаи» в работе инструкции, которые вроде бы не должны возникать в нормальных программах, но на практике по закону Мёрфи будут «стрелять» постоянно.
Тогда как обыкновенные приложения запускаются под управлением той или иной операционной системы, для юнит-тестов она не нужна. Более того, вредна: на загрузку ОС требуется время, затем, она ограждает процессы от доступа к системным ресурсам, планировщик задач норовит запустить что-то своё и т.д. И вообще ОС будет считать себя хозяйкой системы. А ведь это мы тут симулятором командуем!
На помощь приходит ассемблер. В большинстве случаев для юнит-теста нам нужно загрузить исходные значения в регистры и память, исполнить исследуемую инструкцию и сравнить изменившиеся значения в регистрах и памяти с эталонными. Это всё вполне формулируется на языке ассемблера; языки более высокого уровня при этом часто менее удобны, так как могут или не иметь средств для выражения требуемой функциональности, или же услужливо «оптимизировать» результирующий код, переставляя и подменяя машинные команды.
Оный исходный файл ассемблера с тестом затем следует оттранслировать в ELF или даже «сырой» образ памяти, загрузить в симулятор, выставить указатель инструкций на первую, определиться с тем, что является условием окончания теста (предопределённое число исполненных команд, «волшебная» инструкция, достижение точка отладки, попытка доступа к устройству и т.д.) и что является условием успеха в тесте (установка флага, известное состояние процессора).
Конечно, я немного лукавлю. Минимальное работоспособное окружение для юнит-теста подготовить не всегда просто. Часто требуется наличие включенной виртуальной памяти, а значит, и настроенных для неё таблиц страниц, прав доступа и прочих радостей. Возможность возникновения исключений и прерываний требует хотя бы минимальной настройки таблиц прерываний (в архитектуре Intel IA-32 — IDT и GDT). А проверка работы для инструкций, связанных с виртуализацией, без помощи ОС означает ручную настройку структур виртуальной машины (в случае с Intel IA-32 это VMCS).
С другой стороны, единожды созданное окружение можно многократно переиспользовать во всех тестах, а то, как его настраивать, можно подглядеть и в операционных системах. Ну или документацию на процессор прочесть.
На этом на сегодня всё. В следующей статье я покажу место ассемблера при построении ядра симулятора, непосредственно занимающегося моделированием гостевого кода.
Спасибо за внимание!
Автор: Atakua
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/88025
Ссылки в тексте:
[1] golang.org/doc/asm: https://golang.org/doc/asm
[2] Lex/YACC: http://tldp.org/HOWTO/Lex-YACC-HOWTO.html
[3] ANTLR: http://www.antlr.org/
[4] SimGen: https://www.pvsm.ruftp://ftp.sics.se/pub/SICS-reports/Reports/SICS-R--97-03--SE.ps.Z
[5] ISDL: http://citeseerx.ist.psu.edu/viewdoc/download;jsessionid=2446080A04B2DAF6544465AFC3D46787?doi=10.1.1.12.8498&rep=rep1&type=pdf
[6] The New Jersey Machine-Code Toolkit: http://www.eecs.harvard.edu/~nr/pubs/tk-usenix.ps
[7] дискуссию: https://groups.google.com/forum/#!topic/antlr-discussion/THyWEnb0bzM
[8] IEEE 694-1985: https://scribd.com/doc/259322827/
[9] Источник: http://habrahabr.ru/post/254419/
Нажмите здесь для печати.