Является ли определение "изменчивым" этим изменчивым, или имеет GCC какие-то стандартные проблемы соответствия?

Мне нужна функция, которая (например, 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 игнорировать volatile для размеров массивов 1 и 2.
  • GCC 5.1 до 5.3 игнорирует volatile для размеров массива 1, 2, 4, 8.
  • GCC 6.1 просто игнорирует его для любого размера массива (бонусные точки для согласованности).

Любой другой компилятор, который я тестировал (clang, icc, vc), генерирует магазины, которые можно было бы ожидать, с любой версией компилятора и любым размером массива. Итак, в этот момент мне интересно, является ли это (довольно старый и серьезный?) Компилятор GCC, или это определение летучих в стандарте, что неточно, что это на самом деле соответствует поведению, что делает практически невозможным создание переносного "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.)

Ответ 1

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

В частности, стандарт неясен в ключевой точке. (Я преобразовал ваш код в 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 через значение переменной latile, но arr не объявляется volatile. Поэтому, по крайней мере, возможно, разрешено компилятору C сделать вывод о том, что магазины, созданные циклом, "мертвы" и полностью удаляют петлю. Там текст в Обосновании C подразумевает, что комитет означал, чтобы потребовать сохранения этих магазинов, но сам стандарт не выполняет это требование, когда я его читаю.

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

Подробнее о том, что думал комитет volatile, искать C99 Обоснование для слова "volatile". Документ Джона Реджера "Volatiles are Miscompiled" подробно иллюстрирует, как ожидания программистов для volatile могут не удовлетворяться производителями. Серия статей LLVM "Что должен знать каждый программист C Undefined Behavior ", не касается конкретно 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, что заставляет его скопировать конфиденциальные данные в стек, чтобы стереть его, и, что еще хуже, не делает ничего о ключах, которые все еще сидят в векторных регистрах!

Ответ 2

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

Это стандартная функция memset_s для.


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

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

Ответ 3

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

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

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

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

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

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

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

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

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

Ответ 4

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

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, который гарантирует, что компилятор не может делать никаких предположений о его значении, даже если он всегда оценивает как ноль.

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