В программировании на ассемблере довольно часто требуется вычислить что-то из младших битов регистра, которые не гарантируют обнуление других битов. В языках более высокого уровня, таких как C, вы просто приводите свои входные данные к небольшому размеру и позволяете компилятору решить, нужно ли ему обнулять верхние биты каждого входного сигнала отдельно или он может оpipeить верхние биты результата после факт.
Это особенно характерно для x86-64 (он же AMD64) по разным причинам 1, некоторые из которых присутствуют в других ISA.
Я буду использовать 64-битную x86 в качестве примера, но намереваюсь спросить/обсудить 2 дополнения и бинарную арифметику без знака в целом, так как все современные процессоры используют ее. (Обратите внимание, что C и C++ не гарантируют два дополнения 4, и что переполнение со знаком является неопределенным поведением.)
В качестве примера рассмотрим простую функцию, которая может компилироваться в инструкцию LEA
2. (В x86-64 SysV (Linux) ABI3 первые два аргумента функции находятся в rdi
и rsi
, с возвратом в rax
. int
является 32-битным тип.)
; int intfunc(int a, int b) { return a + b*4 + 3; }
intfunc:
lea eax, [edi + esi*4 + 3] ; the obvious choice, but gcc can do better
ret
gcc знает, что сложение, даже целых чисел со знаком минус, переносится только справа налево, поэтому старшие биты входных данных не могут влиять на то, что входит в eax
. Таким образом, он сохраняет байт инструкции и использует lea eax, [rdi + rsi*4 + 3]
Какие другие операции имеют это свойство младших битов результата, не зависящее от старших битов входов?
И почему это работает?
Сноски
1 Почему это часто встречается в x86-64: В x86-64 есть инструкции переменной длины, где дополнительный префиксный байт изменяет размер операнда (с 32 до 64 или 16), поэтому сохранение байта часто возможно в инструкциях, которые в противном случае выполняются с той же скоростью. Он также имеет ложные зависимости (AMD/P4/Silvermont) при записи младших 8b или 16b регистра (или остановку при последующем чтении полного регистра (Intel pre-IvB)): по историческим причинам пишет только до 32b подрегистров ноль, остальные из регистра 64b. Почти вся арифметика и логика могут использоваться на младших 8, 16 или 32 битах, а также на полных 64 битах регистров общего назначения. Целочисленные векторные инструкции также довольно неортогональны, некоторые операции недоступны для элементов некоторых размеров.
Кроме того, в отличие от x86-32, ABI передает аргументы функции в регистрах, и верхние биты не должны быть нулевыми для узких типов.
2 LEA: Как и в других инструкциях, размер операнда по умолчанию LEA составляет 32 бита, но размер адреса по умолчанию - 64 бита. Байт префикса размера операнда (0x66
или REX.W
) может сделать размер выходного операнда 16 или 64 бита. Байт префикса размера адреса (0x67
) может уменьшить размер адреса до 32 бит (в 64-битном режиме) или 16 бит (в 32-битном режиме). Таким образом, в 64-битном режиме lea eax, [edx+esi]
занимает на один байт больше, чем lea eax, [rdx+rsi]
.
Можно сделать lea rax, [edx+esi]
, но адрес все еще вычисляется только с 32 битами (перенос не устанавливает бит 32 в rax
). Вы получаете идентичные результаты с lea eax, [rdx+rsi]
, который на два байта короче. Таким образом, префикс размера адреса никогда не будет полезен с LEA
, так как комментарии в выводе разборки от Agner Fog предупреждают о превосходном дизассемблере objconv.
3 x86 ABI:
Вызывающая сторона не должна обнулять (или расширять знак) верхнюю часть 64-битных регистров, используемых для передачи или возврата меньших типов по значению. Вызывающий объект, который хотел использовать возвращаемое значение в качестве индекса массива, должен был бы расширить его со знаком (с помощью movzx rax, eax
или специальной инструкции case-to-eax cdqe
. (Не путать с cdq
, какой знак расширяет eax
в edx:eax
, например, чтобы установить для idiv
.))
Это означает, что функция, возвращающая unsigned int
, может вычислить свое возвращаемое значение в 64-битном временном значении в rax
и не требовать mov eax, eax
для обнуления старших битов в rax
. Это конструктивное решение работает в большинстве случаев: часто вызывающему абоненту не нужны никакие дополнительные инструкции, чтобы игнорировать неопределенные биты в верхней половине rax
.
4 C и C++
В частности, C и C++ не требуют двух дополнительных двоичных чисел со знаком (кроме типов C++ std::atomic
). Допускается также одно дополнение и знак/величина, поэтому для полностью переносимого C эти приемы полезны только для типов unsigned
. Очевидно, что для знаковых операций установленный бит знака в представлении знак/величина означает, что другие биты, например, вычитаются, а не добавляются. Я не проработал логику для одного дополнения
Однако бит-хаки, которые работают только с двумя дополнениями, широко распространены, потому что на практике больше никому нет дела. Многие вещи, которые работают с двумя дополнениями, должны также работать с одним дополнением, поскольку знаковый бит по-прежнему не меняет интерпретацию других битов: он просто имеет значение - (2 N -1) (вместо 2 N). Представление знака/величины не имеет этого свойства: значение места каждого бита является положительным или отрицательным в зависимости от знакового бита.
Также обратите внимание, что компиляторы C могут предполагать, что переполнение со знаком никогда не происходит, потому что это неопределенное поведение. Так, например компиляторы могут и предполагают, что (x+1) < x
всегда ложно. Это делает обнаружение переполнения со знаком довольно неудобным в C. Обратите внимание, что разница между беззнаковым переносом (переносом) и переполнением со знаком.