Код GCC, который, кажется, нарушает правила встроенной сборки, но эксперт считает иначе - PullRequest
5 голосов
/ 15 мая 2019

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

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

void *memset(void *dest, int value, size_t count)
{
    asm volatile  ("cld; rep stosb" :: "D"(dest), "c"(count) : "a"(value));
    return dest;
}

Я нашел пример в собственной операционной системе expert , где они пишут аналогичный код с тем же шаблоном проектирования. Они используют синтаксис Intel для их встроенной сборки. Этот хобби-код операционной системы работает в контексте ядра (ring0). Примером может служить функция замены буфера 1 :

void swap_vbufs(void) {
    asm volatile (
        "1: "
        "lodsd;"
        "cmp eax, dword ptr ds:[rbx];"
        "jne 2f;"
        "add rdi, 4;"
        "jmp 3f;"
        "2: "
        "stosd;"
        "3: "
        "add rbx, 4;"
        "dec rcx;"
        "jnz 1b;"
        :
        : "S" (antibuffer0),
          "D" (framebuffer),
          "b" (antibuffer1),
          "c" ((vbe_pitch / sizeof(uint32_t)) * vbe_height)
        : "rax"
    );

    return;
}

antibuffer0, antibuffer1 и framebuffer - все буферы в памяти, которые рассматриваются как массивы uint32_t. framebuffer - фактическая видеопамять (MMIO), а antibuffer0, antibuffer1 - буферы, выделенные в памяти.

Глобальные переменные правильно установлены перед вызовом этой функции. Они объявлены как:

volatile uint32_t *framebuffer;
volatile uint32_t *antibuffer0;
volatile uint32_t *antibuffer1;

int vbe_width = 1024;
int vbe_height = 768;
int vbe_pitch;

Мои вопросы и опасения по поводу этого вида кода

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

  1. RDI , RSI , RBX и RCX все изменяются этим кодом. RDI и RSI увеличиваются на LODSD и STOSD неявно. Остальные явно изменены с помощью

        "add rbx, 4;"
        "dec rcx;"
    

    Ни один из этих регистров не указан как вход / выход и не указан как выходной операнд. Я считаю, что эти ограничения необходимо изменить, чтобы сообщить компилятору о том, что эти регистры могли быть изменены / засорены. Единственный регистр, который указан как забитый, который я считаю правильным, это RAX . Правильно ли мое понимание? Я чувствую, что RDI , RSI , RBX и RCX должны быть ограничениями ввода / вывода (с использованием модификатора +) , Даже если кто-то попытается доказать, что соглашение о вызовах 64-битного System V ABI спасет их (предположения, что ИМХО плохой способ написать такой код) RBX - это энергонезависимый регистр, который изменится в этом коде .

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

  3. RBX и RCX - жестко закодированные регистры. Разве не имеет смысла разрешать компилятору выбирать эти регистры автоматически через ограничения?

  4. Если предположить, что здесь (гипотетически) должна использоваться встроенная сборка, что бы выглядел без ошибок код встроенной сборки GCC для этой функции? Хорошо ли работает эта функция, и я просто не понимаю основы расширенной встроенной сборки GCC, как expert ?


Сноска

  • 1 Функция swap_vbufs и соответствующие объявления переменных были воспроизведены дословно без разрешения правообладателя в соответствии с добросовестное использование для Цели комментариев о большем объеме работы.

1 Ответ

6 голосов
/ 15 мая 2019

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

swap_vbufs даже не выглядит очень эффективным, я подозреваю, что gcc будет делать то же самое или лучше с чистой версией C. https://gcc.gnu.org/wiki/DontUseInlineAsm. stosd - это 3 мопа на Intel, хуже, чем в обычном магазине mov + add rdi,4. А если сделать add rdi,4 безусловным, можно избежать необходимости в этом блоке else, который добавит дополнительный jmp на (надеюсь) быстрый путь, где нет хранилища MMIO для видеопамяти, поскольку буферы были равны.

(lodsd - это всего 2 мопа на Haswell и новее, так что это нормально, если вас не волнует IvyBridge или старше).

В коде ядра, я полагаю, они избегают SSE2, хотя он является базовым для x86-64, иначе вы, вероятно, захотите использовать это. Для обычного назначения памяти вам нужно просто memcpy с rep movsd или ERMSB rep movsb, но я предполагаю, что суть в том, чтобы избегать хранилищ MMIO, когда это возможно, путем проверки кэшированной копии видеопамяти RAM. Тем не менее, безусловные потоковые хранилища с movnti могут быть эффективными, если только видеопамять не сопоставлена ​​с UC (без кэширования) вместо WC.


Легко построить примеры, где это действительно ломается на практике, например, снова используя соответствующую переменную C после встроенного оператора asm в той же функции. (Или в родительской функции, которая встроила asm).

Ввод, который вы хотите уничтожить, должен обрабатываться обычно фиктивным выводом или выводом RMW с переменной C tmp, а не просто "r". или "a".

"r" или любое ограничение конкретного регистра, такое как "D", означает, что это вход только для чтения, и компилятор может ожидать, что значение будет восстановлено впоследствии. Нет ограничения «вход, который я хочу уничтожить»; Вы должны синтезировать это с фиктивным выводом или переменной.

Это все относится к другим компиляторам (clang и ICC), которые поддерживают встроенный синтаксис GNU C.

Из руководства GCC: Расширенный asm Входные операнды :

Не изменять содержимое операндов только для ввода (кроме входов, связанных с выходами). Компилятор предполагает, что при выходе из оператора asm эти операнды содержат те же значения, что и до выполнения оператора. Невозможно использовать clobbers для информирования компилятора об изменении значений в этих входах.

(Clobber rax делает ошибкой использование "a" в качестве входа; клобберы и операнды не могут перекрываться.)


Пример для 1: зарегистрировать входные операнды

int plain_C(int in) {   return (in+1) + in;  }

int bad_asm(int in) {
    int out;
    asm ("inc %%edi;\n\t mov %%edi, %0" : "=a"(out) : [in]"D"(in) );
    return out + in;
}

Скомпилировано в проводнике Godbolt

Обратите внимание, что gcc addl использует edi для in, хотя встроенный asm уничтожил этот регистр . В этом случае он удерживает in+1. Я использовал gcc9.1, но это не новое поведение.

## gcc9.1 -O3 -fverbose-asm
bad(int):
        inc %edi;
         mov %edi, %eax         # out  (comment mentions out because I used %0)

        addl    %edi, %eax      # in, tmp86
        ret     

Мы исправляем это, сообщая компилятору, что тот же входной регистр также является выходом, поэтому он больше не может рассчитывать на это. (Или используя auto tmp = in; asm("..." : "+r"(tmp));)

int safe(int in) {
    int out;
    int dummy;
    asm ("inc %%edi;\n\t mov %%edi, %%eax"
     : "=a"(out),
       "=&D"(dummy)
     : [in]"1"(in)  // matching constraint, or "D" works.
    );
    return out + in;
}
# gcc9.1 again.
safe_asm(int):
        movl    %edi, %edx      # tmp89, in    compiler-generated save of in
          # start inline asm
        inc %edi;
         mov %edi, %eax
          # end inline asm
        addl    %edx, %eax      # in, tmp88
        ret

Очевидно, что "lea 1(%%rdi), %0" позволит избежать проблем, если не изменять входные данные, как и mov / inc. Это искусственный пример, который намеренно уничтожает ввод.


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

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

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

Было бы дурацкой пропущенной оптимизацией, если бы gcc предполагал, что операторы asm всегда уничтожают свои входные данные. Фактически, те же ограничения, которые использует inline asm, (я думаю) используются во внутренних файлах описания машин, которые учат gcc об ISA. (Таким образом, уничтоженные входные данные будут ужасны для Code-Gen).

Вся конструкция встроенного ассемблера GNU C основана на переносе одной инструкции, поэтому даже ранний клоббер для выходных данных не используется по умолчанию. При необходимости вы должны сделать это вручную, если пишете несколько инструкций или цикл внутри встроенного asm.


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

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

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

   arr[2] = 1;
   asm(...);
   arr[2] = 0;

Если gcc предполагает, что arr[2] не является вводом в asm, а только сам адрес arr, он выполнит удаление мертвого хранилища и удалит присвоение =1. (Или посмотрите, как он переупорядочивает магазин с помощью оператора asm, а затем сворачивает 2 магазина в одно и то же место).

Массив хорош тем, что показывает, что даже "m"(*arr) не работает для указателя, только фактический массив . Этот операнд ввода только сообщит компилятору, что arr[0] является вводом, но не arr[2]. Это хорошо, если это все, что читает ваш asm, потому что он не блокирует оптимизацию других частей.

В этом примере memset для правильного объявления, что указанная память является выходным операндом, приведите указатель на указатель на массив и разыменуйте его, чтобы сообщить gcc, что весь диапазон памяти является операндом , *(char (*)[count])pointer. (Вы можете оставить [] пустым, чтобы указать область памяти произвольной длины, доступ к которой осуществляется с помощью этого указателя.)

// correct version written by @MichaelPetch.  
void *memset(void *dest, int value, size_t count)
{
  void *tmp = dest;
  asm ("rep stosb    # mem output is %2"
     : "+D"(tmp), "+c"(count),       // tell the compiler we modify the regs
       "=m"(*(char (*)[count])tmp)   // dummy memory output
     : "a"(value)                    // EAX actually is read-only
     : // no clobbers
  );
  return dest;
}

Включение asm-комментария с использованием фиктивного операнда позволяет нам увидеть, как компилятор разместил его. Мы видим, что компилятор выбирает (%rdi) с синтаксисом AT & T, поэтому он готов использовать регистр, который также является операндом ввода / вывода.

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

С функцией void, которая не возвращает указатель (или после встраивания в функцию, которая не использует возвращаемое значение), ей не нужно никуда копировать указатель arg, прежде чем позволить rep stosb destroy это.

...