2 poin oleh GN⁺ 2 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Jika nilai float setelah bagian pecahannya dibuang berada di luar rentang tipe integer tujuan, akan terjadi perilaku tak terdefinisi (UB); konversi implisit, cast bergaya fungsi, dan static_cast semuanya terdampak
  • -Wall dan -Wextra tidak memberi peringatan untuk ini, dan -Wconversion juga hanya mendeteksi konversi implisit, sehingga masalah ini mudah terlewat
  • Fungsi konversi penyempitan aman Microsoft GSL, gsl::narrow, juga memicu UB pada sebagian input floating-point→integer, sehingga gagal memenuhi perilaku yang didokumentasikan, yaitu melempar exception untuk nilai yang tidak dapat direpresentasikan
  • CVTTSS2SI di x86 menangani nilai yang tidak dapat direpresentasikan sebagai INT_MIN, tetapi FCVTZS di AArch64 melakukan konversi saturasi dan mengubah NaN menjadi 0, sehingga hasilnya dapat berbeda bergantung pada hardware
  • Agar konversi aman, rentang harus diperiksa sebelum melakukan cast, dan masalah ini dapat dideteksi dengan opsi UBSan Clang/GCC -fsanitize=float-cast-overflow

Aturan konversi dan batas deteksi

  • Menurut aturan konversi floating-point–integer C++, jika setelah bagian pecahan dibuang nilainya tidak masuk ke tipe integer tujuan, hasilnya menjadi perilaku tak terdefinisi
    • Sekalipun tujuannya unsigned, aritmetika modular tidak diterapkan
    • int i0 = f, int(f), dan static_cast<int>(f) semuanya dapat memicu UB pada sebagian input
  • Sulit menemukan semua masalah hanya dengan peringatan compiler umum
    • -Wall dan -Wextra tidak memberi peringatan untuk ketiga bentuk konversi tersebut
    • -Wconversion hanya memperingatkan konversi implisit
  • Sekalipun program terus berjalan pada prosesor dan compiler saat ini, hasilnya dapat berbeda antarplatform
    • CVTTSS2SI di x86 memetakan input yang tidak dapat direpresentasikan ke INT_MIN
    • FCVTZS di AArch64 melakukan saturasi dan memetakan NaN ke 0
    • UB yang sudah dieksekusi dapat menyebabkan kode tiba-tiba salah berfungsi ketika compiler menerapkan konversi lain

Kasus GSL dan penanganan yang aman

  • gsl::narrow dari Microsoft Guidelines Support Library diposisikan sebagai konversi penyempitan aman yang melempar exception untuk nilai yang tidak dapat direpresentasikan dalam tipe tujuan
    • Namun pada konversi floating-point→integer yang sebenarnya, sebagian input lebih dulu mengeksekusi UB, sehingga tidak sesuai dengan dokumentasi
    • Pihak GSL menilai UB internal tersebut tidak berbahaya karena tidak menyentuh representasi trap hardware pada platform target, dan masalah ini belum diperbaiki sementara logika tersebut tetap tercermin dalam kode
  • Solusi yang benar adalah memeriksa rentang sebelum melakukan cast
  • Di Undefined Behavior Sanitizer Clang dan GCC, UB ini dapat dideteksi dengan -fsanitize=float-cast-overflow
    • Disarankan untuk menguji semua kode C++ dengan UBSan

1 komentar

 
GN⁺ 2 jam lalu
Pendapat di Lobste.rs
  • Saya sudah tahu banyak perilaku tak terdefinisi yang halus di C dan C++, tetapi kasus ini tetap mengejutkan
    Rust juga mewarisi aturan konversi floating-point→integer yang sama dari LLVM IR sehingga sempat memiliki perilaku tak terdefinisi selama beberapa waktu, dan baru pada 2020 diperbaiki agar menghasilkan IR yang lebih kompleks. Karena perbedaan performanya besar, Rust juga menyediakan konversi floating-point→integer tanpa pemeriksaan untuk loop berperforma tinggi ketika diketahui nilainya terbatas dan masuk dalam rentang tipe target
    Sungguh tidak masuk akal jika C++ Core Guidelines Library menganggap ini sepele. LLVM menggunakan informasi yang diperoleh dari konversi untuk menghapus pemeriksaan batas, dan akibatnya ada kasus akses di luar batas array meskipun pemeriksaan batas ada. Jika benar-benar serius soal keamanan memori dan menghindari perilaku tak terdefinisi, hal ini tidak bisa diabaikan, dan pernyataan Herb Sutter tentang “perilaku tak terdefinisi yang jinak” juga mengecewakan

  • Saya tahu C++ punya banyak perilaku tak terdefinisi, tetapi yang ini khususnya mengejutkan. Saya penasaran apakah ini dibiarkan sebagai perilaku tak terdefinisi karena mereka ingin membuat konversinya secepat mungkin, dan tiap arsitektur punya instruksi yang menangani nilai ekstrem ini secara berbeda. Ini tampaknya alasan utama munculnya aturan semacam ini, mirip overflow integer bertanda

    • Jika itu alasannya, seharusnya ini bukan perilaku tak terdefinisi, melainkan perilaku yang ditentukan implementasi. Pembagian dengan nol bisa dipahami sebagai perilaku tak terdefinisi karena pada sebagian arsitektur memicu trap
      Perilaku tak terdefinisi seharusnya dibatasi pada kasus ketika hasil yang konsisten bahkan pada platform yang sama tidak bisa dijamin karena efek di luar mesin abstrak C, seperti use-after-free, atau ketika pada target tertentu bisa memicu trap
      Implementasi yang mematuhi standar dapat mendefinisikan sendiri perilaku tak terdefinisi, dan GCC juga melakukan itu untuk beberapa hal. Jika semantik yang stabil dapat dijamin tanpa biaya performa, perilaku instruksi floating-point→integer per target bisa saja didefinisikan apa adanya, tetapi pada implementasi lain tetap menjadi perilaku tak terdefinisi
    • Mungkin memang begitu. Keluarga fctiw di PowerPC melakukan konversi saturasi, mengubah NaN menjadi INT_MIN, dan juga menyetel flag FPSCR. fctid di Power ISA 64-bit juga menangani integer yang lebih besar dengan cara yang sama, tetapi keduanya tidak cocok dengan perilaku AArch64 maupun x86
  • Dalam kasus seperti ini, lebih tepat memperlakukannya sebagai perilaku yang ditentukan implementasi atau nilai yang tidak ditentukan. Tidak masuk akal jika seluruh program keluar dari kendali standar C++ hanya karena mengonversi infinity ke int
    C++26 telah menghapus beberapa perilaku tak terdefinisi yang tidak masuk akal, dan ini jelas kandidat untuk dihapus juga. Dari eksperimen, GCC dan Clang tampaknya tidak memanfaatkan perilaku tak terdefinisi ini untuk optimisasi, jadi dampak praktisnya terlihat terbatas

  • Ini satu lagi contoh tidak menyenangkan ketika spesifikasi tidak punya alasan untuk menetapkan operasi ini sebagai perilaku tak terdefinisi

  • Satu alasan lagi untuk tidak menyukai IEEE 754. Jika tidak benar-benar diperlukan karena library lain atau performa, saya sebisa mungkin memakai integer murni, bilangan rasional dengan pembilang·penyebut integer besar, atau desimal fixed-point alih-alih floating-point

    • Masalah kali ini tidak ada hubungannya dengan IEEE 754 dan sepenuhnya kesalahan C++
  • Jika pernah memakai C atau C++, perbedaan antara float32 dan int32, bahkan int64, seharusnya bukan hal mengejutkan. float32 dengan eksponen besar bisa merepresentasikan nilai integer yang jauh lebih besar daripada int64
    Di antara format yang bukan superset satu sama lain dan memiliki kemampuan representasi berbeda, tidak ada alasan untuk menganggap konversi floating-point→integer aman, terlepas dari sintaks bahasa

    • Anda melewatkan intinya. Tidak bisa mempertahankan semua nilai floating-point dan perilaku tak terdefinisi adalah hal yang berbeda
      uint32_t juga tidak bisa merepresentasikan semua nilai uint64_t, tetapi semantik konversinya didefinisikan sebagai pemotongan. Di sini, fakta bahwa compiler boleh melakukan apa saja pada input tertentu adalah masalah yang secara mendasar berbeda
    • Perbedaan rentang representasi bukan berarti konversi yang aman tidak bisa didefinisikan. Rust mendefinisikan konversi floating-point→integer secara eksplisit, dan Spesifikasi Bahasa Java 26 merinci prosedur konversinya di bagian 5.1.3
      Untuk bahasa tingkat rendah, bisa juga dipetakan ke instruksi assembly seperti CVTTSS2SI atau FCVTZS. Karena bahasa tingkat rendah dekat dengan assembly dan berbagai perilaku tak terdefinisi “jinak” lainnya juga sangat kontroversial, justru lebih mengejutkan bahwa konversi ini membolehkan perilaku tak terdefinisi
    • Tidak terlalu mengejutkan jika bergantung pada hardware, hasilnya berupa nilai sampah yang spesifik implementasi, atau hardware/alat pemeriksa memicu trap dan menghentikan program
      Namun mengejutkan bahwa itu juga membolehkan kompilasi salah pada kode yang tidak terkait, memformat hard drive, atau membuat “setan keluar dari hidung”
      Konsep perilaku tak terdefinisi bahwa apa pun bisa terjadi memang masuk akal untuk double free atau penulisan di luar batas array, tetapi C dan C++ terlalu sering menggunakannya bahkan di tempat yang seharusnya bisa diberi aturan lebih ketat seperti nilai sampah yang ditentukan implementasi atau penghentian program. Rust melakukan trap untuk beberapa overflow integer dalam mode debug dan mengembalikan nilai yang tidak ditentukan dalam mode rilis, tetapi tidak membiarkannya merusak kode yang tidak terkait
      C++ tidak harus menjadi Java atau Rust, tetapi jika mengurangi perilaku tak terdefinisi yang tidak perlu, jelas bahasanya akan menjadi lebih baik
    • Bagi saya ini hal baru. Saya kira jika floating-point yang tidak dapat direpresentasikan dikonversi ke integer, hasil integer hanya akan berisi nilai kacau, bukan seluruh program tercemar selamanya dan keluar dari kendali standar C++