LINUX.ORG.RU

Авторы Си — наркоманы?

 , , ,


2

5

Столкнулся с интересным багом. После того как разобрался, что же именно происходит, меня постигло крайнее изумление! Оказывается, в языке Си тип числовой константы зависит от формата записи.

Дистиллированный пример кода, который это демонстрирует:

#include <stdbool.h>
#include <stdio.h>

#define IS_HEX(x) \
    _Generic((x), \
        unsigned int: true, \
        long: false \
    )

#define X 0x80000001
#define I 2147483649

int main(void) {
    if(X == I)
        puts("X == I");

    if(!IS_HEX(I))
        puts("I is not hexadecimal");

    if(IS_HEX(X))
        puts("X is hexadecimal");

    return 0;
}

Все три сообщения будут выведены на экран.

Зачем это сделано? Кому от этого легче? Какие оптимизации это позволяет проворачивать, кроме оптимизации отстрела ног программистам? Непонятно! В общем, стремлюсь поделиться своим негодованием здесь и предостеречь будущие поколения от наступления на эти грабли.



Последнее исправление: dataman (всего исправлений: 1)
Ответ на: комментарий от annulen

Большая часть сишного кода где? Точно не в юзерспейсном OSS-софте.

Примеры из головы:

  1. Ядро Linux (да, не юзерспейс, но это один из самых жирных сишных проектов в принципе)
  2. GTK и вагоны софта на GTK.
  3. Qemu

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

yorshka
() автор топика
Ответ на: комментарий от monk

Такая гарантия есть только потому что переполнение является UB. То есть гарантии нет.

Оптимизатор гарантирует, что поведение корректной программы не изменится. Программист гарантирует, что программа корректна, то есть в ней нет UB (а если есть, то программисту в этом случае результат не важен).

Программист гарантирует

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

Опять же, большая часть сишного кода собирается с -fwrapv, поэтому переполнение в любом случае определено.

Это диалект Си с доопределённым UB.

Почему диалект? Это вполне валидный случай обработки UB – притвориться, что никакого UB нет. Так же как GCC спокойно хавает и компилирует код без ‘\n’ в конце файла, хотя может выдавать int main(){system("rm -rf /*");} вместо него.

В смысле? Вот цикл for(unsigned char i = x; i!= y; i++) { …a[i]… }. Он завершается. Но оптимизировать такой цикл нельзя в прочитать в кэш a[x..y-1]; обработать последовательно. Потому что может быть x = 200, y = 5 и тогда надо дробить на прочитать в кэш a[x..255]; обработать последовательно; прочитать в кэш a[0..4]; обработать последовательно;. Или вообще забить на оптимизацию и читать в кэш линию при чтении очередного элемента массива.

А причём тут компилятор? Компилятор кэшем не рулит никак, ололоэ. Хуже того, gcc для int и unsigned int вообще идентичный код выдает.

yorshka
() автор топика
Ответ на: комментарий от yorshka

Сишные программисты тебе ничего не гарантируют, если ты ещё не осознал этот факт из этого и других тредов.

Если не гарантируют, значит их программа после оптимизирующего компилятора может делать что угодно. Их проблемы.

Почему диалект? Это вполне валидный случай обработки UB – притвориться, что никакого UB нет.

Да. Диалект, потому что данный код корректен только в этом компиляторе.

А причём тут компилятор? Компилятор кэшем не рулит никак, ололоэ.

Зависит от процессора. https://www.felixcloutier.com/x86/prefetchh

Хуже того, gcc для int и unsigned int вообще идентичный код выдает.

Потому что может доказать, что переполнения нет. Доступ к array[i] гарантирует, что i не больше 1024.

monk ★★★★★
()
Ответ на: комментарий от monk

Для for(int i = x; i != y; i++) { … a[i] … } есть гарантия, что массив a читается последовательно (можно предсказуемо подгружать кэш и использовать всякие SIMD).

Для unsigned всё становится заметно сложнее: либо делать проверку и удваивать объём кода, либо не иметь гарантии последовательного чтения.

В смысле? Вот цикл for(unsigned char i = x; i!= y; i++) { …a[i]… }. Он завершается.

Потому что может доказать, что переполнения нет. Доступ к array[i] гарантирует, что i не больше 1024.

Ладно про стандарт, но с компиляторами-то ты свои фантазии как-то пытался соотнести?

  1. В реальности для знаковых компилятор тупо выбрасывает проверку недостижимого (с его, компилятора, точки зрения) условия , превращая цикл в вечный не зависимо от sizeof(array) и "Доступ к array[i] гарантирует, что i не больше 1024."©™.

  2. Если компилятор НЕ может доказать, что нет переполнения, так как sizeof(array) не известен, то они генерируют идентичный код для знаковых и беззнаковых счётчиков.

Для for(int i = x; i != y; i++) { … a[i] … } есть гарантия, что массив a читается последовательно (можно предсказуемо подгружать кэш и использовать всякие SIMD).

Но вместо этого, компилятор просто выкинет нахер условие, если может.

Для unsigned всё становится заметно сложнее: либо делать проверку и удваивать объём кода, либо не иметь гарантии последовательного чтения.

Ты хоть один реально существующий компилятор, который вместо неимения гарантий будет городить какие-то проверки сверх описанных в коде, сможешь привести?

r--r--r--
()
Последнее исправление: r--r--r-- (всего исправлений: 2)
Ответ на: комментарий от r--r--r--
  1. Это я писал про то, что для беззнаковых иногда можно оптимизировать в диапазон. Например, если индекс unsigned, а по нему обращение к массиву меньше UINT_MAX, то i+1 > i.

  2. Потому что диапазон явный от 0 до 200. Там нет переполнения. Вот с неизвестным диапазоном: https://godbolt.org/z/a5j46Mbcs

monk ★★★★★
()
Ответ на: комментарий от r--r--r--

Ты хоть один реально существующий компилятор, который вместо неимения гарантий будет городить какие-то проверки сверх описанных в коде, сможешь привести?

Вот. Посмотри на код для clang: https://godbolt.org/z/aaKabe81W

Он для foo как раз пытается выделить гарантированно линейные куски. А gcc просто выключает оптимизацию.

monk ★★★★★
()
Ответ на: комментарий от monk

Вот с неизвестным диапазоном:

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

В общем случае - код идентичный.

Ты хоть один реально существующий компилятор, который вместо неимения гарантий будет городить какие-то проверки сверх описанных в коде, сможешь привести?

Вот. Посмотри на код для clang

Это обычные попытки loop unroll’инга не имеющие никакой связи с твоими фантазиями о работе компилятора.

r--r--r--
()
Ответ на: комментарий от r--r--r--

В общем случае - код идентичный.

То не в общем. То когда из цикла вызывается очень медленная функция. Тогда смысла оптимизировать нет.

Это обычные попытки loop unroll’инга не имеющие никакой связи с твоими фантазиями о работе компилятора.

Они существенно отличаются для unsigned и int.

monk ★★★★★
()
Ответ на: комментарий от r--r--r--

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

Так смысл UB именно в том, чтобы можно было использовать эти отдельные ветки оптимизации. Типа «обнулить диапазон от a[x] до a[y]». Если цикл с знаковым счётчиком, это гарантирует (программистом), что y > x. Можно писать оптимально (каким-нибудь аппаратно-ускоренным rep movsb). Если с беззнаковым, то надо или делать две ветки выполнения и проверкой, или не оптимизировать, а честно писать по одной ячейке за раз.

monk ★★★★★
()
Ответ на: комментарий от monk

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

Так смысл UB именно в том, чтобы можно было использовать эти отдельные ветки оптимизации.

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

То когда из цикла вызывается очень медленная функция.

Не фантазируй. Компилятор не отличает вызов "медленной" функции от вызова "очень медленной" функции.

Они существенно отличаются для unsigned и int.

В твоём примере там разница в первую очередь связана с расширением до 64-ёх бит.

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

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

r--r--r--
()
Последнее исправление: r--r--r-- (всего исправлений: 4)
Ответ на: комментарий от r--r--r--

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

Для существования достаточно одного примера. Компиляторы, разумеется, знают только конкретные шаблоны. int можно транслировать в memset/memmove, а unsigned запрещено.

Не фантазируй. Компилятор не отличает вызов «медленной» функции от вызова «очень медленной» функции.

Любая функция, которая не инлайнится, медленная. И смысла оптимизировать несколько тактов вокруг неё просто нет.

monk ★★★★★
()
Ответ на: комментарий от monk

Для существования достаточно одного примера.

И ты его не привёл.

int можно транслировать в memset/memmove, а unsigned запрещено.

Ты точно не хочешь прекратить рассказывать ерунду^W^W своё 4.2?

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

r--r--r--
()
Последнее исправление: r--r--r-- (всего исправлений: 1)
Ответ на: комментарий от r--r--r--

И ты его не привёл.

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

Поставь 32 бита и будет использование UB.

https://godbolt.org/z/6sqzjYno1

Для 64 бит предполагаю, что компиляторы пользуются фактом, что длина указателя 64-бит, а значит 64-битный номер 4-байтного элемента никак не может переполниться.

monk ★★★★★
()
Ответ на: комментарий от monk

Поставь 32 бита

Теперь ты начинаешь понимать, в чём реальная разница на твоих примерах.

Для 64 бит предполагаю, что компиляторы пользуются фактом, что длина указателя 64-бит, а значит 64-битный номер 4-байтного элемента никак не может переполниться.

Да, но нет. На самом деле компиляторы конечно же не пользуются никакими "фактами".

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

Чтобы включить вставку memset’а надо делать дополнительную логику анализа кода на невозможность переполнения беззнаковых меньше регистра. Этой логики в компиляторах нет. Компиляторы просто в соответствии со стандартом расширяют signed int до int64_t и в случае сравнения между signed int’ом и unsigned int’ом ты получаешь разницу в поведении на разной размерности, а вовсе не какую-то глубокую философию между знаковыми и беззнаковыми, про которые ты тут с упоением рассказываешь. Для unsigned int, который на самом деле uint32_t, компилятор обязан сгенерировать код, допускающий переполнение.

r--r--r--
()
Последнее исправление: r--r--r-- (всего исправлений: 2)
Ответ на: комментарий от r--r--r--

По миллиардам устройств, в которых спецслужбы разных стран живут как у себя дома, благодаря тому, что язык позволяет скрывать CVE?

den73 ★★★★★
()
Для того чтобы оставить комментарий войдите или зарегистрируйтесь.