Я — тот, кто разбирается в битах и байтах! Я всегда говорил, что если бы нашелся кто-то, готовый платить за то, чтобы я целыми днями писал на языке ассемблера, я бы с радостью этим занялся. Мне просто нравится быть ближе к машине и не иметь дела с десятью уровнями абстракции. 

Конечно, ассемблер не портативен, неотлаживаем, нечитаем и так далее, и тому подобное — так что мне пришлось «довольствоваться» карьерой программиста на C с примесью C++ и Python. За редким исключением, возможности писать на языке ассемблера выпадали мне нечасто. 

Пару лет назад все изменилось. Не в смысле возможности писать на языке ассемблера полный рабочий день, но появилось кое-что даже лучше — возможность создавать собственные ассемблерные инструкции! Это стало достижимо благодаря RISC-V и его открытой архитектуре набора команд (ISA), которая поощряет разработку новых расширений и инструкций в рамках ISA. 

Такая практика совсем не похожа на практику гигантских производителей процессоров, которые держат свои наборы инструкций под строгим контролем. С открытой ISA практически любой может создавать свои собственные инструкции, и это очень интересно!

Конечно, суть разработки новых инструкций процессора состоит в том, чтобы в конечном итоге реализовать их в работе настоящего CPU (хотя бы в микрокоде). Внедрение новых инструкций в процессор — дело недешевое. Ни один здравомыслящий человек не станет запускать производство аппаратного процессора, не проведя всестороннего тестирования инструкций. 

Программные эмуляторы представляют собой способ протестировать новые инструкции. Вот этим мы сегодня и займемся: покажем, как легко запустить свои собственные инструкции CPU с помощью программной эмуляции.

Архитектура набора команд

Начнем с пояснения. Архитектура набора команд (англ. Instruction Set Architecture, или ISA) — это язык процессора, который указывает ему, какие операции он может выполнять, например, сложение чисел, загрузку данных или переход к другой инструкции. ISA определяет набор инструкций, выполняемых процессором.

Эти инструкции также называют ассемблерными инструкциями, так как они представляют собой полную коллекцию базовых команд (мнемоник), которые понимает конкретный центральный процессор (CPU). Они составляют основу языка ассемблера, который служит низкоуровневым мостом между ПО и аппаратным обеспечением, управляя фундаментальными операциями, такими как перемещение данных.

Ассемблерная инструкция — это последовательность байтов, которая интерпретируется процессором. Инструкция состоит из кода операции (опкода) и операндов. Опкод описывает саму операцию инструкции, а операнды — ее входные и выходные аргументы. 

Опкоды — это такие команды, как «add» (сложение), «and» (логическое И), «load» (загрузка), «store» (сохранение), «move» (перемещение) и т. д.; операндами обычно являются регистры или адреса памяти. Для упрощения программирования на аппаратном уровне эти инструкции используют удобочитаемые сокращения, называемые мнемониками, в отличие от работы напрямую с двоичным кодом (нулями и единицами). Вот пример ассемблерного кода (он был получен в результате дизассемблирования исполняемого файла).

$ objdump -S -d ins_emul
...
1454: 85 c0 test %eax,%eax
1456: 79 0a jns 1462 <main+0xb9>
1458: bf 01 00 00 00 mov $0x1,%edi
145d: e8 8e fc ff ff call 10f0 <exit@plt>
1462: 48 8b 85 30 ff ff ff mov -0xd0(%rbp),%rax
1469: 48 83 c0 08 add $0x8,%rax
...

Число слева — это адрес инструкции относительно начала программы. Числа, следующие за смещением — это байты самой инструкции. В архитектуре x86 размер инструкции может варьироваться. Например, инструкция test занимает два байта, а инструкция add — четыре байта. Обратите внимание, что одна инструкция mov занимает четыре байта, а другая — шесть байтов; разница обусловлена типом операндов в этих различных инструкциях.

Программные эмуляторы

Когда мы приступили к разработке пользовательских инструкций, нам потребовалась концепция программного эмулятора для их тестирования. Тогда я сразу подумал о QEMU. QEMU (Quick Emulator) — это универсальный эмулятор и виртуализатор с открытым исходным кодом, который позволяет запускать операционные системы и программы для одной аппаратной архитектуры (например, RISC-V) на другой (например, x86). Он работает как эмулятор полной системы, имитируя процессор, память и устройства. 

Проблема с QEMU заключалась в том, что это было избыточное решение. Нужно было всего лишь добавить несколько инструкций, а не создавать целую новую архитектуру. Добавление новых инструкций потребовало бы изменений в основных файлах QEMU, и это казалось объемной работой, требующей глубокого понимания внутреннего устройства QEMU. Мы решили использовать QEMU для эмуляции RISC-V на x86, но не вносить в него никаких изменений.

Вторым вариантом, который мы рассмотрели, был SPIKE. SPIKE — симулятор архитектуры RISC-V ISA. Его можно использовать для запуска простых тестовых программ без необходимости загрузки полноценного экземпляра RISC-V в QEMU или доступа к аппаратному обеспечению RISC-V. Мы обнаружили, что SPIKE популярен среди компаний, разрабатывающих процессоры RISC-V, включая тех, кто в конечном итоге мог бы выпустить процессоры с нашими пользовательскими инструкциями. Но опять же, для наших нужд это казалось избыточным решением.

Эмулятор на основе SIGILL

В ходе оценки программных эмуляторов я вспомнил о нашем старом знакомом — SIGILL, одном из сигналов POSIX. SIGILL можно использовать как основу для программного эмулятора инструкций процессора. Идея заключается в том, чтобы определить инструкции с использованием опкодов, которые процессор не понимает. Эти «недопустимые» инструкции вызывают сигнал SIGILL, а мы можем перехватить этот сигнал и эмулировать инструкции в обработчике сигнала. 

Процедура в обработчике выглядит следующим образом:

  1. Загрузка битов инструкции. Они берутся из адреса сбоя (адреса вызвавшей ошибку инструкции), также известного как программный счетчик (program counter, PC), который передается в обработчик сигнала.
  2. Поиск опкода среди инструкций (например, с помощью оператора switch или дерева опкодов).
  3. Если совпадение в таблице опкодов не найдено, обработчик НЕ сбрасывает SIGILL и осуществляет возврат. Это приводит к выполнению стандартной обработки SIGILL (программа аварийно завершается с возможным созданием дампа памяти).
  4. Если совпадение найдено, вызывается функция-обработчик инструкции в контекстной структуре, возвращенной в результате поиска. Аргументом является указатель на «контекст пользователя» (user context), который содержит текущее состояние регистровых файлов.
  5. Функция-обработчик инструкции выполняется и реализует логику конкретной эмулируемой инструкции.
  6. Функция-обработчик инструкции возвращает значение программного счетчика (PC). Это может быть адрес следующей инструкции после вызвавшей ошибку или другой адрес для реализации перехода (jump). Обработчик сигнала устанавливает это значение PC в контексте пользователя и осуществляет возврат.
  7. Операционная система возобновляет выполнение потока с программного счетчика, установленного обработчиком, используя обновленное обработчиком состояние регистра.

Пример

Рассмотрим пример. Мы создадим инструкции «добавление контрольной суммы» (checksum add) и «свертка контрольной суммы» (checksum fold). Инструкция checksum add выполняет сложение с дополнением до единиц двух 64-битных значений (два значения складываются, и если возникает перенос, единица добавляется к результату). 

Инструкция checksum fold «сворачивает» 64-битное значение контрольной суммы в 16-битное для установки в поле контрольной суммы пакета. Кстати, эти инструкции довольно полезны, поскольку поддержка расчета контрольных сумм во многих архитектурах отсутствует. Вот код:

#define _GNU_SOURCE 1  /* Для получения REG_RIP */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ucontext.h>
#include <linux/types.h>

__u64 __checksum_add(__u64 src1, __u64 src2)
{
__u64 v = src1 + src2;

if (v < src1)
v++;

return v < src1 ? v + 1 : v;
}

// Свернуть 64-битное значение в 16 бит
__u64 __checksum_fold(__u64 v)
{
// Свернуть 64 бита в 32 бита
v = (v & 0xffffffffUL) + (v >> 32);
v = (v + (v >> 32)) & 0xffffffff;

// Свернуть 32 бита в 16 бит
v = (v & 0xffffUL) + (v >> 16);
v = (v + (v >> 16)) & 0xffff;

return v;
}

// Вызвать инструкцию checksum add
__u64 checksum_add(__u64 src1, __u64 src2)
{
__u64 dst;

asm ("mov %1, %%rax \n\t"
"mov %2, %%rbx \n\t"
".byte 0xf, 0x4 \n\t"
"mov %%rax, %0 \n\t"
: "=r" (dst)
: "r" (src1), "r" (src2)
: "rax", "%rbx"
);

return dst;
}

// Вызвать инструкцию checksum fold
__u64 checksum_fold(__u64 src)
{
__u64 dst;

asm ("mov %1, %%rax \n\t"
".byte 0xf, 0xa \n\t"
"mov %%rax, %0 \n\t"
: "=r" (dst)
: "r" (src)
: "rax"
);

return dst;
}

void handler(int signo, siginfo_t *info, void *uctx)
{
mcontext_t *mc = &((ucontext_t *)uctx)->uc_mcontext;

switch (*(__u16 *)mc->gregs[REG_RIP]) {
case 0x040f:
// Инструкция checksum add
mc->gregs[REG_RAX] = __checksum_add(mc->gregs[REG_RAX],
mc->gregs[REG_RBX]);
break;
case 0x0a0f:
// Инструкция checksum fold
mc->gregs[REG_RAX] = __checksum_fold(mc->gregs[REG_RAX]);
break;
default:
// Это недопустимая инструкция
exit(EXIT_FAILURE);
}

// Пропустить недопустимую инструкцию
mc->gregs[REG_RIP] += 2;
}

int main(int argc, char *argv[])
{
struct sigaction sa = {};
__u64 src1, src2, v;

if (argc < 2) {
fprintf(stderr, "Usage: %s <v1> <v2>\n", argv[0]);
exit(EXIT_FAILURE);
}

src1 = strtoull(argv[1], NULL, 0);
src2 = strtoull(argv[2], NULL, 0);

// Настроить обработчик SIGILL
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = handler;
if (sigaction(SIGILL, &sa, NULL) < 0)
exit(EXIT_FAILURE);

v = checksum_add(src1, src2);
printf("Checksum add is %llx\n", v);
printf("Checksum fold is %llx\n", checksum_fold(v));
}

Теперь запустим его несколько раз:

$ gcc -o checksum checksum.c
$ ./checksum 0x10001 0xffff
Checksum add is 20000
Checksum fold is 2
$ ./checksum 0x10001 0xffffffffffffffff
Checksum add is 10001
Checksum fold is 2
$ ./checksum 0xffff 0xffff
Checksum add is 1fffe
Checksum fold is ffff

Разберем по шагам.

Настройка

Программа принимает два числовых входных аргумента. Первое, что делает main — проверяет, что аргументов не менее двух. Далее мы настраиваем обработчик сигнала для SIGILL. Мы используем sigaction, поскольку обработчику сигнала потребуется доступ к регистрам вызывающей стороны. Затем вызывается checksum_add.

Вызов инструкции checksum_add

В checksum_add мы используем встроенный ассемблер для вызова ассемблерной инструкции. 

__u64 checksum_add(__u64 src1, __u64 src2)
{
__u64 dst;

asm ("mov %1, %%rax \n\t"
"mov %2, %%rbx \n\t"
".byte 0xf, 0x4 \n\t"
"mov %%rax, %0 \n\t"
: "=r" (dst)
: "r" (src1), "r" (src2)
: "rax", "%rbx"
);

return dst;
}

Ключевое слово asm позволяет вставлять ассемблерные инструкции непосредственно в код на языке C. Функция asm принимает четыре аргумента, разделенных двоеточиями.

  1. Первый аргумент — это список ассемблерных инструкций.
  2. Далее следуют переменные для вывода/ввода (output/input), переменные только для ввода (input only) и регистры, изменяемые кодом (clobbered registers).

В этом примере переменная dst является переменной вывода (output), а src1 иsrc2 — переменными ввода (input). Регистры rax и rbx указаны как изменяемые (clobbered) — они объявляются, чтобы сообщить компилятору, что встроенные инструкции устанавливают значения регистров.

Инструкция «mov %1, %%rax» перемещает первый аргумент, src1, в регистр rax. «mov %2, %%rbx» перемещает src2 в регистр rbx.

» .byte 0xf, 0x4″ — это место, где мы вызываем нашу инструкцию. Последовательность 0xf, 0x4 представляет собой недопустимую инструкцию в архитектуре x86, поэтому она вызовет SIGILL. Мы будем использовать эту последовательность в качестве нашей инструкции checksum_add. Ее семантика заключается в выполнении сложения с дополнением до единиц значений в регистрах rax и rbx и помещении результата в регистр rax.

Наконец, инструкция «mov %%rbx, %0» перемещает результат в переменную dst, которая возвращается функцией checksum_add.

Вызов инструкции checksum_fold

Мы также используем встроенный ассемблер для вызова инструкции checksum_fold.

__u64 checksum_fold(__u64 src)
{
__u64 dst;

asm ("mov %1, %%rax \n\t"
".byte 0xf, 0xa \n\t"
"mov %%rax, %0 \n\t"
: "=r" (dst)
: "r" (src)
: "rax"
);

return dst;
}

В этом случае у нас только один входной аргумент, а именно src. Инструкция checksum_fold вызывается с помощью «.byte 0xf, 0xa», где 0xf, 0xa — это еще одна недопустимая двухбайтовая инструкция в x86. Семантика этой инструкции заключается в свертке (fold) 64-битного входного значения в 16-битное с использованием сложения с дополнением до единиц, где входное значение берется из регистра rax, а результат помещается обратно в rax.

Обработчик сигнала

Обработчик сигнала эмулирует инструкции checksum_add и checksum_fold. Первое, что он делает, — загружает два байта инструкции. По этим двум байтам выполняется оператор switch. Если совпадение найдено для 0x040f, то это инструкция checksum_add; если для 0x0a0f, то это инструкция checksum_fold. Обратите внимание на замену преобразования порядка байт в инструкциях «.byte».

Как только случай определен, вызывается функция для выполнения checksum_add или checksum_fold. Аргументы берутся из регистров rax и rbx вызывающей стороны, а возвращаемое значение устанавливается в регистр rax. Вот фрагмент кода для checksum_add:

case 0x040f:
      // Инструкция checksum_add
      mc->gregs[REG_RAX] = __checksum_add(mc->gregs[REG_RAX],
          mc->gregs[REG_RBX]);
      break;

После оператора switch значение указателя инструкции увеличивается на два. После возврата из обработчика вызывающая сторона переходит к выполнению инструкции, следующей за недопустимой.

mc->gregs[REG_RIP] += 2;

Что дальше?

Хотя наш пример может показаться немного надуманным, он хорошо иллюстрирует основы работы эмулятора на SIGILL. Мы могли бы добавить гораздо больше инструкций, следуя тому же подходу, а сами инструкции могут иметь иерархию и подклассы. 

Также есть возможность добавить поддержку, позволяющую вызывающей стороне указывать операнды (это может стать сложной задачей в зависимости от соглашений целевой архитектуры). Кроме того, можно использовать этот эмулятор на SIGILL для других архитектур — RISC-V особенно удобен для такой цели, поскольку включает специальные настраиваемые опкоды для пользовательских инструкций.

Еще одна очевидная цель — создать мнемоники для новых инструкций. Допустим, нам нужно записать инструкцию checksum_add как «csum.add %rax, %rbx, %rax», а checksum_fold — как «csum.fold %rcx, %rdx». Для этого потребуется внести изменения в ассемблер. Мы проделали это в ассемблере GAS из пакета binutils для RISC-V, добавив ряд инструкций. Это не самая простая задача, и каждая архитектура, кажется, реализует это по-своему. Хорошая новость заключается в том, что после добавления нескольких инструкций в ассемблер добавлять новые становится проще.

Конечная цель — заставить компиляторы генерировать инструкции для достижения реального эффекта. Вот тут-то точно понадобится помощь экспертов по компиляторам!


Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Tom Herbert: Create and run your own assembly instructions!

Предыдущая статьяСамый недооцененный технологический стек — это не фронтенд + бэкенд
Следующая статьяСледующий рубеж ИИ: системы, которые обучаются, как наш мозг, — быстро, медленно и непрерывно