Apakah definisi "volatile" ini tidak stabil, atau apakah GCC mengalami beberapa masalah kepatuhan standar?

89

Saya memerlukan fungsi yang (seperti SecureZeroMemory dari WinAPI) selalu memiliki memori nol dan tidak dapat dioptimalkan, bahkan jika kompiler berpikir bahwa memori tidak akan pernah diakses lagi setelah itu. Sepertinya kandidat yang sempurna untuk volatile. Namun saya mengalami beberapa masalah saat membuatnya berfungsi dengan GCC. Berikut ini contoh fungsinya:

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

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

Cukup sederhana. Tetapi kode yang sebenarnya dihasilkan GCC jika Anda memanggilnya sangat bervariasi dengan versi kompilator dan jumlah byte yang sebenarnya Anda coba nol. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 dan 4.5.3 tidak pernah mengabaikan volatile.
  • GCC 4.6.4 dan 4.7.3 mengabaikan volatile untuk ukuran array 1, 2, dan 4.
  • GCC 4.8.1 hingga 4.9.2 mengabaikan volatile untuk ukuran array 1 dan 2.
  • GCC 5.1 hingga 5.3 mengabaikan volatile untuk ukuran array 1, 2, 4, 8.
  • GCC 6.1 mengabaikannya untuk ukuran larik apa pun (poin bonus untuk konsistensi).

Kompiler lain yang telah saya uji (clang, icc, vc) menghasilkan penyimpanan yang diharapkan, dengan versi kompiler dan ukuran array apa pun. Jadi pada titik ini saya bertanya-tanya, apakah ini bug compiler GCC (cukup lama dan parah?), Atau apakah definisi volatile dalam standar tidak tepat bahwa ini sebenarnya merupakan perilaku yang sesuai, sehingga pada dasarnya tidak mungkin untuk menulis portabel "" Fungsi SecureZeroMemory?

Edit: Beberapa pengamatan menarik.

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

Kemungkinan penulisan dari callMeMaybe () akan membuat semua versi GCC kecuali 6.1 menghasilkan penyimpanan yang diharapkan. Mengomentari di pagar memori juga akan membuat GCC 6.1 menghasilkan penyimpanan, meskipun hanya dikombinasikan dengan kemungkinan penulisan dari callMeMaybe ().

Seseorang juga menyarankan untuk membersihkan cache. Microsoft tidak mencoba membersihkan cache sama sekali di "SecureZeroMemory". Cache kemungkinan besar akan dinonaktifkan dengan cukup cepat, jadi ini mungkin bukan masalah besar. Juga, jika program lain mencoba untuk menyelidiki data, atau jika itu akan ditulis ke file halaman, itu akan selalu menjadi versi nol.

Ada juga beberapa kekhawatiran tentang GCC 6.1 yang menggunakan memset () dalam fungsi standalone. Kompiler GCC 6.1 pada godbolt mungkin merupakan build yang rusak, karena GCC 6.1 tampaknya menghasilkan loop normal (seperti yang dilakukan 5.3 pada godbolt) untuk fungsi mandiri bagi sebagian orang. (Baca komentar jawaban zwol.)

cooky451
sumber
4
Penggunaan IMHO volatileadalah bug kecuali terbukti sebaliknya. Tapi kemungkinan besar bug. volatilesangat tidak ditentukan sehingga berbahaya - jangan gunakan saja.
Jesper Juhl
19
@JesperJuhl: Tidak, volatiletepat dalam kasus ini.
Dietrich Epp
9
@NathanOliver: Itu tidak akan berhasil, karena kompiler dapat mengoptimalkan penyimpanan mati bahkan jika mereka menggunakannya memset. Masalahnya adalah kompiler tahu persis apa yang memsetdilakukannya.
Dietrich Epp
8
@PaulStelian: Itu akan membuat sebuah volatilepointer, kami ingin sebuah pointer ke volatile(kami tidak peduli apakah ++ketat, tetapi apakah *p = 0ketat).
Dietrich Epp
7
@JesperJuhl: Tidak ada yang diremehkan tentang volatile.
GManNickG

Jawaban:

82

Perilaku GCC mungkin sesuai, dan meskipun tidak, Anda tidak boleh mengandalkan volatileuntuk melakukan apa yang Anda inginkan dalam kasus seperti ini. Komite C dirancang volatileuntuk register perangkat keras yang dipetakan dengan memori dan untuk variabel yang dimodifikasi selama aliran kontrol abnormal (misalnya penangan sinyal dan setjmp). Itu adalah satu-satunya hal yang dapat diandalkan. Tidak aman digunakan sebagai anotasi umum "jangan optimalkan ini".

Secara khusus, standarnya tidak jelas tentang poin kunci. (Saya telah mengubah kode Anda menjadi C; seharusnya tidak ada perbedaan antara C dan C ++ di sini. Saya juga telah melakukan inlining secara manual yang akan terjadi sebelum pengoptimalan yang dipertanyakan, untuk menunjukkan apa yang "dilihat" oleh compiler pada saat itu .)

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

Loop pembersihan memori mengakses arrmelalui nilai l yang memenuhi syarat volatile, tetapi arritu sendiri tidak dideklarasikan volatile. Oleh karena itu, setidaknya boleh dibilang kompiler C dapat menyimpulkan bahwa penyimpanan yang dibuat oleh loop "mati", dan menghapus loop sama sekali. Ada teks dalam Rationale C yang menyiratkan bahwa panitia bermaksud mewajibkan toko-toko itu untuk dilestarikan, tetapi standar itu sendiri tidak benar-benar membuat persyaratan itu, saat saya membacanya.

Untuk diskusi lebih lanjut tentang apa yang standar diperlukan atau tidak, lihat Mengapa variabel lokal yang mudah menguap dioptimalkan secara berbeda dari argumen yang mudah menguap, dan mengapa pengoptimal menghasilkan loop tanpa operasi dari yang terakhir? , Apakah mengakses objek non-volatile yang dideklarasikan melalui referensi / penunjuk volatile memberikan aturan volatil atas akses tersebut? , dan bug GCC 71793 .

Untuk mengetahui lebih lanjut tentang apa yang dipikirkan komite volatile, cari C99 Rationale untuk kata "volatile". Makalah John Regehr " Volatiles are Miscompiled " menggambarkan secara rinci bagaimana ekspektasi programmer volatilemungkin tidak dipenuhi oleh kompiler produksi. Rangkaian esai tim LLVM " Yang Harus Diketahui Setiap Pemrogram C Tentang Perilaku Tak Terdefinisi " tidak menyentuh secara khusus volatiletetapi akan membantu Anda memahami bagaimana dan mengapa kompiler C modern bukan "perakit portabel".


Untuk pertanyaan praktis tentang bagaimana mengimplementasikan suatu fungsi yang melakukan apa yang ingin Anda volatileZeroMemorylakukan: Terlepas dari apa yang diminta atau dimaksudkan oleh standar, akan lebih bijaksana untuk berasumsi bahwa Anda tidak dapat menggunakan volatileuntuk ini. Ada adalah alternatif yang dapat diandalkan untuk bekerja, karena akan merusak terlalu banyak hal-hal lain jika tidak bekerja:

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

Namun, Anda harus benar-benar memastikan bahwa memory_optimization_fenceitu tidak dalam keadaan apa pun. Itu harus dalam file sumbernya sendiri dan tidak boleh dikenai pengoptimalan waktu tautan.

Ada opsi lain, mengandalkan ekstensi kompilator, yang mungkin dapat digunakan dalam beberapa keadaan dan dapat menghasilkan kode yang lebih ketat (salah satunya muncul di edisi sebelumnya dari jawaban ini), tetapi tidak ada yang universal.

(Saya sarankan memanggil fungsi tersebut explicit_bzero , karena ini tersedia dengan nama itu di lebih dari satu perpustakaan C. Setidaknya ada empat pesaing lain untuk nama tersebut, tetapi masing-masing telah diadopsi hanya oleh satu perpustakaan C.)

Anda juga harus tahu bahwa, meskipun Anda bisa mendapatkan ini untuk bekerja, itu mungkin tidak cukup. Secara khusus, pertimbangkan

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

Dengan asumsi perangkat keras dengan instruksi akselerasi AES, jika expand_keydan encrypt_with_eksebaris, kompilator mungkin dapat menyimpan ekseluruhnya dalam file register vektor - hingga panggilan ke explicit_bzero, yang memaksanya untuk menyalin data sensitif ke tumpukan hanya untuk menghapusnya, dan, Lebih buruk lagi, tidak melakukan apa-apa tentang kunci yang masih ada di register vektor!

zwol
sumber
6
Itu menarik ... Saya tertarik melihat referensi dari komentar panitia.
Dietrich Epp
10
Bagaimana persegi ini dengan definisi 6.7.3 (7) volatilesebagai [...] Oleh karena itu setiap ekspresi yang mengacu pada objek seperti itu harus dievaluasi secara ketat sesuai dengan aturan mesin abstrak, seperti yang dijelaskan dalam 5.1.2.3. Selanjutnya, pada setiap titik urutan nilai yang terakhir disimpan dalam objek harus sesuai dengan yang ditentukan oleh mesin abstrak , kecuali sebagaimana yang dimodifikasi oleh faktor-faktor yang tidak diketahui yang disebutkan sebelumnya. Apa yang dimaksud dengan akses ke objek yang memiliki tipe berkualifikasi volatile ditentukan oleh implementasi. ?
Aku tidak akan ada Idonotexist
15
@IwillnotexistIdonotexist Kata kuncinya adalah objek . volatile sig_atomic_t flag;adalah objek yang mudah menguap . *(volatile char *)foohanyalah akses melalui nilai l yang memenuhi syarat volatile dan standarnya tidak mengharuskan untuk memiliki efek khusus.
zwol
3
Standar mengatakan kriteria apa yang harus dipenuhi sesuatu untuk menjadi implementasi yang "patuh". Tidak ada upaya untuk menjelaskan kriteria apa yang harus dipenuhi oleh implementasi pada platform tertentu untuk menjadi implementasi yang "baik" atau yang "dapat digunakan". Perlakuan GCC terhadap volatilemungkin cukup untuk membuatnya menjadi implementasi yang "patuh", tetapi itu tidak berarti itu cukup untuk menjadi "baik" atau "berguna". Untuk banyak jenis pemrograman sistem, itu harus dianggap sangat kurang dalam hal itu.
supercat
3
Spesifikasi C juga secara langsung mengatakan "Implementasi aktual tidak perlu mengevaluasi bagian dari ekspresi jika dapat menyimpulkan bahwa nilainya tidak digunakan dan tidak ada efek samping yang diperlukan yang dihasilkan ( termasuk yang disebabkan oleh pemanggilan fungsi atau mengakses objek volatil ) . " (tekankan milik saya).
Johannes Schaub - litb
15

Saya membutuhkan fungsi yang (seperti SecureZeroMemory dari WinAPI) selalu nol memori dan tidak bisa dioptimalkan,

Untuk itulah fungsi standar memset_situ.


Mengenai apakah perilaku dengan volatile ini sesuai atau tidak, itu agak sulit untuk dikatakan, dan volatile telah lama dikatakan telah diganggu oleh bug.

Satu masalah adalah spesifikasi mengatakan bahwa "Akses ke objek volatil dievaluasi secara ketat sesuai dengan aturan mesin abstrak." Tapi itu hanya mengacu pada 'objek volatile', tidak mengakses objek non-volatile melalui pointer yang telah ditambahkan volatile. Jadi tampaknya jika kompiler dapat mengetahui bahwa Anda tidak benar-benar mengakses objek volatile, maka tidak perlu memperlakukan objek tersebut sebagai volatile.

bames53
sumber
4
Catatan: Ini adalah bagian dari standar C11, dan belum tersedia di semua toolchain.
Dietrich Epp
5
Menariknya, fungsi ini distandarisasi untuk C11 tetapi tidak untuk C ++ 11, C ++ 14 atau C ++ 17. Jadi secara teknis ini bukan solusi untuk C ++, tapi saya setuju bahwa ini sepertinya pilihan terbaik dari perspektif praktis. Pada titik ini saya bertanya-tanya apakah perilaku dari GCC sesuai atau tidak. Sunting: Sebenarnya VS 2015 belum memiliki memset_s, jadi belum semuanya portabel.
cooky451
2
@ cooky451 Saya pikir C ++ 17 menarik pustaka standar C11 dengan referensi (lihat Misc kedua).
nwp
14
Juga, mendeskripsikan memset_ssebagai standar C11 adalah pernyataan yang berlebihan. Ini adalah bagian dari Annex K, yang opsional di C11 (dan karena itu juga opsional di C ++). Pada dasarnya semua pelaksana, termasuk Microsoft, yang idenya pertama kali (!), Telah menolak untuk mengambilnya; terakhir saya mendengar mereka berbicara tentang membatalkannya di C-next.
zwol
8
@ cooky451 Di kalangan tertentu, Microsoft terkenal memaksa hal-hal ke dalam standar C pada dasarnya keberatan orang lain dan kemudian tidak mau repot-repot menerapkannya sendiri. (Contoh paling mengerikan dari ini adalah pelonggaran C99 dari aturan untuk jenis yang mendasari size_tdiizinkan. Win64 ABI tidak sesuai dengan C90. Itu akan ... tidak baik , tetapi tidak buruk ... jika MSVC sebenarnya mengambil C99 hal-hal seperti uintmax_tdan %zupada waktu yang tepat, tetapi ternyata tidak .)
zwol
2

Saya menawarkan versi ini sebagai C ++ portabel (meskipun semantiknya sedikit berbeda):

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

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

Sekarang Anda memiliki akses tulis ke objek volatile , tidak hanya akses ke objek non-volatile yang dibuat melalui tampilan objek volatile.

Perbedaan semantiknya adalah sekarang secara resmi mengakhiri masa pakai objek apa pun yang menempati wilayah memori, karena memori telah digunakan kembali. Jadi akses ke objek setelah memusatkan perhatian pada isinya sekarang jelas merupakan perilaku yang tidak terdefinisi (sebelumnya akan menjadi perilaku tidak terdefinisi dalam banyak kasus, tetapi beberapa pengecualian pasti ada).

Untuk menggunakan zeroing ini selama masa pakai objek, bukan di akhir, pemanggil harus menggunakan penempatan newuntuk mengembalikan instance baru dari tipe asli.

Kode dapat dibuat lebih pendek (meskipun kurang jelas) dengan menggunakan inisialisasi nilai:

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

dan pada titik ini ini adalah satu-baris dan hampir tidak memerlukan fungsi pembantu sama sekali.

Ben Voigt
sumber
2
Jika akses ke objek setelah fungsi dijalankan akan memanggil UB, itu berarti akses tersebut dapat menghasilkan nilai yang dipegang objek sebelum "dihapus". Bagaimana itu bukan kebalikan dari keamanan?
supercat
0

Seharusnya dimungkinkan untuk menulis versi portabel dari fungsi dengan menggunakan objek volatile di sisi kanan dan memaksa compiler untuk menyimpan penyimpanan ke array.

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

The zeroobjek dinyatakanvolatile yang menjamin compiler dapat membuat asumsi tentang nilai meskipun selalu mengevaluasi sebagai nol.

Ekspresi tugas akhir membaca dari indeks volatile dalam array dan menyimpan nilai dalam objek volatile. Karena pembacaan ini tidak dapat dioptimalkan, ini memastikan bahwa kompilator harus menghasilkan penyimpanan yang ditentukan dalam loop.

D Krueger
sumber
1
Ini tidak berfungsi sama sekali ... lihat saja kode yang sedang dibuat.
cooky451
1
Setelah membaca ASM saya yang dihasilkan lebih baik, tampaknya inline pemanggilan fungsi dan mempertahankan perulangan, tetapi tidak melakukan penyimpanan apa pun *ptrselama perulangan itu, atau sebenarnya apa pun ... hanya perulangan. wtf, begitulah otak saya.
underscore_d
3
@underscore_d Itu karena itu mengoptimalkan penyimpanan sambil mempertahankan pembacaan volatile.
D Krueger
1
Ya, dan itu membuang hasilnya ke tidak berubah edx: Saya mendapatkan ini:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d
1
Jika saya mengubah fungsi untuk memungkinkan melewatkan volatile unsigned char constbyte pengisian sewenang-wenang ... itu bahkan tidak membacanya . Panggilan inline yang dihasilkan volatileFill()hanya [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Mengapa pengoptimal (A) tidak membaca ulang byte isian dan (B) repot-repot mempertahankan loop di mana ia tidak melakukan apa-apa?
underscore_d