В одном из моих докладов по ассемблеру я показал список из 20 самых часто исполняемых команд на среднем десктопе x86 с Linux. Разумеется, в этом списке были привычные mov, add, lea, sub, jmp, call и так далее; неожиданным стало включение в него xor — «eXclusive OR». В эпоху, когда я занимался хакингом на 6502, наличие XOR было почти абсолютно точным указанием на то, что найдена часть кода, связанная с шифрованием, или какая-то подпрограмма обработки спрайтов. Поэтому удивительно, что машина с Linux, просто занимающаяся своими делами, выполняет такое количество этих команд.
Но потом мы вспоминаем о том, что компиляторы любят генерировать xor при присвоении регистру нулевого значения:
int main() {
return 0;
}

Мы знаем, что XOR любого значения с самим собой даёт ноль, но почему компилятор генерирует такую последовательность?
В показанном ниже примере я выполнял компиляцию с -O2 и включил опцию Compiler Explorer «Compile to binary object», чтобы можно было увидеть машинный код, который видит CPU, и в частности:
31 c0 xor eax, eax
c3 ret
Если снизить уровень оптимизации GCC до -O1, то мы увидим следующее:
b8 00 00 00 00 mov eax, 0x0
c3 ret
Гораздо более понятная и раскрывающая своё предназначение команда mov eax, 0, записывающая в регистр EAX ноль, занимает пять байт, в то время как версия с XOR занимает всего два. Благодаря использованию чуть менее понятной команды мы экономим три байта каждый раз, когда нужно присвоить регистру нулевое значение, что происходит довольно часто. Экономия байтов уменьшает размер программы и повышает эффективность использования кэша команд.
Но и это ещё не всё! Так как это очень частая операция, CPU x86 замечают эту «идиому обнуления» на ранних этапах конвейера и могут оптимизироваться конкретно под неё: системы отслеживания исполнения с изменением очерёдности знают, что значение «eax» (или какого-то ещё обнуляемого регистра) не зависит от предыдущего значения eax, поэтому они могут распределить свежий, не имеющий зависимостей слот переименования нулевого регистра. И сделав это, они удаляют операцию из очереди исполнения, то есть xor занимает ноль тактов исполнения! [Однако ей всё равно нужно завершиться, поэтому некоторые ресурсы процессора всё равно распределяются для её учёта.] По сути, CPU благодаря оптимизации устраняет её!
Вы можете задаться вопросом, почему мы встречаем xor eax, eax, но никогда не видим xor rax, rax (его 64-битную версию) даже при возврате long:
long get_zero_long() {
return 0;
}

В этом случае, даже несмотря на то, что rax необходим для хранения полного 64-битного результата long, выполняя запись в eax, мы получаем удобный эффект: в отличие от других частичных записей в регистр, при записи в e-регистры наподобие eax архитектура без лишних затрат обнуляет старшие 32 бита. Поэтому xor eax, eax обнуляет все 64 бита.
Любопытно, что при обнулении «расширенных» нумерованных регистров наподобие (like r8) GCC использует вариант d (двойной ширины, то есть 32-битный):
extern void needs_many_longs(
long rdi, long rsi, long rdx,
long rcx, long r8, long r9);
void test() {
needs_many_longs(0, 0, 0, 0, 0, 0);
}

Обратите внимание, что используется xor r8d, r8d (32-битный вариант), хотя с префиксом REX (здесь 45) потребовалось бы то же количество байт для xor r8, r8 полной ширины. Возможно, это упрощает работу компиляторов, потому что clang поступает так же.
xor eax, eax снижает объём кода и время исполнения! Спасибо вам, компиляторы!
Комментарии (32)

Fedorkov
13.12.2025 10:20в отличие от других частичных записей в регистр, при записи в
e-регистры наподобиеeaxархитектура без лишних затрат обнуляет старшие 32 бита. Поэтомуxor eax, eaxобнуляет все 64 бита.Как я понимаю, при разработке 64-битной архитектуры уже было понимание, что частичные регистры (ah, al, ax) мешают внеочередному и спекулятивному исполнению (вызывают partial register stall), поэтому решили, что любая запись в e*x должна обнулять старшие 32 бита.

RainbowJose
13.12.2025 10:20До этой статьи я думал что у меня нет ни одного стыдного вопроса. Спасибо!

LaRN
13.12.2025 10:20Я так делал еще, когда под zx spectrum на asm писал. Там это было и экономия памяти и оптимизация производительности. А с xor удобно было стирать спрайты и восстанавливать фон не перересовывая весь экран, что ускоряло анимацию и убирало моргание экрана.

Jijiki
13.12.2025 10:20извините не ЦПУ, а поидее парсер из кода в ассемблер, еквиваленты парсятся из дерева в ассемблер как я понимаю. В это можно погрузиться написав 2 стадии - парсер/лексер, далее высокоуровневый ассемблер(промежуточный или байткод ) да да я ошибся, но по итогу эта стадия такая в целом), и далее высокоуровневый в ассемблер
тоесть имея промежуточный код можно оставаться на этапе интерпретатора или идти дальше и компилировать бинарник
что интересно файл с байт-кодом это не бинарник всё еще
например есть промежуток push 5, зная какая платформа можно сгенерировать её инструкцию, для этого нужно знать инструкции платформы ну тоесть будет либо смещение либо push 5

DrMefistO
13.12.2025 10:20Вы сами-то поняли что написали? Я вот нет.

Jijiki
13.12.2025 10:20да, потомучто не цпу генерирует инструкцию, а тот проход который зная платформу, зная инструкции, генерит это соответсвие на обнуление
ллвм имеет байткод = проомежуточный код, вы не генерируете без байткода код платформы
или вы по операции +(или 4*3+1-2) генерируете сразу ассемблер без промежутка?
ассемблер в ассемблер гнать проще
от байткода мы имеем адреса переходов и количество переменных(и текущее состояние стека) как минимум и удобно для отладки
тут даже не важно регистровая виртуальная машина или стековая, суть одна переносимость высокоуровнего языка в асемблер же
чтоб перенести код я считаю нужен асемблер высокоуровневый, его мы генерируем после лекс-парсинга, далее стоит выбор, или выполнить это на вм или перегнать в асемблер же, разве не так?
и как раз push 5 -> push 5 эквивалентно надо только перенести состояние стека на цпу как раз, а это уже не сложно если вм отрабатывает и отлаживаема намного удобнее чем сам ассемблер
мы не затрагиваем такую тему как генерация выходного кадра из компилятора gcc/clang ".S"
соотв. нужен синтаксис, дерево и байткод(вм), в асемблер

DrMefistO
13.12.2025 10:20Есть однозначное соответствие между xor eax, eax и байтами 31 c0 для x86. Никаких зависящих от компилятора, проходов (заднего, переднего и т.д.), виртуальных машин и их байткодов вещей нет.

Jijiki
13.12.2025 10:20а как тогда программа из такого вида
Скрытый текст
int fib(int n) { if (n < 1) { return n; } // int a = 0; int b = 1; int temp = 0; while (n > 1) { temp = a + b; a = b; b = temp; n--; } return b; } int main() { int result = 0; int counter = 0; int iterations = 100000; // 100 тысяч итераций while (counter < iterations) { result = fib(24); // Вычисляем fib(24) в каждой итерации counter++; } print(result); print(counter); return 0; }получает бинарник, окей допустим мы знаем адреса соотвествий, там на сколько помню еще ошибки выводит в консоль, и прочее

Jijiki
13.12.2025 10:20добавлю суть в том, что в статье явно приведён синтаксис
int main(){ < return 0; < } <тоесть какая-то программа соотнесла этот синтаксис с точкой входа прологом эпилогом и строкой xor
int возвращаемое значение из программы в ней return 0; и точка входа main у мейн еще аргументы могут быть, и она всё проверила помимо точки с запятой на конце
и за место этого синтаксиса отправила асемблер, вы пишите что у xor есть соответствие, но в сравнении со сборкой регистров, что сложнее, чем просто xor подставить, потомучто надо посчитать аргументы, возвращаемые значения, бывает сохранить состояния, причем нельзя же писать в 1 регистр, полюбому надо 2-3 регистра
тоесть на вход конечного автомата принимается выход из какого-то другого конечного автомата, тут как не крути не выходит всё просто

DrMefistO
13.12.2025 10:20Я понял о чём Вы, но Вы, похоже, скипнули заголовок и суть поста - xor eax, eax, а не Си-в-Азм (передать ноль).

Jijiki
13.12.2025 10:20я написал самый первый коментарий о том, что процессор ничего не делает, а только следует конвееру команд, которые поступают из потока который может называться программа у которой есть точка входа, и ведёт это к той программе а не к процессору, процессор только инструкции имеет, и знает как их выполнять, а решение принимает не процессор, какую ставить команду

Gradiens
13.12.2025 10:20Какой вы бестактный молодой человек;)
Напомнили олд-скульщикам про их возраст: этой фишке 20 лет в обед.

a2g
13.12.2025 10:20Препод в универе рассказал мне 20+ лет назад, что ему рассказывал его препод, что на новомодных тогда пнях такой фортель выполняется за ноль тактов. Побольше даже двадцати, получается)))

SIISII
13.12.2025 10:20Исключающее ИЛИ для обнуления широко использовалось и, например, на Системе 360, а это середина 1960-х. Так что не 20 лет, не 20... :)

goldexer
13.12.2025 10:20Да, фишка очень старая. Сейчас такие оптимизации скорее «по инерции». Сейчас простой экзешник с программой, отображающей пустое окно, в дефолтной конфигурации IDE, соберётся либо более чем в 1 мегабайт, либо в пятерку мелких файлов, которые надо таскать за собой, либо... Скучаю по тем временам, когда эти оптимизации действительно ценили, они имели вес

randomsimplenumber
13.12.2025 10:20Ну, мегабайт. И что ? На современном диске - 1/1000000 обьема. В процентном соотношении - все равно что 200 байт 30 лет назад.

Mingun
13.12.2025 10:20Но емкость дисков росла, чтобы это процентное отношение уменьшать, а не поддерживать постоянным...

randomsimplenumber
13.12.2025 10:20Ну, когда то диски были 200 Мб. 1/1 000 000 == 200 байт. 200 байт exe для windows 95 - в студию, плз.
Процентное отношение уменьшается.

SIISII
13.12.2025 10:20Сейчас -- отнюдь не "по инерции". Как в статье замечено, принудительное обнуление всего регистра облегчает жизнь многим (хотя и не всем) процессорам: благодаря этой операции логика, отвечающая за внеочередное выполнение команд, сразу знает, что обнулённый регистр не тянет зависимости от логически ранее расположенных команд в последующие, что нередко ускоряет выполнение.
Кроме того, практически у всех современных процессоров есть ограничения на выборку и декодирование команд: за один такт процессор может выбрать только 16 байтов, выровненных по естественной границе, и декодировать из их состава до четырёх команд одновременно. Соответственно, экономия на длине команды тоже важна: если у тебя не влезло четыре команды в эти 16 байт, процессор уже не сможет декодировать за такт именно четыре команды, что может создать "голод" для последующих стадий выполнения.

GidraVydra
13.12.2025 10:20Какая разница, какой размер исполняемого файла в абсолютном выражении? Важно только то, какую долю от числа операций составляет операция обнуления регистра. И есть подозрение, что эта доля не сильно изменилась с тех пор.

DrMefistO
13.12.2025 10:20А как тогда правильно выставить в ноль только младший дворд rax?

SIISII
13.12.2025 10:20Не исключено, что только с помощью какого-нибудь AND RAX, RBX, где в RBX лежит константа FFFF'FFFF'0000'0000. Надо читать доку на AMD64, а мне лениво.

jin_x
13.12.2025 10:20Чтобы не тратить драгоценные байты на
mov rdx,0xFFFFFFFF00000000(целых 10 байт), можно сделать например, вот так:mov edx,eax ; 2 байта xor rax,rdx ; 3 байта (можно `sub`)Ещё вариант (чуть длиннее, но без доп. регистров):
shr rax,32 ; 4 байта shl rax,32 ; 4 байтаНу и до кучи вариант обнуления 31 младшего бита (не 32-х):
and rax,0x80000000 ; 6 байт (тут происходит знаковое расширение константы до 64 бит)
SIISII
13.12.2025 10:20Чтобы не тратить драгоценные байты на
mov rdx,0xFFFFFFFF00000000Насколько помню, загрузить 64-разрядную константу можно только в RAX, в другие регистры нельзя.

jin_x
13.12.2025 10:20Есть множество способов обнулись регистр, например:
xor eax,eax ; а также pxor xmm0,xmm0; xorps, xorpd, vpxor, etc... sub eax,eax ; sbb, если cf=0 and eax,0 lea eax,[0] push 0 / pop eax salc ; al=0, если cf=0 cbw ; ah=0, если старший бит al=0; а также cwd, cdq, cqo xchg eax,ebx ; ax=0, если bx=0 и наоборот; mov ax,bx аналогично fldz ; fninit (не совсем обнуление, конечно, но как варик) vzeroall ; vzeroupper mov eax,eax ; старшая часть rax обнуляется mov eax,0 ; внезапно ; странные способы (обфускация, например): loop $ ; ecx=0; dec eax/jnz $-1 (для 32 битов); можно сделать rdtscp/inc ecx/loop $ (чтоб не гонять слишком долго) mul ecx ; eax=edx=0, если ecx=0; аналогично fmul, fmulp, mulps, pmul... mov ecx,-1 / div ecx ; eax=0, если edx=0; есть также divps и пр. aad 0 ; ah=0 aam 1 ; ah=0 aam 0 ; al=0 shr ax,16 ; shr ax,cl, если cl = 16..31; можно shl bzhi eax,eax,ecx ; если ecx=0 mov ecx,0FEh / rdmsr ; edx=0 mov eax,80000000h / cpuid ; ah=ebx=ecx=edx=0 movzx eax,al ; очищаем старшие 24 бита (56 в x64); есть ещё pmovzx in ax,dx ; если правильно выбрать порт ; если значение ax заранее известно и очень подходит под ситуацию, можно inc eax, dec eax, not eax, lodsb, scasw, bswap и т.д. ; если знаем, что в памяти, то можно lds, les, mov eax,[ebx], pop ecx (например, на старте com-программы), xlatb, lodsw и т.д. ; по любому есть ещё 100500 способов
bolk
Во времена, когда я занимался программированием на ассемблере x86 это был рядовой приём, чтобы уменьшить размер кода, писали его, не задумываясь. Неожиданно встретить про него статью на «Хабре».
aamonster
Угу. На 8080 был небольшой выбор: XOR A или SUB A (обе команды – 4 такта и 1 байт кода), а загрузка нуля командой MVI A, 0 – уже 7 тактов и два байта кода, так что о таком даже не задумываешься.
Siddthartha
в ту эпоху это был, прямо скажем, best practice, я бы сказал!) любой, кто пишет на ассемблере только так и обнулял).. даже школьники (по своему примеру могу сказать)
randomsimplenumber
Ичсх, на размер программы оно почти не влияет. Ну сколько раз вам необходимо обнулять EAX ?
SIISII
1) Не так редко, как иногда кажется.
2) На размер влияет не очень сильно, конечно, но если у тебя на всё про всё, скажем, 4 Кбайта памяти, то обычно считаешь каждый байт.
3) Сейчас считают не байты в ОЗУ и тем более на дисках, а байты в строках кэшей и байты, которые процессор способен прочитать из кэша и обработать за один такт -- а там счёт по-прежнему идёт на единицы и десятки байтов.
4) А иногда, наоборот, для производительности выгодней раздувать программу. Скажем, если у тебя на ПК четыре команды вместились в 15 байт, причём первая из них начинается по адресу, кратному 16, то для запуска в работу пяти команд процессору потребуется два или три такта: в первом такте он выберет эти 16 байт, обнаружит, начиная с их начала, четыре полные команды и запустит их, а во втором такте он вынужден будет выбрать те же 16 байт (они ещё не закончились), но обнаружит там лишь одну команду или вообще её начало (в последнем байте этой группы) и запустит только её. А если она многобайтовая, ему придётся прочитать (в третьем такте) следующие 16 байт, чтобы эту команду запустить.
А вот если эти четыре команды искусственно раздуть (добавив, например, ничего не значащий префикс к одной из них) до 16 байтов, то процессор в первом такте выберет те же 16 байт и запустит эти четыре команды, но в следующем будет считывать уже следующие 16 байт -- в которых, если повезёт, тоже будет четыре команды, которые он сможет запустить. В результате получится 8 команд за два такта, а не 5 команд за два или три такта.