Визначення поняття «нестабільне» є цим нестійким, або 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, чи визначення нестабільного у стандарті неточне, що це насправді відповідає поведінці, що робить по суті неможливим написання портативного " Функція 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.)


4
Використання IMHO volatile- це помилка, якщо не доведено інше. Але швидше за все помилка. volatileє настільки невизначеним, що є небезпечним - просто не використовуйте його.
Jesper Juhl

19
@JesperJuhl: Ні, volatileв цьому випадку це доречно.
Дітріх Епп

9
@NathanOliver: Це не буде працювати, тому що компілятори можуть оптимізувати мертві магазини, навіть якщо вони використовують memset. Проблема в тому, що компілятори точно знають, що memsetробить.
Дітріх Епп

8
@PaulStelian: Це створило би volatileвказівник, на який ми хочемо вказівник volatile(нам байдуже, чи ++строгий, але чи *p = 0строгий).
Дітріх Епп

7
@JesperJuhl: Нічого недостатньо визначеного щодо мінливості.
GManNickG

Відповіді:


82

Поведінка GCC може відповідати, і навіть якщо це не так, ви не повинні покладатися на volatileте, що хочете робити у таких випадках. Комітет С призначений 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 є текст, який передбачає, що комітет мав намір вимагати збереження цих магазинів, але сам стандарт насправді не вимагає цієї вимоги, як я прочитав.

Для більш детального обговорення того, що стандарт вимагає чи не вимагає, див. Чому мінлива локальна змінна оптимізована інакше, ніж мінливий аргумент, і чому оптимізатор генерує цикл відмови від останнього? , Чи надає доступ до оголошеного енергонезалежного об'єкта за допомогою енергонезалежного посилання / покажчика привласнення нестабільних правил при згаданих зверненнях? та помилка GCC 71793 .

Щоб отримати докладнішу інформацію про те, для чого комітет думав volatile , знайдіть обґрунтування C99 для слова "мінливе". Документ Джона Регера " Летючі речовини неправильно скомпільовані " детально ілюструє, як 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, що змушує його копіювати конфіденційні дані у стек, щоб просто їх стерти, і, гірше, нічого не робить щодо клавіш, які все ще сидять у векторних регістрах!


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

3
Стандарт зазначає, яким критеріям щось повинно відповідати, щоб бути «сумісним» впровадженням. Він не докладає зусиль, щоб описати, яким критеріям має відповідати реалізація на певній платформі, щоб бути "доброю" реалізацією чи "корисною". Поводження з GCC volatileможе бути достатнім, щоб зробити його "сумісним" впровадженням, але це не означає, що достатньо бути "хорошим" чи "корисним". Для багатьох типів системного програмування це слід розглядати як жахливий дефіцит у цьому відношенні.
supercat

3
Специфікація C також досить прямо говорить: "Фактична реалізація не повинна оцінювати частину виразу, якщо вона може зробити висновок, що його значення не використовується, і що ніяких необхідних побічних ефектів не виробляється ( включаючи будь-які, викликані викликом функції або доступом до нестабільного об'єкта ) . " (підкресли мою).
Йоханнес Шауб - літб

15

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

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


Що стосується це поведінка з нестійкими відповідно чи ні, це трохи важко сказати, і летючий було сказав , що давно страждає від помилок.

Одне питання полягає в тому, що в специфікаціях сказано, що "Доступ до летких об'єктів оцінюється суворо за правилами абстрактної машини". Але це стосується лише `` летких об'єктів '', а не доступу до енергонезалежного об'єкта через покажчик, до якого додано летючі об'єкти. Отож, мабуть, якщо компілятор може сказати, що ви насправді не отримуєте доступ до летючого об’єкта, тоді зрештою не потрібно розглядати об’єкт як летючий.


4
Примітка: Це частина стандарту C11 і поки що доступна не у всіх ланцюжках інструментів.
Дітріх Епп

5
Слід зазначити, що цікаво, що ця функція стандартизована для C11, але не для C ++ 11, C ++ 14 або C ++ 17. Тож технічно це не рішення для С ++, але я згоден, що це здається найкращим варіантом з практичної точки зору. На даний момент я все-таки задаюся питанням, чи відповідає поведінка GCC чи ні. Редагувати: Насправді, VS 2015 не має memset_s, тож це ще не все так портативно.
cooky451

2
nwp

14
Крім того, опис memset_sстандарту C11 є завищенням. Це частина Додатку K, який є необов’язковим для C11 (а отже, також необов’язковим для C ++). В основному всі розробники, включаючи Microsoft, чия ідея була в першу чергу (!), Відмовились її взяти; востаннє я чув, що вони говорили про скрап у C-next.
zwol

8
@ cooky451 У певних колах корпорація Майкрософт відома тим, що примушує речі входити в стандарт С через переважно заперечення всіх інших, а потім не заважає їх реалізовувати самостійно. (Найбільш кричущим прикладом цього є послаблення 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

1
Після кращого прочитання мого згенерованого ASM, схоже, вбудований виклик функції та зберігає цикл, але не робить жодного збереження *ptr протягом цього циклу, або насправді що- небудь взагалі ... просто циклічне. wtf, там йде мій мозок.
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) намагається зберегти цикл там, де він нічого не робить?
підкреслення_d
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.