Разрешен ли мусор в старших битах регистров параметров и возвращаемых значений в x86-64 SysV ABI?

SysV ABI x86-64 указывает, помимо прочего, как параметры функции передаются в регистрах (первый аргумент в rdi, затем rsi и так далее) и как целочисленные возвращаемые значения передаются обратно (в rax а потом rdx для действительно больших ценностей).

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

Например, для следующей функции:

void foo(unsigned x, unsigned y);

... x будет передано в rdi а также y в rsi, но они только 32-битные. Делать высокие 32-битные rdi а также rsi должен быть ноль? Интуитивно я бы сказал, что да, но код, сгенерированный всеми gcc, clang и icc, имеет специфический mov инструкции в начале обнулить старшие биты, поэтому кажется, что компиляторы предполагают обратное.

Аналогично, компиляторы, похоже, предполагают, что старшие биты возвращаемого значения rax может иметь биты мусора, если возвращаемое значение меньше 64 бит. Например, циклы в следующем коде:

unsigned gives32();
unsigned short gives16();

long sum32_64() {
  long total = 0;
  for (int i=1000; i--; ) {
    total += gives32();
  }
  return total;
}

long sum16_64() {
  long total = 0;
  for (int i=1000; i--; ) {
    total += gives16();
  }
  return total;
}

... скомпилировать в clang (и другие компиляторы похожи):

sum32_64():
...
.LBB0_1:                               
    call    gives32()
    mov     eax, eax
    add     rbx, rax
    inc     ebp
    jne     .LBB0_1


sum16_64():
...
.LBB1_1:
    call    gives16()
    movzx   eax, ax
    add     rbx, rax
    inc     ebp
    jne     .LBB1_1

Обратите внимание mov eax, eax после вызова, возвращающего 32 бита, и movzx eax, ax после 16-битного вызова - оба обнуляют старшие 32 или 48 бит соответственно. Таким образом, это поведение имеет определенную стоимость - тот же цикл, работающий с 64-битным возвращаемым значением, пропускает эту инструкцию.

Я очень внимательно прочитал документ ABI System V для архитектуры x86-64, но не смог найти, документировано ли это поведение в стандарте.

Каковы преимущества такого решения? Мне кажется, есть очевидные затраты:

Стоимость параметра

Затраты накладываются на реализацию вызываемого при работе со значениями параметров. и в функциях при работе с параметрами. Конечно, часто эта стоимость равна нулю, потому что функция может эффективно игнорировать старшие биты, или обнуление происходит бесплатно, поскольку могут использоваться инструкции размера 32-битного операнда, которые неявно обнуляют старшие биты.

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

uint32_t average(uint32_t a, uint32_t b) {
  return ((uint64_t)a + b) >> 2;
}

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

average(unsigned int, unsigned int):
        mov     edi, edi
        mov     eax, esi
        add     rax, rdi
        shr     rax, 2
        ret  

Полностью 2 из 4 инструкций (игнорируя ret) нужны только для обнуления старших бит. Это может быть дешево на практике с устранением mov, но все же это кажется большой ценой.

С другой стороны, я не могу увидеть аналогичную соответствующую стоимость для вызывающих абонентов, если бы ABI указывал, что старшие биты равны нулю. Так как rdi а также rsi и другие регистры передачи параметров являются пустыми (т. е. могут быть перезаписаны вызывающей стороной), у вас есть только пара сценариев (мы рассмотрим rdi, но замените его параметром reg на ваш выбор):

  1. Значение, переданное функции в rdi мертв (не нужен) в коде после вызова. В этом случае, какая инструкция была назначена последней rdi просто должен назначить edi вместо. Это не только бесплатно, но часто на один байт меньше, если вы избегаете префикса REX.

  2. Значение, переданное функции в rdi нужен после функции. В этом случае, так как rdi сохраняется для вызывающего абонента, вызывающий должен сделать mov значения в регистр, сохраненный вызываемым пользователем в любом случае. Обычно вы можете организовать его так, чтобы значение начиналось в сохраненном регистре вызываемого абонента (скажем, rbx) и затем перемещается в edi лайк mov edi, ebx так что ничего не стоит.

Я не вижу много сценариев, когда обнуление обходится клиенту очень дорого. Некоторые примеры были бы, если 64-битная математика необходима в последней инструкции, которая назначена rdi, Это кажется довольно редким, хотя.

Возврат стоимости

Здесь решение кажется более нейтральным. После того, как вызываемые абоненты убрали мусор, у него есть определенный код (иногда вы видите mov eax, eax инструкции, чтобы сделать это), но если разрешен мусор, расходы переносятся на вызываемого абонента. В целом, кажется более вероятным, что вызывающая сторона может очистить нежелательную память бесплатно, поэтому допуск мусора не кажется в целом вредным для производительности.

Я предполагаю, что один интересный вариант использования этого поведения состоит в том, что функции с различными размерами могут иметь одинаковую реализацию. Например, все следующие функции:

short sums(short x, short y) {
  return x + y;
}

int sumi(int x, int y) {
  return x + y;
}

long suml(long x, long y) {
  return x + y;
}

На самом деле может использовать одну и ту же реализацию 1:

sum:
        lea     rax, [rdi+rsi]
        ret

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

1 ответ

Решение

Похоже, у вас есть два вопроса здесь:

  1. Нужно ли обнулять старшие биты возвращаемого значения перед возвратом? (И нужно ли обнулять старшие биты аргументов перед вызовом?)
  2. Каковы затраты / выгоды, связанные с этим решением?

Ответ на первый вопрос - нет, в верхах может быть мусор, и Питер Кордес уже написал очень хороший ответ на эту тему.

Что касается второго вопроса, я подозреваю, что оставление неопределенных битов в целом лучше для производительности. С одной стороны, значения с расширением нуля заранее не обходятся без дополнительных затрат при использовании 32-битных операций. Но с другой стороны, обнуление старших битов не всегда необходимо. Если вы разрешаете мусор в старших битах, то вы можете оставить его на усмотрение кода, который получает значения, чтобы выполнять нулевые расширения (или расширения знака) только тогда, когда они действительно необходимы.

Но я хотел бы подчеркнуть еще одно соображение: безопасность

Утечки информации

Когда верхние биты результата не очищаются, они могут сохранять фрагменты других фрагментов информации, таких как указатели функций или адреса в стеке / куче. Если когда-либо существует механизм для выполнения функций с более высоким уровнем привилегий и получения полного значения rax (или же eax), то это может привести к утечке информации. Например, системный вызов может привести к утечке указателя из ядра в пространство пользователя, что приведет к поражению ядра ASLR. Либо механизм IPC может привести к утечке информации о адресном пространстве другого процесса, что может помочь в разработке прорыва в песочнице.

Конечно, можно утверждать, что ABI не несет ответственность за предотвращение утечек информации; Программист должен правильно реализовать свой код. Хотя я и согласен, обязательство компилятора обнулить старшие биты все равно приведет к устранению этой конкретной формы утечки информации.

Вы не должны доверять своему вкладу

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

unsigned char buf[256];
...
__fastcall void write_index(unsigned char index, unsigned char value) {
    buf[index] = value;
}

Если бы нам было позволено предположить, что index его верхние биты обнулены, тогда мы могли бы скомпилировать вышеприведенное как:

write_index:  ;; sil = index, dil = value
    mov rax, offset buf
    mov [rax+rsi], dil
    ret

Но если бы мы могли вызвать эту функцию из нашего собственного кода, мы могли бы предоставить значение rsi вне [0,255] диапазон и запись в память за пределами буфера.

Конечно, компилятор на самом деле не будет генерировать код, подобный этому, поскольку, как упоминалось выше, вызывающий объект несет ответственность за обнуление или расширение знака своих аргументов, а не за счет вызывающего. Я думаю, это очень практичная причина, по которой код, который получает значение, всегда предполагает, что в верхних битах есть мусор, и явно удаляет его.

Другие вопросы по тегам