Perhatikan pernyataan berikut:
*((char*)NULL) = 0; //undefined behavior
Ini jelas memunculkan perilaku yang tidak terdefinisi. Apakah adanya pernyataan seperti itu dalam program tertentu berarti bahwa keseluruhan program tidak terdefinisi atau bahwa perilaku hanya menjadi tidak terdefinisi begitu aliran kendali mencapai pernyataan ini?
Apakah program berikut akan terdefinisi dengan baik jika pengguna tidak pernah memasukkan nomor 3
?
while (true) {
int num = ReadNumberFromConsole();
if (num == 3)
*((char*)NULL) = 0; //undefined behavior
}
Atau apakah itu perilaku yang sepenuhnya tidak terdefinisi, apa pun yang dimasukkan pengguna?
Selain itu, dapatkah kompilator berasumsi bahwa perilaku tidak terdefinisi tidak akan pernah dijalankan saat runtime? Itu akan memungkinkan untuk berpikir mundur dalam waktu:
int num = ReadNumberFromConsole();
if (num == 3) {
PrintToConsole(num);
*((char*)NULL) = 0; //undefined behavior
}
Di sini, kompilator dapat beralasan bahwa jika num == 3
kita akan selalu memanggil perilaku tidak terdefinisi. Oleh karena itu, kasus ini tidak mungkin dilakukan dan nomornya tidak perlu dicetak. Seluruh if
pernyataan bisa dioptimalkan. Apakah penalaran mundur semacam ini diperbolehkan menurut standar?
const int i = 0; if (i) 5/i;
.PrintToConsole
tidak memanggilstd::exit
sehingga harus melakukan panggilan.Jawaban:
Tidak keduanya. Kondisi pertama terlalu kuat dan yang kedua terlalu lemah.
Akses objek terkadang diurutkan, tetapi standarnya menggambarkan perilaku program di luar waktu. Danvil sudah mengutip:
Ini dapat diartikan:
Jadi, unreachable statement dengan UB tidak berarti memberi UB program. Pernyataan yang terjangkau (karena nilai masukan) tidak pernah tercapai, tidak memberikan program UB. Itu sebabnya kondisi pertamamu terlalu kuat.
Sekarang, penyusun secara umum tidak bisa membedakan apa yang memiliki UB. Jadi untuk memungkinkan pengoptimal untuk mengatur ulang pernyataan dengan UB potensial yang akan diatur ulang jika perilakunya ditentukan, perlu untuk mengizinkan UB untuk "mencapai waktu kembali" dan salah sebelum titik urutan sebelumnya (atau di C ++ 11, bagi UB untuk mempengaruhi hal-hal yang diurutkan sebelum hal UB). Karena itu kondisi keduamu terlalu lemah.
Contoh utamanya adalah ketika pengoptimal mengandalkan aliasing yang ketat. Inti dari aturan aliasing yang ketat adalah untuk memungkinkan compiler untuk menata ulang operasi yang tidak dapat diurutkan ulang secara valid jika mungkin pointer yang dimaksud alias memori yang sama. Jadi jika Anda menggunakan penunjuk aliasing secara ilegal, dan UB memang terjadi, maka itu dapat dengan mudah memengaruhi pernyataan "sebelum" pernyataan UB. Sejauh menyangkut mesin abstrak, pernyataan UB belum dijalankan. Sejauh menyangkut kode objek yang sebenarnya, itu telah dijalankan sebagian atau seluruhnya. Namun standar tersebut tidak mencoba merinci apa artinya bagi pengoptimal untuk menyusun ulang pernyataan, atau apa implikasinya bagi UB. Itu hanya memberi lisensi implementasi untuk melakukan kesalahan segera setelah diinginkan.
Anda bisa menganggapnya sebagai, "UB memiliki mesin waktu".
Secara khusus untuk menjawab contoh Anda:
PrintToConsole(3)
diketahui pasti akan kembali. Itu bisa menimbulkan pengecualian atau apa pun.Contoh serupa untuk yang kedua adalah opsi gcc
-fdelete-null-pointer-checks
, yang dapat mengambil kode seperti ini (saya belum memeriksa contoh khusus ini, anggap itu ilustrasi dari gagasan umum):void foo(int *p) { if (p) *p = 3; std::cout << *p << '\n'; }
dan ubah menjadi:
*p = 3; std::cout << "3\n";
Mengapa? Karena jika
p
null maka kodenya memiliki UB, jadi kompilator mungkin menganggapnya bukan null dan mengoptimalkannya. Kernel linux tersandung ini ( https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897 ) pada dasarnya karena beroperasi dalam mode di mana dereferensi pointer nol tidak seharusnya menjadi UB, diharapkan menghasilkan pengecualian perangkat keras yang ditentukan yang dapat ditangani kernel. Saat pengoptimalan diaktifkan, gcc memerlukan penggunaan-fno-delete-null-pointer-checks
untuk untuk memberikan jaminan yang melebihi standar.PS Jawaban praktis untuk pertanyaan "kapan perilaku yang tidak terdefinisi menyerang?" adalah "10 menit sebelum Anda berencana berangkat hari itu".
sumber
void can_add(int x) { if (x + 100 < x) complain(); }
dapat dioptimalkan pergi seluruhnya, karena jikax+100
doesn' meluap tidak ada yang terjadi, dan jikax+100
tidak meluap, itu UB sesuai standar, sehingga tidak ada mungkin terjadi.3
jika diinginkan, dan berkemas ke rumah untuk hari itu segera setelah melihatnya masuk.Status standar pada 1.9 / 4
Hal yang menarik mungkin adalah apa artinya "berisi". Beberapa saat kemudian pada 1.9 / 5 dinyatakan:
Di sini secara khusus menyebutkan "eksekusi ... dengan masukan itu". Saya akan menafsirkannya sebagai, perilaku tidak terdefinisi dalam satu kemungkinan cabang yang tidak dieksekusi saat ini tidak mempengaruhi cabang eksekusi saat ini.
Namun masalah yang berbeda adalah asumsi yang didasarkan pada perilaku tidak terdefinisi selama pembuatan kode. Simak jawaban Steve Jessop untuk lebih jelasnya tentang itu.
sumber
Contoh instruktif adalah
int foo(int x) { int a; if (x) return a; return 0; }
GCC saat ini dan Clang saat ini akan mengoptimalkan ini (pada x86) menjadi
karena mereka menyimpulkan bahwa
x
selalu nol dari UB diif (x)
jalur kendali. GCC bahkan tidak akan memberi Anda peringatan use-of-uninitialized-value! (karena pass yang menerapkan logika di atas berjalan sebelum pass yang menghasilkan peringatan nilai yang tidak diinisialisasi)sumber
a
bahkan jika dalam semua situasi di mana tidak diinisialisasia
akan diteruskan ke fungsi yang fungsi itu tidak akan pernah melakukan apa pun dengannya)?Draf kerja C ++ saat ini mengatakan di 1.9.4 bahwa
Berdasarkan ini, saya akan mengatakan bahwa program yang berisi perilaku tidak terdefinisi pada jalur eksekusi apa pun dapat melakukan apa saja setiap kali eksekusinya.
Ada dua artikel yang sangat bagus tentang perilaku tidak terdefinisi dan apa yang biasanya dilakukan oleh kompiler:
sumber
int f(int x) { if (x > 0) return 100/x; else return 100; }
tentu tidak pernah memunculkan perilaku yang tidak terdefinisi, meskipun100/0
tentu saja tidak terdefinisi.printf("Hello, World"); *((char*)NULL) = 0
tidak ada jaminan untuk mencetak apa pun. Ini membantu pengoptimalan, karena kompilator dapat dengan bebas mengatur ulang operasi (tergantung pada batasan ketergantungan, tentu saja) yang diketahuinya akan terjadi pada akhirnya, tanpa harus memperhitungkan perilaku yang tidak ditentukan.int x,y; std::cin >> x >> y; std::cout << (x+y);
boleh mengatakan bahwa "1 + 1 = 17", hanya karena ada beberapa masukan yangx+y
melimpah (yang merupakan UB karenaint
merupakan tipe yang ditandatangani).Kata "perilaku" berarti sesuatu sedang dilakukan . Sebuah statemenr yang tidak pernah dieksekusi bukanlah "perilaku".
Sebuah ilustrasi:
*ptr = 0;
Apakah itu perilaku yang tidak terdefinisi? Misalkan kita 100% yakin
ptr == nullptr
setidaknya sekali selama eksekusi program. Jawabannya harus ya.Bagaimana dengan ini?
if (ptr) *ptr = 0;
Apakah itu tidak ditentukan? (Ingat
ptr == nullptr
setidaknya sekali?) Saya harap tidak, jika tidak, Anda tidak akan bisa menulis program yang berguna sama sekali.Tidak ada orang srandard yang dirugikan dalam membuat jawaban ini.
sumber
Perilaku tidak terdefinisi menyerang ketika program akan menyebabkan perilaku tak terdefinisi apa pun yang terjadi selanjutnya. Namun, Anda memberi contoh berikut.
int num = ReadNumberFromConsole(); if (num == 3) { PrintToConsole(num); *((char*)NULL) = 0; //undefined behavior }
Kecuali jika kompilator mengetahui definisinya
PrintToConsole
, ia tidak dapat menghapusif (num == 3)
kondisional. Anggaplah Anda memilikiLongAndCamelCaseStdio.h
header sistem dengan deklarasi berikut iniPrintToConsole
.void PrintToConsole(int);
Tidak terlalu membantu, oke. Sekarang, mari kita lihat seberapa jahat (atau mungkin tidak begitu jahat, perilaku tidak terdefinisi bisa lebih buruk) vendor, dengan memeriksa definisi sebenarnya dari fungsi ini.
int printf(const char *, ...); void exit(int); void PrintToConsole(int num) { printf("%d\n", num); exit(0); }
Kompilator sebenarnya harus berasumsi bahwa sembarang fungsi yang tidak diketahui oleh kompilator dapat keluar atau memunculkan pengecualian (dalam kasus C ++). Anda dapat melihat bahwa
*((char*)NULL) = 0;
tidak akan dieksekusi, karena eksekusi tidak akan dilanjutkan setelahPrintToConsole
panggilan.Perilaku tidak terdefinisi menyerang kapan
PrintToConsole
benar-benar kembali. Kompilator mengharapkan ini tidak terjadi (karena ini akan menyebabkan program mengeksekusi perilaku tidak terdefinisi apapun yang terjadi), oleh karena itu apapun bisa terjadi.Namun, mari pertimbangkan hal lain. Katakanlah kita melakukan pemeriksaan null, dan menggunakan variabel setelah pemeriksaan null.
int putchar(int); const char *warning; void lol_null_check(const char *pointer) { if (!pointer) { warning = "pointer is null"; } putchar(*pointer); }
Dalam hal ini, mudah untuk diperhatikan bahwa
lol_null_check
membutuhkan pointer non-NULL. Menetapkan kewarning
variabel non-volatile global bukanlah sesuatu yang dapat keluar dari program atau memunculkan pengecualian apa pun. Inipointer
juga non-volatile, sehingga tidak dapat secara ajaib mengubah nilainya di tengah fungsi (jika ya, itu adalah perilaku yang tidak ditentukan). Panggilanlol_null_check(NULL)
akan menyebabkan perilaku tidak terdefinisi yang dapat menyebabkan variabel tidak ditetapkan (karena pada titik ini, fakta bahwa program mengeksekusi perilaku tidak terdefinisi diketahui).Namun, perilaku tidak terdefinisi berarti program dapat melakukan apa saja. Oleh karena itu, tidak ada yang menghentikan perilaku tidak terdefinisi dari kembali ke masa lalu, dan menghentikan program Anda sebelum baris pertama
int main()
eksekusi. Ini adalah perilaku yang tidak jelas, tidak harus masuk akal. Mungkin juga macet setelah mengetik 3, tetapi perilaku yang tidak ditentukan akan kembali ke masa lalu, dan macet bahkan sebelum Anda mengetik 3. Dan siapa tahu, mungkin perilaku yang tidak ditentukan akan menimpa RAM sistem Anda, dan menyebabkan sistem Anda macet 2 minggu kemudian, sementara program Anda yang tidak ditentukan tidak berjalan.sumber
PrintToConsole
adalah usaha saya untuk memasukkan efek samping program-eksternal yang terlihat bahkan setelah crash dan diurutkan dengan kuat. Saya ingin membuat situasi di mana kami dapat memastikan dengan pasti apakah pernyataan ini dioptimalkan. Tetapi Anda benar bahwa hal itu mungkin tidak akan pernah kembali .; Contoh Anda menulis ke global mungkin tunduk pada pengoptimalan lain yang tidak terkait dengan UB. Misalnya global yang tidak digunakan dapat dihapus. Apakah Anda memiliki ide untuk membuat efek samping eksternal dengan cara yang dijamin dapat mengembalikan kendali?volatile
variabel dapat secara sah memicu operasi I / O yang pada gilirannya dapat segera mengganggu thread saat ini; penangan interupsi kemudian dapat mematikan utas sebelum memiliki kesempatan untuk melakukan hal lain. Saya tidak melihat pembenaran yang digunakan kompilator untuk mendorong perilaku tidak terdefinisi sebelum titik itu.Jika program mencapai pernyataan yang memanggil perilaku tidak terdefinisi, tidak ada persyaratan yang ditempatkan pada keluaran / perilaku program apa pun; tidak peduli apakah mereka akan terjadi "sebelum" atau "setelah" perilaku tidak terdefinisi dipanggil.
Alasan Anda tentang ketiga cuplikan kode itu benar. Secara khusus, kompilator dapat memperlakukan pernyataan apa pun yang tanpa syarat memanggil perilaku tidak terdefinisi seperti yang diperlakukan oleh GCC
__builtin_unreachable()
: sebagai petunjuk pengoptimalan bahwa pernyataan itu tidak dapat dijangkau (dan karenanya, semua jalur kode yang mengarah tanpa syarat ke sana juga tidak dapat dijangkau). Pengoptimalan serupa lainnya tentu saja dimungkinkan.sumber
__builtin_unreachable()
mulai memiliki efek yang berjalan mundur dan maju dalam waktu? Diberikan sesuatu sepertiextern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); }
saya bisa melihat halbuiltin_unreachable()
yang baik untuk memberi tahu kompiler itu dapat menghilangkanreturn
instruksi, tetapi itu akan agak berbeda dari mengatakan bahwa kode sebelumnya dapat dihilangkan.__builtin_unreachable
tercapai. Program ini ditentukan.restrict
penunjuk langsung , untuk ditulis menggunakanunsigned char*
.Banyak standar untuk berbagai macam hal menghabiskan banyak usaha untuk menjelaskan hal-hal yang HARUS atau HARUS dilakukan oleh implementasi, menggunakan nomenklatur yang mirip dengan yang didefinisikan dalam IETF RFC 2119 (meskipun tidak harus mengutip definisi dalam dokumen itu). Dalam banyak kasus, deskripsi tentang hal-hal yang harus dilakukan implementasi kecuali dalam kasus di mana hal itu tidak berguna atau tidak praktis lebih penting daripada persyaratan yang harus dipatuhi oleh semua implementasi yang sesuai.
Sayangnya, Standar C dan C ++ cenderung menghindari deskripsi hal-hal yang, meskipun tidak 100% diperlukan, namun harus diharapkan dari implementasi berkualitas yang tidak mendokumentasikan perilaku yang berlawanan. Sebuah saran bahwa implementasi harus melakukan sesuatu dapat dilihat sebagai menyiratkan bahwa mereka yang tidak inferior, dan dalam kasus di mana umumnya akan jelas perilaku mana yang akan berguna atau praktis, versus tidak praktis dan tidak berguna, pada implementasi tertentu, ada sedikit kebutuhan yang dirasakan Standar untuk mengganggu penilaian tersebut.
Kompilator yang pintar dapat menyesuaikan dengan Standar sambil menghilangkan kode apa pun yang tidak akan berpengaruh kecuali ketika kode menerima input yang pasti akan menyebabkan Perilaku Tidak Terdefinisi, tetapi "pintar" dan "bodoh" bukanlah antonim. Fakta bahwa penulis Standar memutuskan bahwa mungkin ada beberapa jenis implementasi di mana berperilaku berguna dalam situasi tertentu tidak berguna dan tidak praktis tidak menyiratkan penilaian apa pun, apakah perilaku tersebut harus dianggap praktis dan berguna bagi orang lain. Jika suatu implementasi dapat mempertahankan jaminan perilaku tanpa biaya selain hilangnya peluang pemangkasan "cabang mati", hampir semua nilai yang dapat diterima kode pengguna dari jaminan tersebut akan melebihi biaya penyediaannya. Penghapusan cabang mati mungkin baik-baik saja dalam kasus di mana tidak perlu menyerah, Tetapi jika dalam situasi kode pengguna yang diberikan bisa ditangani hampir semua perilaku yang mungkin lain dari eliminasi mati-cabang, setiap upaya kode pengguna harus mengeluarkan menghindari UB kemungkinan akan melebihi nilai yang dicapai dari DBE.
sumber
x*y < z
kapanx*y
tidak meluap, dan dalam kasus luapan menghasilkan 0 atau 1 secara sewenang-wenang tetapi tanpa efek samping, tidak ada alasan pada sebagian besar platform mengapa memenuhi persyaratan kedua dan ketiga harus lebih mahal daripada memenuhi yang pertama, tetapi cara penulisan ekspresi apa pun untuk menjamin perilaku yang ditentukan standar dalam semua kasus akan dalam beberapa kasus menambah biaya yang signifikan. Menulis ekspresi yang(int64_t)x*y < z
dapat melipatgandakan biaya komputasi ...(int)((unsigned)x*y) < z
akan mencegah kompilator menggunakan apa yang mungkin berguna sebagai substitusi aljabar (misalnya jika ia tahu itux
danz
sama dan positif, itu dapat menyederhanakan ekspresi asli menjadiy<0
, tetapi versi menggunakan unsigned akan memaksa kompiler untuk melakukan perkalian). Jika kompilator dapat menjamin meskipun Standar tidak mengamanatkannya, ia akan mempertahankan persyaratan "hasil 0 atau 1 tanpa efek samping", kode pengguna dapat memberikan peluang pengoptimalan kompilator yang tidak dapat diperolehnya.x*y
memancarkan nilai normal jika terjadi luapan tetapi nilai apa pun sama sekali. UB yang dapat dikonfigurasi dalam C / C ++ tampaknya penting bagi saya.