В одном из моих докладов по ассемблеру я показал список из 20 самых часто исполняемых команд на среднем десктопе x86 с Linux. Разумеется, в этом списке были привычные  movaddleasubjmpcall и так далее; неожиданным стало включение в него 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)


  1. bolk
    13.12.2025 10:20

    В эпоху, когда я занимался хакингом на 6502, наличие XOR было почти абсолютно точным указанием на то, что найдена часть кода, связанная с шифрованием, или какая-то подпрограмма обработки спрайтов. 

    Во времена, когда я занимался программированием на ассемблере x86 это был рядовой приём, чтобы уменьшить размер кода, писали его, не задумываясь. Неожиданно встретить про него статью на «Хабре».


    1. aamonster
      13.12.2025 10:20

      Угу. На 8080 был небольшой выбор: XOR A или SUB A (обе команды – 4 такта и 1 байт кода), а загрузка нуля командой MVI A, 0 – уже 7 тактов и два байта кода, так что о таком даже не задумываешься.


    1. Siddthartha
      13.12.2025 10:20

      в ту эпоху это был, прямо скажем, best practice, я бы сказал!) любой, кто пишет на ассемблере только так и обнулял).. даже школьники (по своему примеру могу сказать)


      1. randomsimplenumber
        13.12.2025 10:20

        Ичсх, на размер программы оно почти не влияет. Ну сколько раз вам необходимо обнулять EAX ?


        1. SIISII
          13.12.2025 10:20

          1) Не так редко, как иногда кажется.

          2) На размер влияет не очень сильно, конечно, но если у тебя на всё про всё, скажем, 4 Кбайта памяти, то обычно считаешь каждый байт.

          3) Сейчас считают не байты в ОЗУ и тем более на дисках, а байты в строках кэшей и байты, которые процессор способен прочитать из кэша и обработать за один такт -- а там счёт по-прежнему идёт на единицы и десятки байтов.

          4) А иногда, наоборот, для производительности выгодней раздувать программу. Скажем, если у тебя на ПК четыре команды вместились в 15 байт, причём первая из них начинается по адресу, кратному 16, то для запуска в работу пяти команд процессору потребуется два или три такта: в первом такте он выберет эти 16 байт, обнаружит, начиная с их начала, четыре полные команды и запустит их, а во втором такте он вынужден будет выбрать те же 16 байт (они ещё не закончились), но обнаружит там лишь одну команду или вообще её начало (в последнем байте этой группы) и запустит только её. А если она многобайтовая, ему придётся прочитать (в третьем такте) следующие 16 байт, чтобы эту команду запустить.

          А вот если эти четыре команды искусственно раздуть (добавив, например, ничего не значащий префикс к одной из них) до 16 байтов, то процессор в первом такте выберет те же 16 байт и запустит эти четыре команды, но в следующем будет считывать уже следующие 16 байт -- в которых, если повезёт, тоже будет четыре команды, которые он сможет запустить. В результате получится 8 команд за два такта, а не 5 команд за два или три такта.


  1. Fedorkov
    13.12.2025 10:20

    в отличие от других частичных записей в регистр, при записи в e-регистры наподобие eax архитектура без лишних затрат обнуляет старшие 32 бита. Поэтому xor eax, eax обнуляет все 64 бита.

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


  1. RainbowJose
    13.12.2025 10:20

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


  1. LaRN
    13.12.2025 10:20

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


  1. Jijiki
    13.12.2025 10:20

    извините не ЦПУ, а поидее парсер из кода в ассемблер, еквиваленты парсятся из дерева в ассемблер как я понимаю. В это можно погрузиться написав 2 стадии - парсер/лексер, далее высокоуровневый ассемблер(промежуточный или байткод ) да да я ошибся, но по итогу эта стадия такая в целом), и далее высокоуровневый в ассемблер

    тоесть имея промежуточный код можно оставаться на этапе интерпретатора или идти дальше и компилировать бинарник

    что интересно файл с байт-кодом это не бинарник всё еще

    например есть промежуток push 5, зная какая платформа можно сгенерировать её инструкцию, для этого нужно знать инструкции платформы ну тоесть будет либо смещение либо push 5


    1. DrMefistO
      13.12.2025 10:20

      Вы сами-то поняли что написали? Я вот нет.


      1. Jijiki
        13.12.2025 10:20

        да, потомучто не цпу генерирует инструкцию, а тот проход который зная платформу, зная инструкции, генерит это соответсвие на обнуление

        ллвм имеет байткод = проомежуточный код, вы не генерируете без байткода код платформы

        или вы по операции +(или 4*3+1-2) генерируете сразу ассемблер без промежутка?

        ассемблер в ассемблер гнать проще

        от байткода мы имеем адреса переходов и количество переменных(и текущее состояние стека) как минимум и удобно для отладки

        тут даже не важно регистровая виртуальная машина или стековая, суть одна переносимость высокоуровнего языка в асемблер же

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

        и как раз push 5 -> push 5 эквивалентно надо только перенести состояние стека на цпу как раз, а это уже не сложно если вм отрабатывает и отлаживаема намного удобнее чем сам ассемблер

        мы не затрагиваем такую тему как генерация выходного кадра из компилятора gcc/clang ".S"

        соотв. нужен синтаксис, дерево и байткод(вм), в асемблер


        1. DrMefistO
          13.12.2025 10:20

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


          1. 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;
            }
            

            получает бинарник, окей допустим мы знаем адреса соотвествий, там на сколько помню еще ошибки выводит в консоль, и прочее


          1. Jijiki
            13.12.2025 10:20

            добавлю суть в том, что в статье явно приведён синтаксис

            int main(){ <
              return 0; <
            } <

            тоесть какая-то программа соотнесла этот синтаксис с точкой входа прологом эпилогом и строкой xor

            int возвращаемое значение из программы в ней return 0; и точка входа main у мейн еще аргументы могут быть, и она всё проверила помимо точки с запятой на конце

            и за место этого синтаксиса отправила асемблер, вы пишите что у xor есть соответствие, но в сравнении со сборкой регистров, что сложнее, чем просто xor подставить, потомучто надо посчитать аргументы, возвращаемые значения, бывает сохранить состояния, причем нельзя же писать в 1 регистр, полюбому надо 2-3 регистра

            тоесть на вход конечного автомата принимается выход из какого-то другого конечного автомата, тут как не крути не выходит всё просто


            1. DrMefistO
              13.12.2025 10:20

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


              1. Jijiki
                13.12.2025 10:20

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


    1. MicrofCorp
      13.12.2025 10:20

      Извините, но это бред


  1. Gradiens
    13.12.2025 10:20

    Какой вы бестактный молодой человек;)

    Напомнили олд-скульщикам про их возраст: этой фишке 20 лет в обед.


    1. a2g
      13.12.2025 10:20

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


    1. SIISII
      13.12.2025 10:20

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


  1. AlexKMK
    13.12.2025 10:20

    Ld de, 100
    Push de 
    Pop hl
    Inc hl
    Ld bc, 100
    Ldir

    Эх. Были времена


  1. goldexer
    13.12.2025 10:20

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


    1. randomsimplenumber
      13.12.2025 10:20

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


      1. Mingun
        13.12.2025 10:20

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


        1. randomsimplenumber
          13.12.2025 10:20

          Ну, когда то диски были 200 Мб. 1/1 000 000 == 200 байт. 200 байт exe для windows 95 - в студию, плз.

          Процентное отношение уменьшается.


    1. SIISII
      13.12.2025 10:20

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

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


    1. GidraVydra
      13.12.2025 10:20

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


  1. DrMefistO
    13.12.2025 10:20

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


    1. SIISII
      13.12.2025 10:20

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


      1. 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 бит)


        1. SIISII
          13.12.2025 10:20

          Чтобы не тратить драгоценные байты на mov rdx,0xFFFFFFFF00000000

          Насколько помню, загрузить 64-разрядную константу можно только в RAX, в другие регистры нельзя.


  1. 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 способов