Является ли определение «изменчивого» таким изменчивым, или у GCC есть некоторые стандартные проблемы совместимости?

89

Мне нужна функция, которая (например, SecureZeroMemory из WinAPI) всегда обнуляет память и не оптимизируется, даже если компилятор считает, что после этого к памяти больше не будет доступа. Похоже, идеальный кандидат на волатильность. Но у меня возникли проблемы с тем, чтобы заставить это работать с GCC. Вот пример функции:

void volatileZeroMemory(volatile void* ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = (volatile unsigned char*)ptr;

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

Достаточно просто. Но код, который GCC фактически генерирует, если вы его вызываете, сильно зависит от версии компилятора и количества байтов, которое вы на самом деле пытаетесь обнулить. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 и 4.5.3 никогда не игнорируют изменчивые.
  • GCC 4.6.4 и 4.7.3 игнорируют изменчивые значения для массивов размером 1, 2 и 4.
  • GCC с 4.8.1 по 4.9.2 игнорируют изменчивые значения для массивов размером 1 и 2.
  • GCC с 5.1 по 5.3 игнорируют изменчивые значения для массивов размером 1, 2, 4, 8.
  • GCC 6.1 просто игнорирует его для любого размера массива (бонусные баллы за согласованность).

Любой другой компилятор, который я тестировал (clang, icc, vc), генерирует ожидаемые хранилища с любой версией компилятора и любым размером массива. Итак, на данный момент мне интересно, является ли это (довольно старая и серьезная?) Ошибка компилятора GCC, или определение volatile в стандарте неточно указывает на то, что это действительно соответствующее поведение, что делает практически невозможным написать переносимый " SecureZeroMemory "?

Изменить: некоторые интересные наблюдения.

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <atomic>

void callMeMaybe(char* buf);

void volatileZeroMemory(volatile void* ptr, std::size_t size)
{
    for (auto bytePtr = static_cast<volatile std::uint8_t*>(ptr); size-- > 0; )
    {
        *bytePtr++ = 0;
    }

    //std::atomic_thread_fence(std::memory_order_release);
}

std::size_t foo()
{
    char arr[8];
    callMeMaybe(arr);
    volatileZeroMemory(arr, sizeof arr);
    return sizeof arr;
}

Возможная запись из callMeMaybe () заставит все версии GCC, кроме 6.1, генерировать ожидаемые хранилища. Комментирование в заборе памяти также заставит GCC 6.1 генерировать хранилища, но только в сочетании с возможной записью из callMeMaybe ().

Кто-то тоже предлагал промывать кеши. Microsoft вообще не пытается очистить кеш в «SecureZeroMemory». Кеш, скорее всего, в любом случае будет недействителен довольно быстро, так что это, вероятно, не имеет большого значения. Кроме того, если другая программа пыталась проверить данные или если они собирались записать в файл подкачки, это всегда будет обнуленная версия.

Есть также некоторые опасения по поводу использования в GCC 6.1 memset () в автономной функции. Компилятор GCC 6.1 на godbolt может быть сломанной сборкой, поскольку GCC 6.1, кажется, генерирует нормальный цикл (как и 5.3 на godbolt) для автономной функции для некоторых людей. (Прочтите комментарии к ответу zwol.)

Cooky451
источник
4
ИМХО использование volatile- это ошибка, если не доказано обратное. Но скорее всего баг. volatileнастолько недооценено, что может быть опасным - просто не используйте его.
Jesper Juhl
19
@JesperJuhl: Нет, volatileв данном случае подходит.
Дитрих Эпп
9
@NathanOliver: Это не сработает, потому что компиляторы могут оптимизировать мертвые хранилища, даже если они используют memset. Проблема в том, что компиляторы точно знают, что memsetделают.
Дитрих Эпп
8
@PaulStelian: Это сделало бы volatileуказатель, на который нам нужен указатель volatile(нас не волнует, ++строгий ли он, а *p = 0строгий).
Дитрих Эпп
7
@JesperJuhl: В volatile нет ничего недооцененного.
GManNickG 06

Ответы:

82

Поведение GCC может соответствовать, и даже если это не так, вы не должны полагаться на volatileто, что хотите, в таких случаях. Комитет C разработан volatileдля аппаратных регистров с отображением в память и для переменных, измененных во время аномального потока управления (например, обработчики сигналов и setjmp). Это единственное, для чего он надежен. Небезопасно использовать в качестве общей аннотации «не оптимизируйте это».

В частности, в стандарте неясен ключевой момент. (Я преобразовал ваш код в C; здесь не должно быть никаких расхождений между C и C ++. Я также вручную выполнил встраивание, которое произошло бы до сомнительной оптимизации, чтобы показать, что компилятор «видит» в этот момент. .)

extern void use_arr(void *, size_t);
void foo(void)
{
    char arr[8];
    use_arr(arr, sizeof arr);

    for (volatile char *p = (volatile char *)arr;
         p < (volatile char *)(arr + 8);
         p++)
      *p = 0;
}

Цикл очистки памяти обращается arrчерез переменную lvalue, но arrсам не объявляется volatile. Поэтому, по крайней мере, возможно, компилятору C разрешено сделать вывод о том, что хранилища, созданные циклом, «мертвые», и полностью удалить цикл. В Обосновании C есть текст, который подразумевает, что комитет имел в виду требовать сохранения этих хранилищ, но, как я читал, сам стандарт на самом деле не требует этого требования.

Для более подробного обсуждения того, что стандарт требует или не требует, см. Почему изменчивая локальная переменная оптимизируется иначе, чем изменчивый аргумент, и почему оптимизатор генерирует цикл без операции из последнего? , Присваивает ли доступ к объявленному энергонезависимому объекту через изменчивую ссылку / указатель изменчивые правила для указанных доступов? и ошибка GCC 71793 .

Чтобы узнать больше о том, что volatile было задумано комитетом , поищите в Обосновании C99 слово «изменчивый». В статье Джона Регера « Volatiles are Miscompiled » подробно показано, как volatileпроизводственные компиляторы могут не удовлетворить ожидания программистов . Серия эссе команды LLVM « Что должен знать каждый программист на C о неопределенном поведении » не затрагивает конкретно, volatileно поможет вам понять, как и почему современные компиляторы C не являются «портативными ассемблерами».


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

extern void memory_optimization_fence(void *ptr, size_t size);
inline void
explicit_bzero(void *ptr, size_t size)
{
   memset(ptr, 0, size);
   memory_optimization_fence(ptr, size);
}

/* in a separate source file */
void memory_optimization_fence(void *unused1, size_t unused2) {}

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

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

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

Вы также должны знать, что даже если вы сможете заставить это работать, этого может быть недостаточно. В частности, рассмотрим

struct aes_expanded_key { __uint128_t rndk[16]; };

void encrypt(const char *key, const char *iv,
             const char *in, char *out, size_t size)
{
    aes_expanded_key ek;
    expand_key(key, ek);
    encrypt_with_ek(ek, iv, in, out, size);
    explicit_bzero(&ek, sizeof ek);
}

Предполагая, что оборудование с инструкциями ускорения AES, если expand_keyи encrypt_with_ekвстроены, компилятор может иметь возможность ekполностью сохранить файл в векторном регистре - до вызова explicit_bzero, который заставляет его копировать конфиденциальные данные в стек только для их стирания, и, хуже того, ни черта не делает с ключами, которые все еще находятся в векторных регистрах!

Zwol
источник
6
Это интересно ... Мне было бы интересно увидеть ссылку на комментарии комитета.
Дитрих Эпп
10
Как этот квадрат с определением 6.7.3 (7) volatileкак [...] Следовательно, любое выражение, относящееся к такому объекту, должно оцениваться строго в соответствии с правилами абстрактной машины, как описано в 5.1.2.3. Более того, в каждой точке последовательности последнее сохраненное в объекте значение должно соответствовать значению, предписанному абстрактной машиной , за исключением случаев, когда оно изменено неизвестными факторами, упомянутыми ранее. То, что составляет доступ к объекту, имеющему тип с изменяемым типом, определяется реализацией. ?
Iwillnotexist Idonotexist
15
@IwillnotexistIdonotexist Ключевое слово в этом отрывке - объект . volatile sig_atomic_t flag;летучий объект . *(volatile char *)fooэто просто доступ через переменную lvalue, и стандарт не требует от нее каких-либо специальных эффектов.
zwol 06
3
Стандарт говорит, каким критериям что-то должно соответствовать, чтобы быть «соответствующей» реализацией. Он не пытается описать, каким критериям должна соответствовать реализация на данной платформе, чтобы быть «хорошей» или «пригодной для использования». Обработка GCC volatileможет быть достаточной, чтобы сделать его «совместимой» реализацией, но это не значит, что этого достаточно, чтобы быть «хорошим» или «полезным». Для многих видов системного программирования его следует рассматривать как крайне несовершенный в этом отношении.
supercat 06
3
В спецификации C также прямо говорится: «Фактическая реализация не должна оценивать часть выражения, если она может сделать вывод, что ее значение не используется и что никаких побочных эффектов не возникает ( включая любые, вызванные вызовом функции или доступом к изменчивому объекту ). . " (подчеркните мое).
Йоханнес Шауб - лит
15

Мне нужна функция, которая (например, SecureZeroMemory из WinAPI) всегда обнуляет память и не оптимизируется,

Для этого и предназначена стандартная функция memset_s.


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

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

bames53
источник
4
Примечание: это часть стандарта C11 и пока что доступна не во всех инструментальных цепочках.
Дитрих Эпп
5
Следует отметить, что, что интересно, эта функция стандартизирована для C11, но не для C ++ 11, C ++ 14 или C ++ 17. Так что технически это не решение для C ++, но я согласен с тем, что это лучший вариант с практической точки зрения. На данный момент мне действительно интересно, соответствует ли поведение GCC или нет. Изменить: На самом деле VS 2015 не имеет memset_s, так что это еще не все, что переносимо.
cooky451 06
2
@ cooky451 Я думал, что C ++ 17 извлекает стандартную библиотеку C11 по ссылке (см. второе Разное).
nwp 06
14
Кроме того, описание memset_sкак C11-стандарт - это преувеличение. Он является частью Приложения K, которое является необязательным в C11 (и, следовательно, также необязательно в C ++). Практически все разработчики, включая Microsoft, чья идея была в первую очередь (!), Отказались ее поддержать; последний раз я слышал, что они говорили о его утилизации в C-next.
zwol 06
8
@ cooky451 В определенных кругах Microsoft печально известна тем, что навязывала стандарты C, вопреки всем возражениям, а затем не пыталась реализовать их самостоятельно. (Самый вопиющий пример этого - ослабление C99 правил того, что size_tразрешено использовать базовый тип . Win64 ABI не соответствует C90. Это было бы ... не нормально , но не ужасно ... если бы MSVC фактически взяли C99 вещи , как uintmax_tи %zuсвоевременно, но они не делали ).
zwol
2

Я предлагаю эту версию как переносимый C ++ (хотя семантика несколько отличается):

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = new (ptr) volatile unsigned char[size];

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

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

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

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

Код можно сделать короче (хотя и менее понятным) с помощью инициализации значения:

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    new (ptr) volatile unsigned char[size] ();
}

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

Бен Фойгт
источник
2
Если доступ к объекту после выполнения функции будет вызывать UB, это будет означать, что такие обращения могут дать значения, которые объект удерживал до того, как он был «очищен». Как это не противоположность безопасности?
supercat
0

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

void volatileZeroMemory(void* ptr, unsigned long long size)
{
    volatile unsigned char zero = 0;
    unsigned char* bytePtr = static_cast<unsigned char*>(ptr);

    while (size--)
    {
        *bytePtr++ = zero;
    }

    zero = static_cast<unsigned char*>(ptr)[zero];
}

Объявляется zeroобъект, volatileкоторый гарантирует, что компилятор не может делать никаких предположений о его значении, даже если он всегда оценивается как ноль.

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

Д Крюгер
источник
1
Это вообще не работает ... просто посмотрите на генерируемый код.
cooky451 06
1
Прочитав мой сгенерированный ASM mo 'лучше, кажется, что он встраивает вызов функции и сохраняет цикл, но не выполняет никаких операций сохранения во *ptrвремя этого цикла или вообще чего-либо ... просто зацикливается. черт возьми, вот и мой мозг.
underscore_d
3
@underscore_d Это потому, что он оптимизирует хранилище, сохраняя при этом чтение изменчивого файла.
D Krueger
1
Да, и он сбрасывает результат в неизменный edx: я получаю это:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d
1
Если я изменю функцию, чтобы разрешить передачу произвольного volatile unsigned char constбайта заполнения ... он даже не читает его . Сгенерированный встроенный вызов volatileFill()- это просто [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Почему оптимизатор (A) не перечитывает байт заполнения, а (B) заботится о сохранении цикла там, где он ничего не делает?
underscore_d