Как сделать бесконечно пустым l oop, который не будет оптимизирован? - PullRequest
131 голосов
/ 27 января 2020

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

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

В этом ответе упоминается, что все oop подобно while(1) ; не должны подвергаться оптимизации.

Итак ... почему Clang / LLVM оптимизирует l oop ниже (скомпилировано с cc -O2 -std=c11 test.c -o test)?

#include <stdio.h>

static void die() {
    while(1)
        ;
}

int main() {
    printf("begin\n");
    die();
    printf("unreachable\n");
}

На моей машине это выводит begin, затем вылетает из-за недопустимой инструкции (ud2 ловушка после die()). На Godbolt мы можем видеть, что ничего не генерируется после вызова puts.

Было удивительно трудной задачей заставить Clang вывести бесконечное l oop при -O2 - хотя я мог неоднократно проверять переменную volatile, это включает чтение из памяти, которое мне не нужно. И если я сделаю что-то вроде этого:

#include <stdio.h>

static void die() {
    while(1)
        ;
}

int main() {
    printf("begin\n");
    volatile int x = 1;
    if(x)
        die();
    printf("unreachable\n");
}

... Ложит печать begin, а затем unreachable, как будто бесконечного l oop никогда не существовало.

Как вы заставить Clang вывести правильный бесконечный доступ без памяти l oop с включенной оптимизацией?

Ответы [ 13 ]

77 голосов
/ 27 января 2020

Стандарт C11 говорит об этом, 6.8.5 / 6:

Оператор итерации, управляющее выражение которого не является константным выражением, 156) , который не выполняет ввод / вывод операций, не имеет доступа к изменчивым объектам и не выполняет никаких операций синхронизации или атома c в своем теле, управляющем выражении или (в случае оператора for) в своем выражении-3, реализация может допускать завершение. 157)

Примечания к двум футам не являются нормативными, но предоставляют полезную информацию:

156) Пропущенное управляющее выражение заменяется ненулевой константой, которое является константным выражением.

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

В вашем случае, while(1) является кристально чистым константным выражением, поэтому реализация может завершить , а не . Такая реализация была бы безнадежно нарушена, поскольку циклы "навсегда" - это обычная программная конструкция.

Что происходит с "недоступным кодом" после l oop, однако, насколько я знаю, не четко определены. Тем не менее, Clang действительно ведет себя очень странно. Сравнение машинного кода с g cc (x86):

g cc 9.2 -O3 -std=c11 -pedantic-errors

.LC0:
        .string "begin"
main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:.LC0
        call    puts
.L2:
        jmp     .L2

clang 9.0.0 -O3 -std=c11 -pedantic-errors

main:                                   # @main
        push    rax
        mov     edi, offset .Lstr
        call    puts
.Lstr:
        .asciz  "begin"

g cc генерирует l oop, лязг просто бежит в лес и выходит с ошибкой 255.

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

#include <stdio.h>
#include <setjmp.h>

static _Noreturn void die() {
    while(1)
        ;
}

int main(void) {
    jmp_buf buf;
    _Bool first = !setjmp(buf);

    printf("begin\n");
    if(first)
    {
      die();
      longjmp(buf, 1);
    }
    printf("unreachable\n");
}

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

setjmp вернет 0 при первом запуске, поэтому эта программа должна просто sma sh войти в while(1) и остановиться на этом, только печать «начало» (при условии \ n сбрасывает стандартный вывод). Это происходит с g cc.

Если l oop был просто удален, он должен напечатать «begin» 2 раза, а затем «unreachable». Однако на clang ( godbolt ) он печатает «начало» 1 раз, а затем «недоступен» перед возвратом кода выхода 0. Это просто неправильно, независимо от того, как вы это сформулировали.

Я могу Здесь нет причин для заявления о неопределенном поведении, поэтому я считаю, что это ошибка в Clang. В любом случае, такое поведение делает clang на 100% бесполезным для таких программ, как встроенные системы, где вы просто должны быть в состоянии полагаться на вечные циклы, подвешивающие программу (в ожидании watchdog et c).

52 голосов
/ 27 января 2020

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

Самое простое решение:

static void die() {
    while(1)
       __asm("");
}

Godbolt link

50 голосов
/ 28 января 2020

В других ответах уже рассказывалось о том, как заставить Clang излучать бесконечное l oop, с использованием встроенного языка ассемблера или других побочных эффектов. Я просто хочу подтвердить, что это действительно ошибка компилятора. В частности, это давняя ошибка LLVM - она ​​применяет концепцию C ++ «все циклы без побочных эффектов должны заканчиваться» к языкам, где это не должно, например C.

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

В краткосрочной перспективе, похоже, что LLVM будет продолжать предполагать, что «все петли без побочных эффектов должны завершаться». Для любого языка, который допускает бесконечные циклы, LLVM ожидает, что внешний интерфейс вставит в такие циклы llvm.sideeffect коды операций. Это то, что Rust планирует сделать, поэтому Clang (при компиляции C кода), вероятно, тоже должен будет это сделать.

32 голосов
/ 28 января 2020

Это ошибка Clang

... при встраивании функции, содержащей бесконечный l oop. Поведение отличается, когда while(1); появляется непосредственно в основном, что пахнет очень глючно для меня.

См. @ ответ Арнавиона для сводки и ссылок. Остальная часть этого ответа была написана до того, как у меня было подтверждение, что это ошибка, не говоря уже об известной ошибке.


Чтобы ответить на заглавный вопрос: Как сделать бесконечный пустой l oop что не будет оптимизировано? ? - сделать die() макросом, а не функцией , чтобы обойти эту ошибку в Clang 3.9 и более поздних версиях. (Более ранние версии Clang либо сохраняют l oop, либо испускают call для не встроенной версии функции с бесконечным l oop.) Это кажется безопасным, даже если print;while(1);print; функция встроена в ее вызывающую ( Godbolt ). -std=gnu11 против -std=gnu99 ничего не меняет.

Если вы заботитесь только о GNU C, P__J __'s __asm__(""); внутри l oop также работает, и не должно мешать оптимизации любого окружающего кода для любых компиляторов, которые его понимают. GNU C Basi c asm операторы неявно volatile, так что это считается видимым побочным эффектом, который должен «исполняться» столько раз, сколько это было бы в абстрактной машине C , (И да, Clang реализует GNU-диалект C, как описано в руководстве G CC.)


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

(Это будет соответствовать стандартам для Clang ++ (но все же не очень полезно); бесконечные циклы без каких-либо побочных эффектов - это UB в C ++, но не C. Есть время (1); неопределенное поведение в C? UB позволяет компилятору в основном генерировать что-либо для кода на пути выполнения, который обязательно встретит UB. Оператор asm в l oop позволит избежать этого UB для C ++. Но на практике компиляция Clang как C ++ не удаляет бесконечные пустые циклы с постоянным выражением, кроме как при встраивании, так же, как при компиляции как C.)


Внесение вручную while(1); изменения как Clang компилирует это: бесконечно l oop присутствует в asm. Это то, что мы ожидаем от POV. *, Clang 9.0 -O3 компилируется как C (-xc) для x86-64:

main:                                   # @main
        push    rax                       # re-align the stack by 16
        mov     edi, offset .Lstr         # non-PIE executable can use 32-bit absolute addresses
        call    puts
.LBB3_1:                                # =>This Inner Loop Header: Depth=1
        jmp     .LBB3_1                   # infinite loop


.section .rodata
 ...
.Lstr:
        .asciz  "begin"

Тот же компилятор с теми же параметрами компилирует main, который вызывает infloop() { while(1); } к тому же сначала puts, но затем просто прекращает выдавать инструкции для main после этого момента. Как я уже сказал, выполнение просто падает из конца функции в любую функцию, следующую за ней (но со стеком, смещенным для входа в функцию, так что это даже не допустимый вызов вызова).

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

  • испускает label: jmp label бесконечный l oop
  • или (если мы допустим, что бесконечный l oop можно удалить) испускает другой вызов для печати 2-й строки, и затем return 0 из main.

Сбой или иное продолжение без печати «недостижимый» явно не подходит для реализации C11, если нет UB, который я не заметил.


Сноска 1:

Для записи я согласен с @ ответом Лундина, который цитирует стандарт для доказательства того, что C11 не допускает предположения завершения для бесконечных циклов с постоянным выражением, даже когда они пусты (нет ввода-вывода, энергозависимости, синхронизации или других видимых побочных эффектов).

Это набор условий, которые пусть компиляция al oop к пустому асму l oop для обычного процессора. (Даже если тело не было пустым в источнике, назначения переменных не могут быть видны другим потокам или обработчикам сигналов без гонки данных UB, пока работает l oop. Поэтому соответствующая реализация может удалить такой l oop тел, если он этого хотел. Тогда остается вопрос, можно ли удалить сам l oop. ИСО С11 явно говорит нет.)

Учитывая, что С11 выделяет этот случай как тот, где реализация не могу предположить, что l oop завершается (и что это не UB), кажется ясным, что они предполагают присутствие l oop во время выполнения. Реализация, нацеленная на процессоры с моделью исполнения, которая не может выполнять бесконечное количество работы за конечное время, не имеет оснований для удаления пустой константы бесконечной l oop. Или даже в целом, точная формулировка о том, можно ли «предположительно прекратить» или нет. Если al oop не может завершиться, это означает, что более поздний код недоступен, независимо от того, какие аргументы вы приводите о математике и бесконечности и сколько времени занимает выполнение бесконечного объема работы на некоторой гипотетической машине .

Кроме того, Clang - это не просто DeathStation 9000, совместимый с ISO C, он предназначен для практического программирования низкоуровневых систем, включая ядра и встроенные компоненты. Итак, независимо от того, принимаете ли вы аргументы о том, что C11 разрешает удаление while(1);, не имеет смысла, что Clang действительно захочет это сделать. Если вы напишите while(1);, это, вероятно, не было случайностью. Удаление циклов, которые заканчиваются бесконечно случайно (с управляющими выражениями переменных времени выполнения), может быть полезным, и для компиляторов имеет смысл сделать это.

Редко, когда вы хотите просто вращаться до следующего прерывания, но если вы напишите это в C, это определенно то, что вы ожидаете. (И что происходит в G CC и Clang, за исключением Clang, когда бесконечный l oop находится внутри функции-оболочки).

Например, в примитивном ядре ОС если у планировщика нет задач для запуска, он может запустить простоя задачу. Первой реализацией этого может быть while(1);.

Или для оборудования без какой-либо функции энергосбережения в режиме ожидания, которая может быть единственной реализацией. (До начала 2000-х это было, я думаю, не редко на x86. Хотя инструкция hlt существовала, IDK, если она экономила значимое количество энергии до тех пор, пока центральные процессоры не начали иметь состояния простоя с низким энергопотреблением.)

14 голосов
/ 28 января 2020

Только для записи, Clang также плохо себя ведет с goto:

static void die() {
nasty:
    goto nasty;
}

int main() {
    int x; printf("begin\n");
    die();
    printf("unreachable\n");
}

Он выдает тот же результат, что и в вопросе, то есть:

main: # @main
  push rax
  mov edi, offset .Lstr
  call puts
.Lstr:
  .asciz "begin"

Я вижу, не Я не вижу способа прочитать это, как это разрешено в C11, который говорит только:

6.8.6.1 (2) Оператор goto вызывает безусловный переход к оператору, начинающемуся с префикса с указанной меткой в включающая функция.

Поскольку goto не является «оператором итерации» (6.8.5 списков while, do и for) ничего о специальных индульгенциях, «предполагаемых прекращением» примените, однако вы хотите их прочитать.

В исходном вопросе компилятор ссылок Godbolt - x86-64 Clang 9.0.0, а флаги - -g -o output.s -mllvm --x86-asm-syntax=intel -S --gcc-toolchain=/opt/compiler-explorer/gcc-9.2.0 -fcolor-diagnostics -fno-crash-diagnostics -O2 -std=c11 example.c

С другими, такими как x86-64 G CC 9,2 Вы получите довольно хорошо идеально:

.LC0:
  .string "begin"
main:
  sub rsp, 8
  mov edi, OFFSET FLAT:.LC0
  call puts
.L2:
  jmp .L2

Флаги: -g -o output.s -masm=intel -S -fdiagnostics-color=always -O2 -std=c11 example.c

5 голосов
/ 27 января 2020

Я сыграю адвоката дьявола и утверждаю, что стандарт не запрещает компилятору оптимизировать бесконечное число l oop.

Итерационное утверждение, управляющее выражение которого не является константой выражение, 156), которое не выполняет никаких операций ввода-вывода, не осуществляет доступ к энергозависимым объектам и не выполняет никаких синхронизирующих или атомарных операций в своем теле, управляя выражением или (в случае оператора for) его выражением-3, можно предположить реализацией прекратить.157)

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

if (satisfiesCriteriaForTerminatingEh(a_loop)) 
    if (whatever_reason_or_just_because_you_feel_like_it)
         assumeTerminates(a_loop);

Это ничего не говорит о том, что происходит, если критерии не выполняются, и предполагается, что al oop может завершиться даже тогда, когда isn явно запрещено, если соблюдаются другие правила стандарта.

do { } while(0) или while(0){} после всех итерационных операторов (циклов), которые не удовлетворяют критериям, которые позволяют компилятору просто предполагать по какой-то причине, что они завершаются, и все же они, очевидно, действительно завершаются.

Но может ли компилятор просто оптимизировать while(1){} out?

5.1.2.3p4 говорит:

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

Здесь упоминаются выражения, а не операторы, поэтому это не на 100% убедительно, но, безусловно, позволяет пропустить такие вызовы, как:

void loop(void){ loop(); }

int main()
{
    loop();
}

. Интересно, что clang пропускает его, а g cc не .

2 голосов
/ 29 января 2020

Кажется, это ошибка в компиляторе Clang. Если в функции die() нет принуждения быть функцией stati c, покончи с static и сделайте ее inline:

#include <stdio.h>

inline void die(void) {
    while(1)
        ;
}

int main(void) {
    printf("begin\n");
    die();
    printf("unreachable\n");
}

Она работает, как ожидается, при компиляции с компилятором Clang и также переносим.

Проводник компилятора (godbolt.org) - clang 9.0.0 -O3 -std=c11 -pedantic-errors

main:                                   # @main
        push    rax
        mov     edi, offset .Lstr
        call    puts
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
        jmp     .LBB0_1
.Lstr:
        .asciz  "begin"
2 голосов
/ 27 января 2020

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


Я думаю, что это неопределенное поведение (см. Конец), и у Кланга есть только один реализация. G CC действительно работает, как вы ожидаете, оптимизируя только оператор печати unreachable, но оставляя l oop. Кое-что, как Clang странно принимает решения, комбинируя встраивание и определяя, что он может делать с l oop.

Поведение очень странное - оно удаляет окончательный отпечаток, поэтому «видит» бесконечное l oop, но затем избавляется и от l oop.

Это даже хуже, насколько я могу судить. Удалив встроенную строку, мы получим:

die: # @die
.LBB0_1: # =>This Inner Loop Header: Depth=1
  jmp .LBB0_1
main: # @main
  push rax
  mov edi, offset .Lstr
  call puts
.Lstr:
  .asciz "begin"

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

#include <stdio.h>

void die(int x) {
    while(x);
}

int main() {
    printf("begin\n");
    die(1);
    printf("unreachable\n");
}

приводит к очень неоптимальной сборке для функции, но вызов функции снова оптимизирован! Еще хуже:

void die(x) {
    while(x++);
}

int main() {
    printf("begin\n");
    die(1);
    printf("unreachable\n");
}

Я сделал кучу других тестов, добавив локальную переменную и увеличив ее, передав указатель, используя goto et c ... На этом этапе я бы дал вверх. Если вы должны использовать clang

static void die() {
    int volatile x = 1;
    while(x);
}

делает работу. Это отстой при оптимизации (очевидно) и оставляет в избыточном финале printf. По крайней мере, программа не останавливается. Может быть, G CC в конце концов?

Приложение

После обсуждения с Дэвидом, я полагаю, что стандарт не говорит: «Если условие постоянное, вы не можете предположим, что l oop заканчивается ". Таким образом, и, учитывая, что согласно стандарту нет наблюдаемого поведения (как определено в стандарте), я бы поспорил только о согласованности - если компилятор оптимизирует все oop, поскольку он предполагает, что он завершается, он не должен оптимизировать следующее заявления.

Черт возьми n1528 имеет эти неопределенное поведение, если я правильно понял. В частности,

Основная проблема для этого заключается в том, что он позволяет коду перемещаться по потенциально не завершающемуся l oop

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

1 голос
/ 28 января 2020

Мне кажется, что работает следующее:

#include <stdio.h>

__attribute__ ((optnone))
static void die(void) {
    while (1) ;
}

int main(void) {
    printf("begin\n");
    die();
    printf("unreachable\n");
}

при godbolt

Явное указание Clang не оптимизировать, что одна функция вызывает бесконечное l oop быть выпущенным, как ожидалось. Надеемся, что есть способ выборочно отключить определенные оптимизации вместо того, чтобы просто отключить их все так. Clang по-прежнему отказывается выдавать код для второго printf. Чтобы заставить это сделать это, мне пришлось дополнительно изменить код внутри main до:

volatile int x = 0;
if (x == 0)
    die();

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

0 голосов
/ 14 февраля 2020

Извините, если это абсурдно не так, я наткнулся на этот пост, и я знаю, потому что мои годы использования Gentoo Linux distro, что если вы хотите, чтобы компилятор не оптимизировал ваш код, вы должны использовать -O0 (Ноль ). Мне было любопытно, я скомпилировал и запустил приведенный выше код, и l oop do работает бесконечно долго. Скомпилировано с использованием clang-9:

cc -O0 -std=c11 test.c -o test
...