Pull to refresh

Симкод — современный язык ассемблера

Reading time33 min
Views17K

Начну с определений.

Симкод — это последовательность симкоманд.

Симкоманда — это символьная машинная макрокоманда с Си-подобным синтаксисом.

Например, ассемблерной команде add rax, rbx соответствует симкоманда rax += rbx.

Симкод позволяет выразить любой ассемблерный код [и как следствие машинный], только в более человекочитаемом виде. Однако, симкод не пытается назначить символьное обозначение для абсолютно каждой ассемблерной команды — те команды ассемблера, которые не имеют символьной записи, оставляются как есть. Таким образом, симкод является надмножеством ассемблера.

Зачем?
Хороший вопрос.

Вообще, симкод, как средство облегчения читаемости кода, сгенерированного компилятором, родился ещё в 2017 году, вскоре после того, как я начал разработку нового языка программирования и стал изучать технологии построения компиляторов. Но достаточно быстро я понял, что сложность любой ассемблерной записи [даже богомерзкого AT&T-синтаксиса] ничтожна по сравнению со сложностью полноценного оптимизирующего компилятора в нативный код. Поэтому проект был отложен «в стол», без особых надежд на практическое воплощение.

Но вот недавно мне по работе потребовалось погрузиться в ассемблер для современных процессоров архитектуры x86-64. Вскоре у меня стали появляться новые идеи на тему символьной записи команд, которые я сначала просто записывал, затем как-то структурировал и которые в итоге сложились в целостную систему альтернативной записи ассемблерных команд, которой я и намереваюсь поделиться в данной статье. Насколько эта система записи лучше традиционных языков ассемблера ещё предстоит выяснить, но мне бы хотелось напомнить читателю, что выполнение программы на любом языке программирования высокого уровня, каким бы высокоуровневым он не был, в итоге сводится к выполнению машинных команд, прямо [путём компиляции программы в машинный код] или косвенно [путём исполнения программы виртуальной машиной или интерпретатором, которые были скомпилированы в машинный код]. Поэтому, умение мыслить в терминах машинных команд даёт понимание того, что в действительности происходит при выполнении ваших программ.

Симкод может пригодиться если не для разработчиков компиляторов, то для тех, кто просто ищет ошибку в ассемблерном коде.

Часто ли приходится искать ошибки в ассемблерном коде [написанном вручную или сгенерированном компилятором]?

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

Зато, у него фактически нет аналогов [во всяком случае, я их не нашёл]. Всё что-то похожее (например, terse или HLA), во-первых, уже давно не обновлялось и не поддерживает современных расширений набора команд x86 (даже x86-64, не говоря уже об AVX), и, во-вторых, все эти проекты ориентированы на упрощение написания ассемблерного кода, а симкод разработан в первую очередь для удобства чтения с целью проверки машинного кода, сгенерированного компилятором. [Т.к. в настоящее время на языке ассемблера код уже не пишут (кроме очень редких случаев), и язык ассемблера сейчас используют только для чтения машинного кода, а потому современный язык ассемблера имеет смысл ориентировать именно на максимальную читаемость кода.]

Хотя предложенная мной идея не нова (как минимум символьную запись ассемблерных команд предлагали Юрий [автор сайта compiler.su] и Евгений Зуев [разработчик отечественного компилятора C++]), но столь же завершённого и проработанного проекта я не встречал.

Предназначение симкоманд, помимо улучшения читаемости, заключается в более чётком обозначении назначения кода. Так, например, очевидно, что при использовании ассемблерной команды xor eax, eax компилятором, назначение данной команды не в том, чтобы выполнить операцию «исключающего или» над регистром eax, а в том, чтобы просто установить его значение равным нулю. В симкоде это действие обозначается понятной симкомандой eax = 0, в то время как на языке ассемблера x86 понятная команда mov eax, 0 не используется только из-за того, что есть более эффективная команда, соответствующая этому же действию: xor eax, eax. Аналогично и в случае с командой test eax, eax, выполняемой с целью сравнения eax с нулём, которая в этом случае аналогична команде cmp eax, 0, однако является чуть более эффективной.

Хотя поведение xor eax, eax всё же отличается от mov eax, 0 тем, что команда xor изменяет регистр флагов процессора, а mov — нет. Но такая особенность инструкции mov используется очень редко, и на этот случай есть симкоманда eax = (0), которая соответствует mov eax, 0.

Симкоманды арифметических и логических операций


Команда ассемблера x86 Симкоманда x86
add eax, ebx eax += ebx
sub eax, ebx eax -= ebx
imul ebx edx:eax *= ebx (примечание)
mul ebx edx:eax u*= ebx
imul eax, ebx eax *= ebx
imul eax, ebx, 10 eax = ebx * 10
idiv ebx edx:eax /= ebx
div ebx edx:eax u/= ebx
neg eax eax = -eax
inc eax eax++
dec eax eax--
and eax, ebx eax &= ebx
or eax, ebx eax |= ebx
xor eax, ebx eax (+)= ebx
xor eax, eax eax = 0
not eax eax = ~eax
sal eax, cl eax <<= cl
shl eax, cl eax <<= cl
sar eax, cl eax >>= cl
shr eax, cl eax u>>= cl
rol eax, cl eax (<<)= cl
ror eax, cl eax (>>)= cl
rcl eax, cl cf:eax (<<)= cl
rcr eax, cl cf:eax (>>)= cl
adc eax, ebx eax += ebx + cf
sbb eax, ebx eax -= ebx + cf
Как можно догадаться, u обозначает unsigned.

Практически все операции используют традиционную запись из языка Си, за исключением «исключающего или», для которого использование символа ^ мне категорически не нравится. ^ вне языков программирования часто используется для обозначения возведения в степень. И вообще, этот знак похож на символ конъюнкции ⋀ (логическое И). Поэтому для «исключающего или» используется другое обозначение, а именно — тройка символов ( (открывающая скобка), + (плюс) и ) (закрывающая скобка), так как они похожи на символ ⊕, который используется в алгебре логики для обозначения данной операции. И хотя ⊕ используется чаще для одноразрядных значений, в Википедии встречается его применение для указателей и для массивов из байт. Кроме того, символ ⊕ можно встретить в научных статьях, а также в текстах задач на Codeforces (1, 2).

Симкоманды переходов


Для обозначения безусловного перехода на метку используется команда, "зеркальная" объявлению метки, т.е. :<метка>.
Для обозначения условного перехода используется запись <условие>:<метка>.

Проанализировав множество ассемблерных листингов, я заметил, что перед инструкциями условных переходов (jg, je и т.д.) практически всегда находится команда, изменяющая флаги процессора. Это не обязательно команда cmp [или test]. Это может быть dec ecx и подобная. Но инструкцию, использующую флаги, практически всегда предваряет инструкция их установки. Так возникла идея объединить эти две инструкции (меняющую флаги и условный переход) в одну симкоманду.

В качестве примера использования симкоманд перехода далее представлена часть функции для определения того факта, является ли заданная строка палиндромом, то есть, читается ли она одинаково в обоих направлениях — слева направо и справа налево. [Полный ассемблерный код функции находится здесь.]

Ассемблер x86-64 Симкод x86-64
palindrome_start:
    cmp rcx, 0
    jl palindrome_end
    mov rbx, rdx
    sub rbx, rcx
    sub rbx, 1
    mov bl, byte [rdi + rbx]
    cmp byte [rdi + rcx], bl
    jne palindrome_failed
    dec rcx
    jmp palindrome_start
palindrome_end:
palindrome_start:
    rcx < 0 : palindrome_end

    rbx = rdx
    rbx -= rcx
    rbx -= 1
    bl = [rdi + rbx]
    [rdi + rcx] != bl : palindrome_failed

    rcx--
    :palindrome_start
palindrome_end:

И всё же, в некоторых случаях необходимо использовать инструкцию cmp, результат которой используется не только в последующей команде условного перехода. Для этого в симкоде предусмотрена такая запись:
eax <=> ebx                 // cmp eax, ebx
< : eax_is_less_than_ebx    // jl eax_is_less_than_ebx
> : eax_is_greater_than_ebx // jg eax_is_greater_than_ebx
<=> — это оператор трехстороннего сравнения (также называемый spaceship operator), который присутствует в Perl, PHP 7, Ruby, Groovy, а также появился в C++20.

[По сути, команда cmp eax, ebx выполняет вычитание ebx из eax, устанавливая флаги, но не изменяя значение eax. И было бы логично обозначать эту команду в симкоде как eax - ebx, но символ - слишком малозаметен, его можно спутать с =.]

Объединение инструкции сравнения и условного перехода в одну симкоманду нужно больше даже не для сокращения симкода и улучшения читаемости, а с целью обозначить тот факт, что флаги, устанавливаемые инструкцией сравнения, используются только в последующей инструкции условного перехода [т.е. в дальнейшем коде они использоваться не будут]. Поэтому приведённые выше три симкоманды сократить в две не получится:
eax < ebx : eax_is_less_than_ebx
>         : eax_is_greater_than_ebx // ошибка: нет предшествующей симкоманды сравнения (`<=>`)
[Кстати, как оказалось, в RISC-V нет регистра флагов и поэтому инструкции условных переходов организованы аналогично: сравнение и условный переход объединены в одну инструкцию. Так, одна RISC-V инструкция BLT x0, x1, loop соответствует одной симкоманде x0 < x1 : loop.]

Условные инструкции CMOVcc записываются в симкоде в стиле Ruby:
<назн> = <ист> if <условие>

А SETcc — в стиле Python:
<назн> = 1 if <условие> else 0

Вообще, лично мне не нравится такой синтаксис ни в Ruby (я предпочитаю всегда if писать в начале), ни в Python (Сишный тернарный оператор <условие> ? 1 : 0 мне нравится больше), но в симкоде такая запись смотрится очень органично:
eax = 0
edi <=> esi
edx = -1
al  = 1   if > else 0
eax = edx if <
ret
Сравните этот симкод с исходной ассемблерной записью, из которой он был получен:
xor     eax, eax
cmp     edi, esi
mov     edx, -1
setg    al
cmovl   eax, edx
ret
Данный пример кода — не синтетический, а является результатом компиляции посредством GCC такой функции на языке Си:
int compare(int a, int b)
{
    return a < b ? -1 : a > b ? 1 : 0;
}

Unsigned-условия в симкоде также поддерживаются: пара команд cmp eax, ebx и jb l записывается как eax u< ebx : l.

Симкоманды SSE-инструкций


Ну, со скалярными арифметическими операциями всё понятно:
xmm0s += xmm1s [addss xmm0, xmm1], xmm1s -= xmm1s [subss xmm0, xmm1] и т.д. или
xmm0d += xmm1d [addsd xmm0, xmm1], xmm1d -= xmm1d [subsd xmm0, xmm1] и т.д. [d обозначает double precision, а s — single.]

А как быть с векторными/упакованными операциями, а также со всякими специфическими вроде movhlps, unpcklpd, shufps и пр.?

Для обозначения векторных операций я решил использовать две вертикальные черты вокруг применяемой операции.
Вертикальные линии обозначают параллельность/параllелизм:
SIMD — Википедия:

SIMD — принцип компьютерных вычислений, позволяющий обеспечить параллелизм на уровне данных.
SIMD — Wikipedia:

Such machines exploit data level parallelism, but not concurrency: there are simultaneous (parallel) computations

Примеры:
xmm0s |+=| xmm1s   // addps xmm0, xmm1
xmm0s |=| 0        // xorps xmm0, xmm0
xmm0s |(+)=| xmm1s // xorps xmm0, xmm1

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

В SSE2 появились команды целочисленных упакованных/параллельных/векторных операций над XMM-регистрами для устранения необходимости в расширении MMX. В симкоде они записываются аналогично параллельным операциям с вещественными числами, только вместо s (single) и d (double) используются b (byte), w (word), i (int, т.к. в языках C/C++/C#/Java тип int соответствует 32-разрядному целому) и l (long, т.к. в языках C/C++/C#/Java тип long соответствует 64-разрядному целому).
xmm1b |+=| xmm2b // paddb xmm1, xmm2
xmm1w |+=| xmm2w // paddw xmm1, xmm2
xmm1i |+=| xmm2i // paddd xmm1, xmm2 // `xmm1d` уже занято под double
xmm1l |+=| xmm2l // paddq xmm1, xmm2
Для параллельных целочисленных поразрядных операций суффикс-тип у регистров указывать не нужно:
xmm1 |(+)=| xmm2      // pxor xmm1, xmm2
xmm1 ||=| xmm2        // por  xmm1, xmm2
xmm1 |&=| xmm2        // pand xmm1, xmm2
xmm1 |=| ~xmm1 & xmm2 // pandn xmm1, xmm2
Параллельные операции сложения/вычитания с насыщением записываются так:
xmm1b |s+=| xmm2  // paddsb xmm1, xmm2
xmm1w |s+=| xmm2  // paddsw xmm1, xmm2
xmm1b |s-=| xmm2  // psubsb xmm1, xmm2
xmm1b |us-=| xmm2 // psubusb xmm1, xmm2
xmm1b |us+=| xmm2 // paddusb xmm1, xmm2
// Или так:
xmm1ub |s+=| xmm2 // paddusb xmm1, xmm2

Для movhlps и movlhps используется такая запись, которая напоминает срезы в Python:
xmm0s[0:2] |=| xmm1s[2:4] // movhlps xmm0, xmm1
xmm0s[2:4] |=| xmm1s[0:2] // movlhps xmm0, xmm1

unpckhpd и unpcklpd записываются так:
xmm0d |=| xmm0d[1], xmm1d[1] // unpckhpd xmm0, xmm1
xmm0d[1] = xmm1d[0]          // unpcklpd xmm0, xmm1

shufps и shufpd записываются так:
xmm0s |=| xmm0s[2,3], xmm1s[0,0] // shufps xmm0, xmm1, 1110b
xmm0s |=| xmm0s[2,3,0,0]         // shufps xmm0, xmm0, 1110b
xmm0d |=| xmm0d[1], xmm1d[0]     // shufpd xmm0, xmm1, 01b
xmm0d |=| xmm0d[1,0]             // shufpd xmm0, xmm0, 01b
(Симкоманды xmm0s |=| xmm1s[2,3,0,0] и xmm0d |=| xmm1d[1,0] поддерживаются только в AVX.)

Команды пересылки.

Расширение SSE знаменито тем, что у него целый зоопарк move-инструкций: чтобы загрузить полный 128-разрядный XMM-регистр из памяти существует аж 8 move-команд: movapd, movaps, movdqa, movupd, movups, movdqu, lddqu, movntdqa. Но т.к. последние две инструкции используются очень редко, символьная запись есть только у первых 6:
xmm0s |=a| [f]       movaps xmm0, [f]
xmm0s |=u| [f]       movups xmm0, [f]
xmm0d |=a| [f]       movapd xmm0, [f]
xmm0d |=u| [f]       movupd xmm0, [f]
xmm0l |=a| [i]       movdqa xmm0, [i]
xmm0l |=u| [i]       movdqu xmm0, [i]
Для чего их так много?
Инструкции с буковкой a (aligned) в мнемонике требуют выровненности адреса, из которого загружается значение в XMM-регистр, на 16 байт.
А с буковкой u (unaligned) — не требуют, но работают в общем случае медленнее.

Помимо этого, каждая инструкция пересылки аннотирует XMM-регистр невидимым флагом, указывающим на тип хранящихся в нем данных. Если использовать регистр не по назначению, команды всё равно будут работать как ожидается, однако могут появиться дополнительные один-два такта задержки из-за необходимости копирования значения регистра в другой «домен» (т.к. процессор использует различные функциональные устройства, исполняющие SSE-команды различных типов [целочисленные и вещественные]). Поэтому в большинстве случаев нужно стараться использовать инструкцию пересылки, соответствующую операциям, которые будут выполняться с этими регистрами.

Со скалярными move-инструкциями всё просто:
xmm0s = [f]        movss xmm0, [f]
xmm0d = [f]        movsd xmm0, [f]
rax = xmm0l        movq rax, xmm0
eax = xmm0i        movd eax, xmm0

Для обозначения специфических move-инструкций (movlps, movlpd, movhps, movhpd) снова на помощь приходят срезы из Python:
xmm0s[0:2] |=| [m64] // movlps xmm0, [m64]
xmm0s[2:4] |=| [m64] // movhps xmm0, [m64]

xmm0d[0] = [m64]     // movlpd xmm0, [m64]
xmm0d[1] = [m64]     // movhpd xmm0, [m64]

Вставка/извлечение компоненты XMM-регистра:
xmm1b[1] = al      // pinsrb xmm1, eax, 1
xmm1w[2] = ax      // pinsrw xmm1, eax, 2
eax = xmm1b[3]     // pextrb eax, xmm1, 3
rax = xmm1l[1]     // pextrq rax, xmm1, 1

Команды сравнений:
xmm0s <=> xmm1s    // comiss  xmm0, xmm1
xmm0s uo<=> xmm1s  // ucomiss xmm0, xmm1 // uo — unordered
xmm0w |=| == xmm2w // pcmpeqw xmm0, xmm2 // xmm0w |===| xmm2w — выглядит некрасиво и непонятно
xmm0w |=| > xmm2w  // pcmpgtw xmm0, xmm2 // xmm0w |>=| xmm2w  — выглядит как применение операции `>=`
xmm0d = == xmm1d   // cmpeqsd xmm0, xmm1
xmm0d = < xmm1d    // cmpltsd xmm0, xmm1
Можно было бы писать так:
xmm0d == xmm1d
xmm0d < xmm1d
Но ‘такая запись не даёт понять’/‘при такой записи не очевидно’, что результат записывается в xmm0d. Такую запись разумно зарезервировать за сравнением, результат которого отражается в изменении регистра флагов.

В симкомандах условного перехода при сравнении SSE-регистров необходимо использовать unsigned-условия:
xmm0s u> xmm1s : .L4 // comiss xmm0, xmm1
                     // ja     .L4
Это связано с тем, что инструкции comiss/comisd выставляют флаги так, как будто сравниваются числа без знака. Если бы запись xmm0s > xmm1s : .L4 была разрешена, то возникла бы несогласованность: после xmm0s <=> xmm1s необходимо писать u> : .L4, а не > : .L4.
Unordered-условия являются неявно unsigned:
xmm0s uo> xmm1s : .L4 // ucomiss xmm0, xmm1
                      // ja      .L4

Симкоманды AVX-инструкций


В принципе, записываются так же, как и SSE-симкоманды, только используют регистры ymmzmm в случае AVX-512), а также позволяют использовать раздельный регистр-приёмник от регистров-источников благодаря тому, что AVX-инструкции поддерживают 3 регистровых операнда, а не только 2, как было в MMX и SSE.

Примеры:
vmulpd  ymm7, ymm5, ymm6 ; ymm7d v|=| ymm5 * ymm6
vrcpps  ymm0, ymm1       ; ymm0s v|=| 1 / ymm1
vpsllw  xmm1, xmm2, xmm3 ; xmm1w v|=| xmm2 << xmm3
vpaddsb xmm1, xmm2, xmm3 ; xmm1b v|=| xmm2 s+ xmm3
vpandn  xmm1, xmm2, xmm3 ; xmm1  v|=| ~xmm2 & xmm3

vfmadd132ps xmm2, xmm3, xmm4 ; xmm2s v|=| xmm2 * xmm4 + xmm3

vshufps xmm0, xmm1, xmm1, 1110b ; xmm0s v|=| xmm1s[2,3,0,0]
vshufps xmm0, xmm1, xmm2, 1110b ; xmm0s v|=| xmm1s[2,3], xmm2s[0,0]
vshufps ymm0, ymm1, ymm2, 1110b ; ymm0s v|=| ymm1s[2,3], ymm2s[0,0], ymm1s[6,7], ymm2s[4,4]
vshufps ymm0, ymm1, ymm1, 1110b ; ymm0s v|=| ymm1s[2,3,0,0,6,7,4,4]
vshufpd xmm0, xmm1, xmm1, 01b   ; xmm0d v|=| xmm1d[1,0]
vshufpd xmm0, xmm1, xmm2, 01b   ; xmm0d v|=| xmm1d[1], xmm2d[0]
vshufpd ymm0, ymm1, ymm2, WZYXb ; ymm0d v|=| ymm1d[X], ymm2d[Y], ymm1d[Z+2], ymm2d[W+2]
vshufpd ymm0, ymm1, ymm1, WZYXb ; ymm0d v|=| ymm1d[X,Y,Z+2,W+2]

vaddss  xmm0, xmm1, xmm2 ; xmm0s v= xmm1 + xmm2

; Команды пересылки:
vmovaps ymm0, [f]        ; ymm0s v|=a| [f]
vmovups ymm0, [f]        ; ymm0s v|=u| [f]
vmovapd ymm0, [f]        ; ymm0d v|=a| [f]

vmovntpd [f], ymm0       ; [f] v|=nt| ymm0d
vmovntps [f], ymm0       ; [f] v|=nt| ymm0s

vmovlps xmm0, xmm1, [m64] ; xmm0s v|=| [m64], xmm1s[2:4]
vmovlps xmm0, xmm0, [m64] ; xmm0s[0:2] v|=| [m64]
vmovhps xmm0, xmm1, [m64] ; xmm0s v|=| xmm1s[0:2], [m64]
vmovhps xmm0, xmm0, [m64] ; xmm0s[2:4] v|=| [m64]

; Команды сравнений:
vpcmpeqw xmm0, xmm1, xmm2 ; xmm0w v|=| xmm1w == xmm2w
vpcmpgtw xmm0, xmm1, xmm2 ; xmm0w v|=| xmm1w > xmm2w
vcmpltpd xmm0, xmm1, xmm2 ; xmm0d v|=| xmm1d < xmm2d
vcmpltsd xmm0, xmm1, xmm2 ; xmm0d v= xmm1d < xmm2d

Замечание по поводу буковки ‘v’ перед |=| и =
Как можно догадаться, буковка ‘v’ является признаком AVX-инструкций (т.к. их ассемблерные мнемоники начинаются на эту букву).
Но есть нюанс.

Смешение в коде программы SSE-инструкций (без буковки v) и AVX-инструкций (с буковкой v) приводит к значительному снижению производительности, если не принимать специальных мер.
Using AVX CPU instructions: Poor performance:

Every time you improperly switch back and forth between SSE and AVX instructions, you will pay an extremely high (~70) cycle penalty.
...

Transitioning between 256-bit Intel® AVX instructions and legacy Intel® SSE instructions within a program may cause performance penalties because the hardware must save and restore the upper 128 bits of the YMM registers.

Т.е. если после выполнения AVX-инструкций процессор встречает SSE-инструкцию, он сохраняет старшие 128 бит всех YMM регистров в специальном внутреннем буфере. Сделано это для того, чтобы SSE-инструкции не "портили" старшие половины YMM регистров (т.к. XMM регистры — это младшие половины YMM). Затем, когда процессору попадается AVX-инструкция, он восстанавливает старшие 128 бит всех YMM регистров из этого буфера.
Но так было до Skylake.
В Skylake процессор сохраняет старшую часть не всех YMM регистров, а лишь того регистра, который "испортила" SSE-инструкция. Но при этом выполнение любой AVX-инструкции переводит процессор в некий "dirty upper state", в котором SSE-инструкции выполняются чудовищно медленно из-за частичных регистровых зависимостей. (Более подробно можно почитать в ответе на вопрос ‘Почему этот SSE-код в 6 раз медленнее без VZEROUPPER?’)
Решения у данной проблемы всего два:
  1. Вставлять инструкцию VZEROUPPER после завершения работы кода, использующего AVX-инструкции. В этом случае последующие SSE-инструкции будут исполняться без пенальти.
  2. Преобразовать все SSE-инструкции в коде в их AVX-аналоги (отличие в том, что AVX-аналоги зануляют все старшие биты YMM/ZMM регистров [начиная со 128-го] при работе с XMM-регистрами).

Второй способ является предпочтительным — компиляторы GCC и Clang при включении поддержки AVX (например, опцией -march=haswell или просто -mavx) используют всегда AVX-инструкции даже для скалярных операций, и некоторые ассемблеры автоматически заменяют SSE-инструкции на их AVX-аналоги.
Is it okay to mix legacy SSE encoded instructions and VEX encoded ones in the same code path?:
Worst thing: some assemblers (like gas) may convert SHUFPS into VSHUFPS while creating object file (when -mavx flag is applied).

В итоге, я пришёл к выводу, что раз в правильном машинном коде должны присутствовать либо только SSE, либо только AVX-инструкции [т.к. их смешение чревато снижением производительности], то буковку v перед |=| и = в правильном машинном коде добавлять не нужно. А нужно только в "неправильном". Т.е. v имеет смысл добавлять в симкод только в том случае, когда в коде программы смешиваются AVX и SSE-инструкции. Добавлять как раз таки с той целью, чтобы показать, что в коде что-то не в порядке. Тогда наличие v будет означать не признак AVX-инструкции, а признак того, что где-то есть не-AVX (т.е. SSE) инструкция.

Так, AVX-инструкцию vpaddb xmm1, xmm2, xmm3 в симкоде можно записать так [без v перед |=|]:
xmm1b |=| xmm2 + xmm3
Почему не xmm1b |=| xmm2 |+| xmm3?
Лишние | вокруг + слишком перетягивают на себя внимание, при беглом взгляде создаётся ощущение разделения команды на три части: xmm1b, xmm2 и xmm3.

Ну хорошо, все перечисленные выше команды относительно легко ложатся на символьную запись, но ведь в SSE- и AVX-расширениях просто зиллион всяких разных нерегулярных команд. Как записывать их?

Ответить на этот вопрос оказалось гораздо проще, чем я думал. Можно поступить аналогично тому, как в языках высокого уровня (C/C++/Rust) осуществляется доступ к SSE/AVX-инструкциям процессора без использования asm-вставок или подключения отдельных библиотек, написанных на ассемблере. Можно использовать… «интринсики».
Вот самый простой пример:
xmm0s = sqrt(xmm1s)    //  sqrtss xmm0, xmm1
xmm0s |=| sqrt(xmm1s)  //  sqrtps xmm0, xmm1
Хотя в данном случае, откровенно говоря, можно было бы воспользоваться тем фактом, что симкод является надмножеством ассемблера и что запись sqrtss xmm0, xmm1 является также действительным симкодом и, казалось бы, что «интринсик» sqrt() вводить необязательно. Но полезность таких простых «интринсиков», как sqrt(), многократно возрастает при записи AVX-команд, т.к. команды vsqrtss/vsqrtsd имеют 3 операнда. При этом возможность использовать различные регистры в 1-м и 2-м операнде требуется примерно никогда. Поэтому команда vsqrtss xmm0, xmm0, xmm1 записывается в симкоде как xmm0s v= sqrt(xmm1). Но если очень нужно, то команду vsqrtss xmm0, xmm1, xmm2 можно записать как xmm0s v= sqrt(xmm2s[0]), xmm1s[1:4].

«Интринсики» могут использоваться для альтернативной записи симкоманд, например xmm0s v|=| shuffle(xmm1s, 1110b) является эквивалентом симкоманды xmm0s v|=| xmm1s[2,3,0,0], но ближе к ассемблерной записи [vshufps xmm0, xmm1, xmm1, 1110b].
Ещё «интринсики» удобны для записи команд преобразований/конвертации
xmm2s = float(ecx)            // cvtsi2ss xmm2, ecx
ecx = int(round(xmm2s))       // cvtss2si ecx, xmm2
ecx = int(xmm2s)              // cvttss2si ecx, xmm2
xmm0d = convert(xmm1s)        // cvtss2sd xmm0, xmm1
xmm1s |=| float(xmm2i)        // cvtdq2ps xmm1, xmm2
xmm1i |=| int(round(xmm2s))   // cvtps2dq xmm1, xmm2
xmm1d |=| float(xmm2i[0:2])   // cvtdq2pd xmm1, xmm2
xmm6d |=| convert(xmm0s[0:2]) // cvtps2pd xmm6, xmm0
ymm1d v|=| float(xmm2i)       // vcvtdq2pd ymm1, xmm2
ymm6d v|=| convert(xmm0s)     // vcvtps2pd ymm6, xmm0
ymm6s v|=| convert(xmm0h)     // vcvtph2ps ymm6, xmm0
Обратите внимание, что парной векторной инструкцией к скалярной cvtsi2ss является cvtdq2ps, а не cvtpi2ps! Т.к. последняя может читать значение только из MMX-регистра или 64-разрядного значения из памяти. [И аналогично с cvtss2si-cvtps2dq-cvtps2pi, а также с cvttss2si-cvttps2dq-cvttps2pi, а также с cvtsi2sd-cvtdq2pd-cvtpi2pd, а также с cvtsd2si-cvtpd2dq-cvtpd2pi и с cvttsd2si-cvttpd2dq-cvttpd2pi.]

Ещё примечательно, что «интринсик» convert() в представленных примерах по большому счёту ничего не делает (в отличие от int(), который извлекает целую часть вещественного числа [т.е. выполняет операцию "truncate"], или float(), который переводит целое число в вещественное), в том смысле, что в языках программирования высокого уровня преобразование float32 к float64 обычно выполняется неявно/автоматически. Необходимость явного convert() в симкоде обусловлена тем, что в записи xmm0d = xmm1s окончание регистра-источника можно не заметить и спутать эту запись с xmm0d = xmm1d или xmm0d = xmm1. Поэтому convert() служит хорошо заметным признаком того, что в данной симкоманде выполняется сужающее или расширяющее преобразование вещественного числа. (При этом симкоманда xmm0d = convert(xmm1d) запрещена!)

Также идея использовать «интринсики» решила очень непростой вопрос с записью в симкоде инструкций movzx/movsx.
С использованием «интринсика» zx() команду movzx eax, bx можно записать как eax = zx(bx).
А команду movzx eax, byte ptr [ebx] как eax = zx(byte[ebx]). Я решил остановиться на NASM-овском синтаксисе обращений к памяти: в этом ptr не вижу никакого смысла — квадратные скобочки итак однозначно дают понять, что происходит обращение к памяти.

Размер адресуемого операнда в памяти задаётся как byte, 2bytes, 4bytes, 8bytes и т.д.
Также можно писать word вместо 2bytes (т.к. w используется в xmm0w).
Почему не используются традиционные `dword`, `qword` и пр.?
Во-первых, указание размера требуется достаточно редко {помимо zx()/sx() оно используется лишь в mov byte ptr [eax], 1 -> byte[eax] = 1}. Поэтому оно должно быть максимально понятно/читаемо/незабываемо.

Во-вторых, d в xmm0d обозначает double-precision. И аналогично q в zmm0q зарезервировано для quadruple-precision float (binary128). Поэтому я считаю, что dword и qword необходимо полностью исключить из лексикона записи машинных команд.

И в-третьих, для 16-байтных адресов есть некоторая несогласованность: в FASM используется dqword, а в MASM — oword.

Традиция считать память в «машинных словах» размером в 16 бит [2 байта] тянется ещё с PDP-11 и Intel 8086, но в распространённых ассемблерах она доведена до абсурда:
flat assembler 1.73 Programmer's Manual:

Table 1.1 Size operators
Operator Bits Bytes
word 16 2
dword 32 4
fword 48 6
pword 48 6
qword 64 8
tbyte 80 10
tword 80 10
dqword 128 16
qqword 256 32
dqqword 512 64

Но я не против оставить word как синоним 2bytes в память о PDP-11 и первом процессоре семейства x86, а также по причине того, что в xmm0w не получится вместо w использовать s (short int), т.к. s уже занято и обозначает single-precision float.

Также как в NASM, обращение к памяти в симкоде всегда записывается с использованием квадратных скобок [т.е. если есть обращение к памяти, значит есть квадратные скобки]. Обратное — тоже верно, за исключением единственного случая: обращение к компонентам SSE/AVX-регистров. В итоге признак обращения к памяти такой: если перед квадратными скобками стоит размер (byte, 2bytes, 4bytes и т.д.) или ничего не стоит, тогда это обращение к памяти, а если стоит имя регистра (xmm, ymm или zmm), то это обращение к компоненте или компонентам регистра.
[Есть и ещё одно «наполовину» исключение: второй операнд инструкции lea, который выглядит как обращение к памяти, но фактически данная инструкция к памяти не обращается. Полностью решить проблему наглядности определения того, обращается данная симкоманда к памяти или не обращается, должна подсветка синтаксиса симкода, например, выделяя все квадратные скобки, обозначающие обращение к памяти, синим цветом, а если обращения к памяти нет, тогда серым цветом.]

Симкод скрывает незначительные детали архитектуры процессора в пользу максимальной регулярности синтаксиса и очевидности записи команд. Например, AMD в x86-64 зачем-то ввела мнемонику movsxd для знакового расширения 32-разрядного числа в 64-разрядный регистр. Существующую мнемонику movsx AMD расширять не захотела, причём именно для данного случая. Т.е. если написать movsx rax, WORD PTR [short_num] можно, то movsx rax, DWORD PTR [int_num] — уже нельзя (нужно вместо movsx использовать movsxd). Занятно, что симметричной команды movzxd (для беззнакового расширения) нет совсем — необходимо использовать обычный mov указывая 32-разрядный регистр назначения (например, mov eax, DWORD PTR [int_num]) и опираться на тот факт, что в long mode все инструкции с 32-разрядным регистром назначения всегда зануляют старшие биты 64-разрядного регистра (т.е. биты с 32-го по 63-й включительно).

Соответственно, запись инструкции в симкоде отражает не то, как она кодируется, а то, что она делает:
movsx  rax, BYTE PTR [byte_num]  ; rax = sx(byte[byte_num])
movsx  rax, WORD PTR [short_num] ; rax = sx(2bytes[short_num])
movsxd rax, DWORD PTR [int_num]  ; rax = sx(4bytes[int_num])
mov    eax, DWORD PTR [int_num]  ; rax = zx(4bytes[int_num])
movzx  rax, WORD PTR [short_num] ; rax = zx(2bytes[short_num])
Причём команда mov eax, DWORD PTR [int_num] может быть записана в симкоде как eax = [int_num] в том случае, если последующий код не использует старшую часть регистра rax.
Такое поведение связано с тем, что 32-разрядные регистры по-прежнему достаточно часто используются в сгенерированном коде, т.к. x86-64 эффективно поддерживает работу с 32-разрядными целыми и запись eax = [int_num] будет нагляднее, чем rax = zx(4bytes[int_num]) в том случае, если дальше в коде используются только младшие 32 разряда регистра rax (т.е. eax).

И наоборот, в таком коде (отсюда):
div:
    movq    %rdi, %rax
    xorl    %edx, %edx
    divq    %rsi
    ret
Инструкция xorl %edx, %edx будет странслирована в симкоманду rdx = 0 (а не edx = 0) по причине того, что divq %rsi использует rdx.
[Да, симкоманды rdx = 0 и edx = 0 эквивалентны: обе они транслируются в xor edx, edx, а если прям нужно получить xor rdx, rdx, тогда придётся писать явно: rdx (+)= rdx. Хотя у такой записи есть один нюанс.]

«Интринсик» sx() также используется для записи инструкций cdq и cqo, которые обычно используются для подготовки регистра edx или rdx перед выполнением целочисленного деления со знаком:
int divide(int a, int b)
{
    return a / b;
}

long divide(long a, long b)
{
    return a / b;
}
divide(int, int):
        mov     eax, edi ; eax = edi
        cdq              ; edx:eax = sx(eax)
        idiv    esi      ; edx:eax /= esi
        ret
divide(long, long):
        mov     rax, rdi ; rax = rdi
        cqo              ; rdx:rax = sx(rax)
        idiv    rsi      ; rdx:rax /= rsi
        ret

«Знание немногих принципов освобождает от знания многих фактов».

Думаю, очевидно, нет необходимости приводить длиннющие таблицы соответствия всех ассемблерных команд симкомандам. Достаточно понять основные принципы, по которым они формируются.

А для того, чтобы сложился последний кусочек пазла, который позволяет понять принцип того, как можно получить символьную запись вообще любой SSE- или AVX-команды ассемблера, необходимо знать про две фичи языков высокого уровня:
  • Uniform Function Call Syntax — возможность, присутствующая в языках D и Nim, которая позволяет вместо min(a, b) писать a.min(b) [вместо min может быть любое другое имя функции, количество аргументов также любое, но не меньше одного].
  • Оператор .= из 11l. Его необходимость в 11l во многом связана с тем, что если в Python строки иммутабельны, то в 11l [также как в C++] — нет. Соответственно, такие методы как replace(), trim(), lowercase() пришлось бы продублировать, чтобы существовала возможность модификации строки in-place. Но возникает проблема, как их назвать: если для lowercase() ещё понятно — make_lowercase(), то подобрать пару к остальным функциям очень непросто [ну не называть же метод replace_inplace() :)(:]. Так у меня родилась идея оператора .=, который аналогичен +=, -=, *= и подобным: my_string=my_string.replace(" ", "_") в 11l можно записать как my_string.=replace(" ", "_").

А теперь, возьмём к примеру инструкцию minss xmm1, xmm2. Да, используя «интринсик» min() её можно записать как xmm1s = min(xmm1, xmm2). Но это, согласитесь, избыточно. Тогда:
  1. перепишем её как xmm1s = xmm1.min(xmm2) используя Uniform Function Call Syntax;
  2. перепишем её как xmm1s .= min(xmm2) используя оператор .= из 11l.

Нужно записать minps xmm1, xmm2? Пожалуйста: xmm1s |.=| min(xmm2).

Нужно vminps xmm1, xmm2? Без проблем: xmm1s v|.=| min(xmm2).
[Хотя vminps принимает 3 операнда, в симкоде такая запись оправдана, т.к. minps может быть автоматически расширена до vminps, у которой первые 2 операнда совпадают.]

А теперь рассмотрим какие-нибудь своеобразные команды, например vpunpcklbw и vphaddw отсюда.
Сначала открываем документацию по команде vpunpcklbw. Сразу идём в раздел ‘Intel C/C++ Compiler Intrinsic Equivalents’. Находим соответствующий ей интринсик:
VPUNPCKLBW __m256i _mm256_unpacklo_epi8 (__m256i m1, __m256i m2)

Соответствие интринсика команде определяется очень просто:
  1. Слева перед сигнатурой интринсика указана мнемоника команды — она должна в точности совпадать с мнемоникой искомой команды.
  2. Тип параметров интринсика должен соответствовать регистрам искомой команды. В нашем случае, искомой командой является VPUNPCKLBW ymm1, ymm2, ymm0. Регистрам ymm соответствует тип __m256i.
Имя соответствующей симкоманды получается «выкусыванием» средней части имени интринсика. В данном случае это будет unpacklo.

Т.к. _mm256_unpacklo_epi8 оканчивается на i8, то используем тип b.
Вот таблица соответствия окончаний интринсиков «симтипам»
Окончание интринсика «Симтип»
i8 b
i16 w
i32 i
i64 l
u8 ub
u16 uw
u32 ui
u64 ul
d d
s s
h h
Буковка p перед i8 означает packed, следовательно знак = необходимо окружить вертикальными чертами. И добавляем v, т.к. мнемоника инструкции начинается на v. В результате получается вот такая симкоманда:
ymm1b v|=| unpacklo(ymm2, ymm0)
[Для примера, SSE-команде PUNPCKLBW xmm1, xmm2 соответствует симкоманда xmm1b |.=| unpacklo(xmm2).]

Теперь открываем документацию по команде vphaddw. Идём в раздел ‘Intel C/C++ Compiler Intrinsic Equivalents’. Находим соответствующий ей интринсик:
VPHADDW __m256i _mm256_hadd_epi16 (__m256i a, __m256i b)
Т.к. соответствующий интринсик оканчивается на i16, то используем тип w. В остальном всё аналогично.
В результате, команде VPHADDW ymm1,ymm2,ymm3 соответствует симкоманда ymm1w v|=| hadd(ymm2, ymm3).
[И аналогично SSE-команде PHADDW xmm1, xmm2 соответствует симкоманда xmm1w |.=| hadd(xmm2).]

Ну и последний недостающий элемент этого пазла: если регистров назначения не один, а несколько, тогда нужно просто перечислить их через запятую:
<рег1>, <рег2> .= <операция>(<операнды-источники...>)

Симкоманды FPU


Несмотря на то, что сопроцессор безнадёжно устарел и современными программами практически не используется, он полностью поддерживается в long mode и обеспечивает возможность вычислений с большей точностью, чем SSE/AVX. Поэтому, просто на всякий случай, я решил включить в симкод и команды сопроцессора.
Изначально, я хотел оставить обозначение регистров сопроцессора в стиле Intel. Но в процессе написания этой статьи всё-таки передумал. Т.к. в традиционной ассемблерной записи увидев мнемонику команды, начинающуюся на f, можно догадаться, что это инструкция сопроцессора, а, следовательно, и регистрами она оперирует сопроцессорными. В таком контексте название регистров сопроцессора st(i) вполне логично (st означает stack, т.к. регистры FPU организованы в виде стека). Но в симкоде операции задаются математическими/программистскими символами и в записи st(0) += st(2) уже не очевидно, что речь идёт об FPU-инструкции, и пришлось бы писать что-то вроде st(0) f+= st(2) для большей ясности. Поэтому я решил добавить префикс f для обозначения регистров стека сопроцессора.
Ну и скобочки эти [в названиях регистров сопроцессора] сбивают с толку. Они создают ощущение, что внутрь можно прописать какой-то целочисленный регистр, хотя по факту там может быть только целочисленная константа от 0 до 7.
fld REAL8 PTR [esp+8] fst.load(8bytes[esp+8])
fld st(2) fst.load(fst2)
fldz fst.load(0)
fld1 fst.load(1)
fldpi fst.load_pi() (не fst.load(pi), т.к. есть fld pi)
fldl2t fst.load_log2(10)
fldl2e fst.load_log2(e)
fldlg2 fst.load_lg(2)
fldln2 fst.load_ln(2)
fst REAL8 PTR [esp] 8bytes[esp] = fst0
fstp REAL8 PTR [esp] 8bytes[esp] = fst.pop()
fstp st fst.pop()
fincstp fst.top++
fdecstp fst.top--
fst st(2) fst2 = fst0
fstp st(2) fst2 = fst.pop()
fxch st(2) fst0 >< fst2 (примечание)
fadd st(0), st(2) fst0 += fst2
fmulp st(1), st(0) fst1 *= fst.pop()
fdivp st(1), st(0) fst1 /= fst.pop()
fdivrp st(1), st(0) fst1 \= fst.pop()* или fst1 = fst.pop() / fst1
fbld [bcd_num] fst.load_bcd([bcd_num])
fild QWORD PTR [esp+8] fst.load_int(8bytes[esp+8])
fist DWORD PTR [esp] 4bytes[esp] = int(round(fst0))
fistp QWORD PTR [esp] 8bytes[esp] = int(round(fst.pop()))
fisttp QWORD PTR [esp] 8bytes[esp] = int(fst.pop())
fcom st(2) fpu.sw = fst0 <=> fst2 (результат в sw — status word)
ftst fpu.sw = fst0 <=> 0
fucom st(2) fpu.sw = fst0 uo<=> fst2
fcomp st(2) fpu.sw = fst.pop() <=> fst2
fcompp fpu.sw = fst.pop() <=> fst1, fst.pop()
fcomi st, st(2) fst0 <=> fst2
fcomip st, st(2) fst.pop() <=> fst2
fucomi st, st(2) fst0 uo<=> fst2
fcmovb st(0), st(1) fst0 = fst1 if u<
frndint fst0 = round(fst0)
fsqrt fst0 = sqrt(fst0)
fabs fst0 = abs(fst0)
fchs fst0 = -fst0
fsin fst0 = sin(fst0)
fsincos fst1:fst0 = sincos(fst0) или
fst1,fst0 = sincos(fst0) или
fst.load(cos(fst0)), fst1 = sin(fst1)
fstsw ax ax = fpu.sw
fstcw mem [mem] = fpu.cw
fldcw mem fpu.cw = [mem]
Запись fst.pop() навеяна Python-овским методом pop(), который извлекает последний элемент списка и возвращает его. Только извлечение происходит после выполнения симкоманды, а не в момент "вызова" pop(). А то иначе симкоманда fst1 *= fst.pop() соответствовала бы команде ассемблера fmulp st(2), st(0).

Вызов функций


Соглашения вызова x86-64 используют преимущество появившихся в этой архитектуре дополнительных 8-ми регистров [как общего назначения, так и векторных, т.е. SSE-регистров] для передачи первых 4-х [в Microsoft x64] или 6-8-ми [в System V AMD64 ABI] аргументов вызываемой функции в регистрах. Процессору от такого соглашения стало легче, а вот программисту, читающему ассемблерный код, — тяжелее.

Дабы облегчить жизнь последнему, симкоманды вызовов функций содержат имена регистров, которые соответствуют переданным аргументам функции.
Например, вызов функции func с тремя целочисленными аргументами 1, 2 и 3 в симкоде записывается так:
rcx = 1            // mov rcx, 1
rdx = 2            // mov rdx, 2
r8  = 3            // mov r8,  3
func(rcx, rdx, r8) // call func
//   ^^^^^^^^^^^^ — просто подсказка для читающего код

Для вызова функции без аргументов используется запись func(), а если количество аргументов неизвестно, то func(...).

Если аргументов у функции много, то не поместившиеся в регистрах передаются через стек, при этом количество таких аргументов обозначается последним числом в списке аргументов:
func(rcx, rdx, r8, r9, 2)
//                     ^ — количество параметров/аргументов функции, переданных на стеке

Также симкод позволяет указать регистр, где окажется возвращённое функцией значение:
func(rcx, rdx, r8, r9, 2) -> xmm0s
//                           ^^^^^ — регистр с возвращённым значением функции
Почему не xmm0s = func(rcx, rdx, r8, r9, 2)?
Запись <регистр> = <имя>(...) зарезервирована для «интринсиков», например xmm0d = sqrt(xmm0d) [что обозначает инструкцию sqrtsd xmm0, xmm0, а вызов функции sqrt в симкоде пишется так: sqrt(xmm0d) -> xmm0d].

Если функция возвращает вектор чисел, занимающих весь SSE/AVX-регистр, то имя регистра окружается вертикальными чертами:
func(rcx, rdx, r8, r9, 2) -> |xmm0s|
А если только часть регистра, то используется синтаксис срезов:
func(rcx, rdx, r8, r9, 2) -> xmm0s[0:2]

Если функция не возвращает значение, это можно указать явно:
func(rcx, rdx, r8, r9, 2) -> void

Соответствие регистров переменным


Одной из наиболее трудоёмких вещей при чтении ассемблерного кода является понимание того, в каких регистрах какие переменные находятся в конкретной строке кода.
Я предлагаю обозначать соответствие регистров переменным в симкоде следующим образом. Каждая симкоманда вместо названий регистров будет использовать соответствующее регистру имя переменной в <угловых> или {фигурных} скобках. При этом перед симкомандой идёт расшифровка использованных переменных, т.е. названия соответствующих регистров. Расшифровка отделяется от симкоманды вертикальной чертой или символом ¦.
Как выглядит такая запись рассмотрим на конкретных примерах.
int compare(int a, int b)
{
    return a < b ? -1 : a > b ? 1 : 0;
}
eax = 0
edi <=> esi
edx = -1
al  = 1   if > else 0
eax = edx if <
ret
Этот код на Си я уже приводил ранее. И соответствующий симкод тоже.
А вот как выглядит симкод, использующий имена переменных (аргументов функции) с расшифровкой:
eax     ¦ {.result} = 0
edi esi ¦ {a} <=> {b}
edx     ¦ {@temp} = -1
al      ¦ {.result}b = 1 if > else 0
eax edx ¦ {.result} = {@temp} if <
          ret

Рассмотрим пример чуть посложнее.
Вот функция расчёта факториала, взятая отсюда.
int factorial(int n)
{
    if (n == 0) {
        return 1;
    }

    int result = 1;
    for (int c = 1; c <= n; ++c) {
        result = result * c;
    }

    return result;
}
Компилятор GCC с опцией -Os генерирует для этой функции почти [я переставил лишь одну инструкцию] такой код:
        mov     eax, 1
        test    edi, edi
        je      .L1
        mov     edx, 1
.L3:
        cmp     edx, edi
        jg      .L1
        imul    eax, edx
        inc     edx
        jmp     .L3
.L1:
        ret
А теперь взгляните на симкод с расшифровкой:
eax     ¦ {.result} = 1
edi     ¦ {n} == 0 : .L1
edx     ¦ {c} = 1
.L3:
edx edi ¦ {c} !<= {n} : .L1
eax edx ¦ {.result} *= {c}
edx     ¦ {c}++
          :.L3
.L1:
          ret

Неплохо улучшилась читаемость, правда? [По сравнению с традиционным ассемблерным кодом.]

Для чего вообще заключать имена переменных в фигурные скобки?
Во-первых, для избежания конфликтов с именами регистров и другими зарезервированными идентификаторами симкода.
И во-вторых [что даже более важно], в фигурных скобках может быть не просто имя переменной, но и промежуточное выражение, которое в переменные не записывается:
xmm0s xmm0s       ¦ {4*a} = {a} * 4bytes[.LC0+rip] // .LC0 = 4.0
xmm0s xmm0s xmm2s ¦ {4*a*c} = {4*a} * {c}
xmm1s xmm1s xmm1s ¦ {b*b} = {b} * {b}
xmm0s xmm1s xmm0s ¦ {.result} = {b*b} - {4*a*c}
                    ret
(Данный симкод соответствует функции float d(float a, float b, float c) { return b*b - 4*a*c; })

Особенности реализации


Консольная утилита symasm имеет режим --annotate, который выдаёт тот же ассемблерный код, который подаётся на вход, только с симкодом в комментариях (к каждой инструкции прилагается соответствующая ей симкоманда).

Но по умолчанию [без указания режима] консольная утилита пытается «угадать», что хочет от неё пользователь. Это избавляет от необходимости запоминать названия опций командной строки.
Если ей на вход передаётся ассемблерный код (в любом поддерживаемом синтаксисе: Intel [MASM] или AT&T), то используется режим --annotate.
Если передаётся симкод, то он транслируется в ассемблер с Intel-синтаксисом (режим --translate).
В случае отсутствия аргументов командной строки [и без переназначения стандартного потока ввода] утилита запускается в интерактивном режиме (что-то вроде REPL), в котором:
  • каждая введённая ассемблерная команда (в любом поддерживаемом синтаксисе: Intel или AT&T) тут же переводится в симкоманду и выводится в консоль;
  • каждая введённая симкоманда транслируется в команду ассемблера с Intel-синтаксисом;
  • если строка ввода начинается или заканчивается символом ?, тогда выводится справка по введённой строке.

    Работает это как-то так:
     ввод: !o : skip_int_4
    вывод: jno skip_int_4
    
     ввод: ?jno
    вывод:  JNO rel8/rel32 — jump if not overflow (OF=0)
    
     ввод: ?rel8
    вывод: A relative offset, encoded as a signed, 8-bit immediate value,
           that is generally specified as a label in assembly code.
    
     ввод: ?OF
    вывод: Overflow flag (bit 11 of the EFLAGS register) — Set if the integer result
           is too large a positive number or too small a negative number (excluding
           the sign-bit) to fit in the destination operand; cleared otherwise. This
           flag indicates an overflow condition for signed-integer (two's complement)
           arithmetic.
    
     ввод: ?EFLAGS
    вывод:
    
    . . .
    The 32-bit EFLAGS register contains a group of status flags, a control flag,
    and a group of system flags.
    
    The next figure shows the most commonly used flags of EFLAGS.
    
                          11 10 7 6 2 0
    ┌─────────────────────┬──┬─┬─┬─┬─┬─┐
    │                     │O │D│S│Z│P│C│
    │                     │F │F│F│F│F│F│
    └─────────────────────┴┬─┴┬┴┬┴┬┴┬┴┬┘
                           │  │ │ │ │ │
    S Overflow Flag (OF)───┘  │ │ │ │ │
    C Direction Flag (DF)─────┘ │ │ │ │
    S Sign Flag (SF)────────────┘ │ │ │
    S Zero Flag (ZF)──────────────┘ │ │
    S Parity Flag (PF)──────────────┘ │
    S Carry Flag (CF)─────────────────┘
    
    S Indicates a Status Flag
    C Indicates a Control Flag
    
    Show all flags?
    

Да, я планирую включить в symasm интерактивный справочник по всем используемым командам ассемблера архитектур x86/x86-64/ARM64.

В век переизбытка информации приходится вырабатывать новые подходы для представления знаний, в том числе знаний об архитектуре современных процессоров.
Прошли те времена, когда можно было взять в руки 200-страничный «PDP-11/40 Processor Handbook» от DEC, откинуться на спинку кресла и неторопливо, страницу за страницей, прочитать его полностью, восхищаясь гениальностью разработчиков архитектуры PDP-11.
Последний «Intel® 64 and IA-32 Architectures Software Developer's Manual Combined Volumes» — это талмуд на 5000 страниц, который читать полностью нет никакого смысла и который никто распечатывать не будет, и оформлен он в PDF исключительно по архаическим причинам. Причём, очевидно, PDF-документ этот в Intel не верстается вручную, а генерируется на основе каких-то более machine-friendly исходных данных, которыми Intel не хочет поделиться, а выдаёт только такой вот талмуд, уже на основе которого "механически" энтузиасты производят что-то более-менее практичное, как например вот этот справочный сайт:
It's been mechanically separated into distinct files by a dumb script. It may be enough to replace the official documentation on your weekend reverse engineering project, but for anything where money is at stake, go get the official and freely available documentation.

Собственно, я предлагаю сделать нечто ещё гораздо более практичное: полноценный software developer's manual в интерактивной форме. (Причём manual\справочник не по всем возможностям архитектуры x86, а только по актуальным, т.к. по информации от одного из бывших сотрудников Intel: «из существующей X86 ISA сейчас используется около 20%. Остальное – пережитки прошлого.»)

Интерактивный справочник особенно полезен для симкоманд, т.к. их проблематично искать в поисковых системах, в отличие от традиционных ассемблерных команд, которые можно искать просто вбив в поисковик мнемонику команды.
Пример вывода справки по симкоманде `eax >< ecx`
 ввод: eax >< ecx?
вывод: └┬┘ └┤ └┬┘
        │   │  └─ counter register     (type `ecx?` for more info)
        │   └──── eXchange operator    (type `><??` for rationale)
        └──────── accumulator register (type `eax?` for more info)
       Exchanges the contents of eax and ecx.

 ввод: ecx?
вывод: ECX is a lower 32-bit part of the RCX register, a counter for string and loop operations.

       It is [implicitly] used in the following instructions:
       • Bit index for shift and rotate instructions (SAL/SHL, SAR, SHR, SHLD, SHRD).
       • Iteration count for loop (LOOP, LOOPE, LOOPNE).
       • Repeated string instructions (REP, REPE/REPZ, REPNE/REPNZ).
       • Jump conditional if zero (JRCXZ, JECXZ, JCXZ).
       • CPUID.

       According to the Intel ABI, Microsoft x64, and System V AMD64 ABI, the ECX/RCX is a caller-saved (volatile) register.
       In Microsoft x64, RCX denotes the first [integer] argument of the called function.
       In the System V AMD64 ABI, RCX denotes the fourth [integer] argument of the called function.

 ввод: eax?
вывод: EAX is a lower 32-bit part of the RAX register, an accumulator for operands and results data.

       This register is used to store the result of a function in all calling conventions.

       It is [implicitly] used in the following instructions:
       • Operand for decimal arithmetic, multiply, divide, string, compare-and-exchange, table-translation,
         and I/O instructions (MUL, IMUL, DIV, IDIV, CMPXCHG, CMPXCHG8B, CMPXCHG16B, IN*, OUT*, [-...-]).
       • Special sign extension instructions (CWD, CDQ, CQO, CBW, CWDE, CDQE).
       ...
       • CPUID.
(Пока ещё вывод справки по симкомандам не реализован. Т.к. необходимо продумать унифицированную архитектуру фронтенда транслятора симкода ("сим-ассемблера") с поддержкой как минимум трёх бэкендов: MASM, машинный код и вывод справки, а также с продвинутым выводом ошибок — например для ошибочной симкоманды eax <<= bl необходимо сообщить, что bl тут использовать нельзя, а можно только cl или целочисленную 8-разрядную константу.)

Интерактивный режим работы консольной утилиты symasm (включая интерактивный справочник) уже сейчас доступен online и работает полностью на стороне клиента (локально в браузере) благодаря Brython.

Сама же утилита symasm (ссылка на репозиторий) написана на «компилируемом Python» — подмножестве Python, которое компилируется в нативный код посредством транспайлера Python → 11l → C++.

Реализация перевода ассемблерного кода (как синтаксиса Intel [MASM], так и AT&T) в симкод практически завершена. Осталось реализовать только перевод в обратную сторону — из симкода в традиционный язык ассемблера и в машинный код.
Как именно лучше всего делать генерацию машинного кода — я пока ещё точно не решил. Но опираться только на официальную документацию Intel [и затем проверять сгенерированный машинный код на корректность по результату его работы на реальном процессоре] — это будет очень долго и мучительно. Раньше, когда документация была не такая большая, возможностей процессора было существенно меньше и когда времени было больше, так можно было делать, но не сейчас. Нужно искать какой-то более прогрессивный подход, с учётом современных реалий. Также можно воспользоваться идеей Юрия, изложенной им в статье ‘Надёжные программы из ненадёжных компонентов’, а именно: проверять сгенерированный машинный код несколькими различными ассемблерами — например, MASM, FASM и NASM. Если машинный код, сгенерированный сим-ассемблером, совпадает хотя бы с двумя из этих ассемблеров, тогда можно считать, что этот машинный код корректный. Потому что вероятность того, что разработчики MASM, FASM и NASM ошиблись так, что их ассемблеры сгенерировали одинаковый некорректный машинный код, очень мала. [Правда, Юрий предлагал реализовать функциональность тремя способами, а я предлагаю брать уже готовые реализации. И использовать их для повышения надёжности.]
Ещё может пригодиться информация, которую я получил от Томаша Грыштара (создателя fasm). Я задал ему следующие три вопроса:
  1. Can you briefly describe the fasm development process [especially in the early stages]?
    \
    Можете ли вы кратко описать процесс разработки fasm [особенно на ранних этапах]?
  2. What documentation did you use? Was it the official Intel Software Developer's Manuals or some unofficial docs?
    \
    Какую документацию вы использовали? Это были официальные руководства Intel для разработчиков программного обеспечения или какая-то неофициальная документация?
  3. How do you check the machine code generated by fasm for correctness? [I couldn't find any tests in the fasm repository.]
    \
    Как вы проверяете машинный код, сгенерированный fasm, на корректность? [Я не нашёл никаких тестов в репозитории fasm].
Он ответил, что:
  1. Ранняя история fasm обсуждалась в этой теме (а также в той, на которую она ссылается).
  2. Первоначально он использовал OPCODES.LST из списка прерываний Ральфа Брауна (здесь он упоминал об этом). Но вскоре после этого он начал использовать официальные руководства Intel (начиная с 80386.TXT).
  3. Он использовал автоматизированный процесс для выявления несоответствий при повторной сборке примеров и проектов. Позже он начал использовать свой фреймворк IEV, особенно для тестирования новых реализаций fasmg/fasm2 в сравнении с fasm 1 в качестве эталона.

Заключение


Если верить моим замерам, то  символьная запись (большая часть которой описана в данной статье) покрывает более 86% ассемблерного кода таких достаточно больших проектов, как компилятор g++-9. А если приплюсовать к символьной записи четыре наиболее часто встречающиеся ассемблерные инструкции, которые не имеют символьной записи — push, pop, ret и nop, то покрытие увеличится до 99%.
Программный проект Символьная запись Символьная запись плюс
push, pop, ret и nop
GNU C++ 9.4 86.8% 99.87%
The GNU MP Library 10.3.2 (библиотека для вычислений произвольной точности над целыми и вещественными числами, содержит достаточно большое количество оптимизированного ассемблерного кода) 89.9% 99.81%
The GNU Scientific Library 23.0.0 (большая библиотека для численных вычислений в прикладной математике и науке) 88% 99.88%
Python 3.6.8 [Win32] 77.2% 99.77%
Python 3.11.3 [Win32] 77.8% 99.84%
Python 3.11.3 [Win64] 94.78% 99.98%
Intel Math Kernel Library 10.2.6.037 79.9% 98.61%
Я не стал включать в данную статью директивы объявления данных, т.к. более наглядного [по сравнению с традиционным] обозначения для них я пока не нашёл.
Также я обошёл вниманием некоторые не достаточно распространённые наборы инструкций, такие как BMI, AMX и даже AVX-512.
[Хотя для AVX-512 в принципе уже есть достаточно хорошее обозначение:
vaddps zmm7 {k6}{z}, zmm2, zmm4, {rd-sae}
можно записать в симкоде так:
zmm7{k6}{z} v|=|{rd-sae} zmm2 + zmm4
Это вполне очевидная запись. Хочу заметить только, что 4-й операнд {rd-sae} вынесен вперёд, т.к. он является скорее свойством операции (и расшифровывается как round down + suppress all exceptions), а не операндом.
]

Вообще, если углубиться в эту тему, то можно с грустью заметить, что скорость добавления (одной только компанией Intel и только к архитектуре x86) новых инструкций и новых фич к старым инструкциям такова, что чтобы хорошо разбираться во всём этом, нужно посвятить этому всё своё свободное [от работы] время. А я к такому, во-первых, не готов. И, во-вторых, не вижу в этом никакого смысла. Т.к. над «работой» по добавлению новых инструкций трудятся много тысяч инженеров Intel вовсе не потому, что это прям какие-то реально полезные инструкции. А просто такая у них работа. Нужно же как-то оправдывать занимаемую высокооплачиваемую должность и удовлетворять менеджеров и маркетологов. И пытаться угнаться за всеми «новшествами» процессоростроителей и бесконечно играть в «догонялки» у меня желания нет.

Послесловие


Символьная запись подавляющего большинства часто используемых ассемблерных команд [вместо буквенных обозначений] существенно сокращает привязку к английскому языку.
Не уверен, что это заинтересует многих читателей Хабра, но кому-то может показаться интересной идея русификации ассемблера симкода и составления русской документации по архитектуре x86-64.
Дело в том, что в документации Intel довольно много неактуальных и ненужных терминов и понятий (и, кстати, в IT-отрасли в целом, как я считаю). И переводить на русский имеет смысл только актуальные и реально нужные. И главная цель такой русификации даже не в самом переводе [вообще-то имеет смысл писать такую документацию параллельно на английском и на русском], а в выделении только нужных вещей, оставив все бесполезные/устаревшие понятия на английском.

Но, если с документацией пока ещё не очень понятно (ей должны заниматься эксперты в области низкоуровневого программирования), то поучаствовать в русификации симкода я уже сейчас приглашаю всех желающих.
Я составил небольшой [на ~100 строк] текстовый файл с названиями регистров x86-64 и с симкомандами на английском языке, которые покрывают >99% ассемблерного кода типичных пользовательских программ. В каждой строке вместо символа точки (.) можно написать свой вариант перевода (не обязательно переводить прям весь файл, лучше, наоборот, ограничиться только теми строками, где вы хотя бы примерно понимаете, что они означают).
В принципе, у меня уже есть достаточно хороший вариант перевода. Но пока что разглашать я его не буду. И всем желающим поучаствовать тоже рекомендую не подглядывать на варианты других участников.
Данный текстовый файл размещён на пяти различных платформах для работы с исходным кодом. Выбирайте платформу, на которой вы уже зарегистрированы и которая вам больше нравится:
Для редактирования файла можно нажать кнопку с изображением карандаша или надписью ‘Edit’ (в GitFlic правда такой возможности нет).
Создавать ‘Pull request’\‘Запрос на слияние’, в принципе, не обязательно. Главное, чтобы ваш форк был публичным.
Tags:
Hubs:
Total votes 56: ↑51 and ↓5+61
Comments50

Articles