Czy definicja „niestabilności” jest tak zmienna, czy też GCC ma jakieś problemy ze standardami zgodności?

89

Potrzebuję funkcji, która (jak SecureZeroMemory z WinAPI) zawsze zeruje pamięć i nie jest zoptymalizowana, nawet jeśli kompilator uważa, że ​​pamięć nie będzie już nigdy dostępna. Wydaje się, że jest to idealny kandydat na niestabilność. Ale mam pewne problemy z uruchomieniem tego z GCC. Oto przykładowa funkcja:

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

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

Wystarczająco proste. Ale kod, który faktycznie generuje GCC, jeśli go wywołasz, różni się gwałtownie w zależności od wersji kompilatora i ilości bajtów, które faktycznie próbujesz wyzerować. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 i 4.5.3 nigdy nie ignorują zmienności.
  • GCC 4.6.4 i 4.7.3 ignorują zmienne dla tablic o rozmiarach 1, 2 i 4.
  • GCC 4.8.1 do 4.9.2 ignoruje zmienne dla tablic o rozmiarach 1 i 2.
  • GCC 5.1 do 5.3 ignoruje volatile dla tablic o rozmiarach 1, 2, 4, 8.
  • GCC 6.1 po prostu ignoruje to dla dowolnego rozmiaru tablicy (dodatkowe punkty za spójność).

Każdy inny testowany przeze mnie kompilator (clang, icc, vc) generuje sklepy, których można by się spodziewać, z dowolną wersją kompilatora i dowolnym rozmiarem tablicy. W tym miejscu zastanawiam się, czy jest to (dość stary i poważny?) Błąd kompilatora GCC, czy też definicja niestabilności w standardzie jest tak nieprecyzyjna, że ​​jest to faktycznie zgodne zachowanie, co zasadniczo uniemożliwia napisanie przenośnego. „SecureZeroMemory”?

Edycja: kilka interesujących obserwacji.

#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;
}

Możliwy zapis z callMeMaybe () spowoduje, że wszystkie wersje GCC z wyjątkiem 6.1 wygenerują oczekiwane magazyny. Komentowanie w obszarze pamięci spowoduje również, że GCC 6.1 wygeneruje magazyny, chociaż tylko w połączeniu z możliwym zapisem z callMeMaybe ().

Ktoś zasugerował również opróżnienie pamięci podręcznych. Firma Microsoft w ogóle nie próbuje opróżnić pamięci podręcznej w „SecureZeroMemory”. Pamięć podręczna prawdopodobnie i tak zostanie unieważniona dość szybko, więc prawdopodobnie nie będzie to wielka sprawa. Ponadto, jeśli inny program próbował sondować dane lub miał zostać zapisany w pliku stronicowania, zawsze będzie to wersja wyzerowana.

Istnieją również pewne obawy związane z używaniem memset () w GCC 6.1 w funkcji samodzielnej. Kompilator GCC 6.1 na Godbolt może być zepsuty, ponieważ GCC 6.1 wydaje się generować normalną pętlę (jak 5.3 robi na Godbolt) dla samodzielnej funkcji dla niektórych ludzi. (Przeczytaj komentarze do odpowiedzi Zwol.)

cooky451
źródło
4
Używanie IMHO volatilejest błędem, chyba że udowodniono inaczej. Ale najprawdopodobniej błąd. volatilejest tak niedookreślony, że jest niebezpieczny - po prostu go nie używaj.
Jesper Juhl
19
@JesperJuhl: Nie, volatilew tym przypadku jest odpowiednie.
Dietrich Epp
9
@NathanOliver: To nie zadziała, ponieważ kompilatory mogą zoptymalizować martwe sklepy, nawet jeśli używają memset. Problem w tym, że kompilatory dokładnie wiedzą, co memsetrobi.
Dietrich Epp
8
@PaulStelian: To utworzyłoby volatilewskaźnik, do którego chcemy, aby wskaźnik volatile(nie obchodzi nas, czy ++jest ścisły, ale czy *p = 0jest ścisły).
Dietrich Epp
7
@JesperJuhl: Nie ma nic niedookreślonego na temat niestabilności.
GManNickG

Odpowiedzi:

82

Zachowanie GCC może być zgodne, a nawet jeśli tak nie jest, nie powinieneś polegać na volatilerobieniu tego, co chcesz w takich przypadkach. Komitet C zaprojektowany volatiledla rejestrów sprzętowych mapowanych w pamięci i dla zmiennych modyfikowanych podczas nieprawidłowego przepływu sterowania (np. Obsługi sygnałów i setjmp). To jedyne rzeczy, na których można polegać. Używanie ogólnej adnotacji „nie optymalizuj tego” nie jest bezpieczne.

W szczególności norma jest niejasna w kluczowym punkcie. (Przekonwertowałem twój kod na C; nie powinno być tutaj żadnych rozbieżności między C i C ++. Również ręcznie wykonałem wstawianie przed wątpliwą optymalizacją, aby pokazać co kompilator "widzi" w tym momencie .)

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;
}

Pętla do czyszczenia pamięci uzyskuje dostęp arrza pośrednictwem lvalue kwalifikowanej ulotnie, ale arrsama nie jest zadeklarowana volatile. Dlatego przynajmniej prawdopodobnie kompilator C może wywnioskować, że zapasy utworzone przez pętlę są „martwe”, i całkowicie usunąć pętlę. W uzasadnieniu C znajduje się tekst, który sugeruje, że komitet chciał wymagać, aby te sklepy były konserwowane, ale sam standard w rzeczywistości nie nakłada takiego wymogu, jak go czytałem.

Aby uzyskać więcej informacji na temat tego, czego standard wymaga, a czego nie, zobacz Dlaczego zmienna zmienna lokalna zmienna jest zoptymalizowana inaczej niż argument zmienny i dlaczego optymalizator generuje pętlę bez operacji na podstawie tego drugiego? , Czy dostęp do zadeklarowanego nieulotnego obiektu poprzez zmienne odniesienie / wskaźnik nadaje zmienne reguły tym dostępom? i błąd GCC 71793 .

Aby uzyskać więcej informacji na temat tego, o czym myśli komisja volatile, poszukaj w uzasadnieniu C99 słowa „niestabilny”. Artykuł Johna Regehra „ Volatiles are Miscompiled ” ilustruje szczegółowo, w jaki sposób oczekiwania programistów volatilemogą nie zostać spełnione przez kompilatory produkcyjne. Seria esejów zespołu LLVM „ Co każdy programista języka C powinien wiedzieć o niezdefiniowanym zachowaniu ” nie dotyczy konkretnie, volatileale pomoże ci zrozumieć, w jaki sposób i dlaczego współczesne kompilatory języka C nie są „przenośnymi asemblerami”.


Do praktycznego pytania o to, jak zaimplementować funkcję, która robi to, co chciałeś volatileZeroMemoryzrobić: Bez względu na to, czego wymaga lub miał wymagać norma, najrozsądniej byłoby założyć, że nie możesz tego użyć volatile. Jest to alternatywa, że można powołać się na pracy, ponieważ byłoby złamać zbyt wiele innych rzeczy, jeśli to nie działa:

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) {}

Musisz jednak absolutnie upewnić się, że memory_optimization_fencenie jest to pod żadnym pozorem. Musi znajdować się we własnym pliku źródłowym i nie może podlegać optymalizacji czasu łącza.

Istnieją inne opcje, opierające się na rozszerzeniach kompilatora, które mogą być użyteczne w pewnych okolicznościach i mogą generować ściślejszy kod (jedna z nich pojawiła się w poprzednim wydaniu tej odpowiedzi), ale żadna nie jest uniwersalna.

(Polecam wywołanie funkcji explicit_bzero, ponieważ jest ona dostępna pod tą nazwą w więcej niż jednej bibliotece C. Jest co najmniej czterech innych pretendentów do tej nazwy, ale każdy z nich został przyjęty tylko przez jedną bibliotekę C.)

Powinieneś także wiedzieć, że nawet jeśli możesz to uruchomić, może to nie wystarczyć. W szczególności rozważ

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);
}

Zakładając, że sprzęt z instrukcjami przyspieszania AES, jeśli expand_keyi encrypt_with_eksą wbudowane, kompilator może być w stanie zachować ekcałość w pliku rejestru wektorowego - aż do wywołania explicit_bzero, które zmusza go do skopiowania poufnych danych na stos tylko w celu ich usunięcia, oraz gorzej, nie robi nic z klawiszami, które wciąż znajdują się w rejestrach wektorów!

zwol
źródło
6
To interesujące ... Chciałbym zobaczyć odniesienie do komentarzy komisji.
Dietrich Epp
10
Jak to się zgadza z definicją w 6.7.3 (7) volatilejako [...] Dlatego każde wyrażenie odnoszące się do takiego obiektu powinno być oceniane ściśle według reguł maszyny abstrakcyjnej, jak opisano w 5.1.2.3. Ponadto w każdym punkcie sekwencji wartość ostatnio zapisana w obiekcie będzie zgodna z wartością zalecaną przez maszynę abstrakcyjną , z wyjątkiem modyfikacji przez nieznane czynniki wspomniane wcześniej. To, co stanowi dostęp do obiektu, który ma typ ulotny-kwalifikowany, jest zdefiniowane przez implementację. ?
Ionotexist Idonotexist
15
@IwillnotexistIdonotexist Kluczowym słowem w tym fragmencie jest obiekt . volatile sig_atomic_t flag;jest lotnym przedmiot . *(volatile char *)foojest jedynie dostępem poprzez zmienną kwalifikowaną l-wartość, a standard nie wymaga tego, aby mieć jakiekolwiek efekty specjalne.
zwol
3
Norma mówi, jakie kryteria musi spełniać coś, aby była implementacją „zgodną”. Nie stara się opisać, jakie kryteria musi spełniać implementacja na danej platformie, aby była implementacją „dobrą” lub „użyteczną”. Potraktowanie go przez GCC volatilemoże być wystarczające, aby uczynić ją implementacją „zgodną”, ale to nie znaczy, że wystarczy, aby była „dobra” lub „użyteczna”. W przypadku wielu rodzajów programowania systemów należy to uznać za żałośnie ułomne w tym względzie.
supercat
3
Specyfikacja C mówi również raczej bezpośrednio: "Rzeczywista implementacja nie musi oceniać części wyrażenia, jeśli może wywnioskować, że jej wartość nie jest używana i że nie są wytwarzane żadne potrzebne efekty uboczne (w tym wszelkie spowodowane wywołaniem funkcji lub dostępem do zmiennego obiektu ) ”. (moje podkreślenie).
Johannes Schaub - litb
15

Potrzebuję funkcji, która (jak SecureZeroMemory z WinAPI) zawsze zeruje pamięć i nie jest zoptymalizowana,

Do tego służy standardowa funkcja memset_s.


Czy takie zachowanie jest zgodne z lotny, czy nie, to trochę trudno powiedzieć, a lotny został powiedział do od dawna boryka się z błędów.

Jedną z kwestii jest to, że specyfikacje mówią, że „Dostęp do obiektów ulotnych jest oceniany ściśle według reguł abstrakcyjnej maszyny”. Ale to odnosi się tylko do „obiektów ulotnych”, a nie dostępu do nieulotnego obiektu za pomocą wskaźnika, do którego dodano zmienne. Najwyraźniej więc jeśli kompilator może stwierdzić, że tak naprawdę nie uzyskujesz dostępu do obiektu ulotnego, nie jest wymagane traktowanie obiektu jako ulotnego.

bames53
źródło
4
Uwaga: jest to część standardu C11 i nie jest jeszcze dostępne we wszystkich zestawach narzędzi.
Dietrich Epp
5
Należy zauważyć, że co ciekawe, ta funkcja jest znormalizowana dla C11, ale nie dla C ++ 11, C ++ 14 lub C ++ 17. Więc technicznie nie jest to rozwiązanie dla C ++, ale zgadzam się, że wydaje się to najlepszą opcją z praktycznego punktu widzenia. W tym momencie zastanawiam się, czy zachowanie z GCC jest zgodne, czy nie. Edycja: Właściwie VS 2015 nie ma memset_s, więc nie jest jeszcze tak przenośny.
cooky451
2
@ cooky451 Myślałem, że C ++ 17 pobiera standardową bibliotekę C11 przez odniesienie (zobacz drugi Różne).
nwp
14
Ponadto opisywanie memset_sjako standard C11 to przesada. Jest częścią załącznika K, który jest opcjonalny w C11 (a zatem również opcjonalny w C ++). Zasadniczo wszyscy wdrażający, w tym Microsoft, którego pomysł był na pierwszym miejscu (!), Odmówili przyjęcia go; ostatnio słyszałem, że rozmawiali o złomowaniu go w C-next.
zwol
8
@ cooky451 W niektórych kręgach Microsoft jest znany z tego, że narzucał standardowi C rzeczy w zasadzie z powodu zastrzeżeń wszystkich innych, a potem nie zawracał sobie głowy wdrażaniem go samodzielnie. (Najbardziej skandalicznym tego przykładem jest złagodzenie przez C99 reguł określających, jakie typy bazowe size_tmogą być. ABI Win64 nie jest zgodne z C90. To byłoby ... nie w porządku , ale nie straszne ... gdyby MSVC faktycznie odebrał rzeczy takie jak C99 uintmax_ti %zuw odpowiednim czasie, ale tak się nie stało .)
zwolnij
2

Oferuję tę wersję jako przenośny C ++ (chociaż semantyka jest nieco inna):

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

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

Teraz masz dostęp do zapisu do obiektu ulotnego , a nie tylko dostęp do obiektu nieulotnego utworzony za pomocą ulotnego widoku obiektu.

Różnica semantyczna polega na tym, że teraz formalnie kończy ona żywotność dowolnego obiektu (obiektów) zajmowanego w regionie pamięci, ponieważ pamięć została ponownie wykorzystana. Tak więc dostęp do obiektu po wyzerowaniu jego zawartości jest teraz z pewnością niezdefiniowanym zachowaniem (wcześniej w większości przypadków byłoby to niezdefiniowane zachowanie, ale z pewnością istniały pewne wyjątki).

Aby użyć tego zerowania w okresie istnienia obiektu zamiast na końcu, obiekt wywołujący powinien użyć umieszczenia, newaby ponownie umieścić nowe wystąpienie oryginalnego typu.

Kod można skrócić (aczkolwiek mniej czytelny) za pomocą inicjalizacji wartości:

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

w tym momencie jest to jednolinijka i ledwo gwarantuje w ogóle funkcję pomocniczą.

Ben Voigt
źródło
2
Gdyby dostęp do obiektu po wykonaniu funkcji wywołałby UB, oznaczałoby to, że taki dostęp mógłby dać wartości, które obiekt trzymał przed „wyczyszczeniem”. Jak to nie jest przeciwieństwem bezpieczeństwa?
supercat
0

Powinno być możliwe napisanie przenośnej wersji funkcji przy użyciu nietrwałego obiektu po prawej stronie i zmuszając kompilator do zachowania magazynów w tablicy.

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];
}

zeroObiekt jest zadeklarowana volatile, który zapewnia, kompilator może dokonać żadnych założeń co do jego wartości, mimo że zawsze ocenia jako zero.

Ostatnie wyrażenie przypisania odczytuje zmienny indeks w tablicy i zapisuje wartość w zmiennym obiekcie. Ponieważ tego odczytu nie można zoptymalizować, zapewnia to, że kompilator musi wygenerować magazyny określone w pętli.

D Krueger
źródło
1
To w ogóle nie działa ... wystarczy spojrzeć na generowany kod.
cooky451
1
Po lepszym przeczytaniu mojego wygenerowanego ASM mo 'wydaje się, że wbudowuje wywołanie funkcji i zachowuje pętlę, ale nie wykonuje żadnego przechowywania *ptrpodczas tej pętli, ani właściwie niczego ... po prostu zapętla. wtf, idzie mój mózg.
underscore_d
3
@underscore_d Dzieje się tak, ponieważ optymalizuje sklep, zachowując odczyt zmiennych.
D Krueger,
1
Tak, i zrzuca wynik do niezmiennego edx: Dostaję to:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d
1
Jeśli zmienię funkcję, aby umożliwić przekazanie dowolnego volatile unsigned char constbajtu wypełnienia ... nawet go nie czyta . Wygenerowane wbudowane wywołanie volatileFill()to just [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Dlaczego optymalizator (A) nie odczytuje ponownie bajtu wypełniania, a (B) zawraca sobie głowę zachowaniem pętli, w której nic nie robi?
underscore_d