1 poin oleh GN⁺ 4 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Undefined behavior (UB) bukanlah optimisasi jahat dari compiler, melainkan aturan bahwa compiler tidak wajib menangani jalur eksekusi yang seharusnya mustahil jika kodenya valid
  • Dalam kode C/C++ yang tidak sepele, UB tersembunyi luas bukan hanya sebagai double-free atau akses di luar batas, tetapi juga dalam hal-hal halus seperti alignment, casting, inisialisasi, dan ketidakcocokan tipe
  • Mengakses int* atau std::atomic<int>* yang tidak selaras sudah merupakan UB menurut standar, meski hasil nyata di tiap platform bisa berbeda: SIGBUS, dikoreksi kernel, atau tampak berjalan normal
  • Kode umum seperti memberikan signed char ke isxdigit(), mengubah float menjadi int, atau salah memakai NULL dan argumen variadik juga mudah keluar dari standar
  • Basis kode lama memang tidak bisa dibuang, tetapi perlu diperbaiki dalam skala besar dengan menggabungkan deteksi UB berbasis LLM dan verifikasi pakar, karena masalahnya terlalu subtil untuk diserahkan ke programmer junior

Undefined behavior di C/C++ bukan masalah optimisasi

  • Undefined behavior (UB) bukan berarti compiler “mengeksploitasi” kesalahan programmer, melainkan bahwa program boleh diasumsikan valid menurut standar
  • Meski niatnya jelas bagi manusia, niat itu bisa sulit diekspresikan di tahap compiler atau antar modul
  • Compiler tidak wajib menangani kasus khusus yang “mustahil terjadi” saat menghasilkan kode, dan hasil yang berbeda dari niat bisa muncul di jalur eksekusi yang melibatkan perangkat keras
  • Mematikan optimisasi tidak membuat UB menjadi aman, dan tidak ada jaminan perilaku yang sama akan tetap terjaga di compiler atau arsitektur sekarang maupun di masa depan

UB tidak hanya ada di kode yang aneh

  • double-free, use-after-free, akses di luar batas objek, dan akses ke memori yang belum diinisialisasi adalah UB yang sudah dikenal, tetapi tetap berulang di seluruh industri
  • Ada juga banyak UB yang lebih halus dan tidak intuitif, sehingga kode C/C++ yang terlihat biasa saja pun mudah keluar dari standar
  • Dalam standar C23, kata “undefined” muncul 283 kali, dan cakupannya lebih luas lagi jika memasukkan kasus yang tidak didefinisikan secara implisit
  • Pada hampir semua kode C/C++ yang tidak sepele, UB ada di mana-mana, sehingga sulit menyalahkannya semata-mata pada kelalaian programmer individu

Akses ke objek yang tidak selaras

  • Fungsi yang melakukan dereference int* seperti berikut menjadi UB bila pointer tidak memiliki alignment yang benar
    int foo(const int* p) {
       return *p;
    }
    
  • Alignment biasanya bisa berarti alamat yang merupakan kelipatan sizeof(int), tetapi kebutuhan sebenarnya bisa berbeda tergantung platform dan implementasi
  • Di Linux Alpha, dalam beberapa kasus kernel bisa menangkap trap dan meniru akses yang dimaksud lewat perangkat lunak, tetapi dalam kasus lain program bisa mati dengan SIGBUS
  • Di SPARC akan muncul SIGBUS, sedangkan di x86/amd64 umumnya tampak berjalan normal atau bahkan terlihat seperti pembacaan atomik
  • Di ARM, RISC-V, dan arsitektur masa depan, hasilnya tidak bisa digeneralisasi, dan arsitektur mendatang bahkan bisa saja memakai bit-bit rendah int* untuk register khusus
  • Jika compiler memakai instruksi load yang berbeda, akses yang sebelumnya dikoreksi kernel mungkin tidak lagi dikoreksi
  • Compiler tidak berkewajiban menghasilkan assembly yang tetap bekerja untuk pointer yang tidak selaras; akses itu sendiri sudah UB

Tipe atomik juga tetap UB jika alignment salah

  • Bahkan jika memanggil store() atau load() pada std::atomic<int>* seperti berikut, perilakunya tetap UB bila objek tidak selaras dengan benar
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • Dari sudut pandang standar, pertanyaan “apakah operasi ini tetap atomik pada objek yang tidak selaras” sebenarnya tidak valid
  • Di perangkat keras nyata ini bisa menjadi masalah atomisitas, tetapi menurut standar, sebelumnya sudah UB lebih dulu
  • Jika objek yang dianggap dibaca secara atomik melintasi halaman, masalahnya makin rumit, tetapi kesimpulannya tetap bukan “aman” melainkan UB

Hanya membuat pointer pun bisa jadi masalah

  • Pointer yang tidak selaras bisa bermasalah bahkan sebelum didereference; sekadar cast ke tipe pointer tertentu saja bisa sudah salah
    bool parse_packet(const uint8_t* bytes) {
            const int* magic_intp = (const int*)bytes;   // UB!
            int magic_raw = foo(magic_intp);  // Probably crashes on SPARC.
            int magic = ntohl(magic_raw); // this is fine, at least.
            […]
    }
    
  • Di sini masalahnya bukan pemanggilan foo(), melainkan cast (const int*)bytes
  • Menurut standar, compiler juga bisa saja memberi makna khusus pada bit-bit rendah int*, misalnya untuk garbage collection atau bit tag keamanan

Masalah memberi char ke isxdigit()

  • Kode berikut terlihat sederhana, tetapi pada arsitektur yang char-nya signed, nilainya bisa menjadi UB jika input berada di luar rentang 0–127
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() adalah fungsi untuk memeriksa apakah suatu karakter adalah digit heksadesimal, dan juga bisa menerima EOF sebagai argumen
  • Menurut C23 7.4p1, EOF bertipe int dan dapat disimpulkan sebagai nilai yang tidak dapat direpresentasikan oleh unsigned char
  • isxdigit() menerima int, bukan char, dan walaupun konversi dari char ke int dimungkinkan, nilai negatif dari signed char menimbulkan masalah
  • Menurut C23 6.2.5 paragraf 20, apakah char bertanda signed ditentukan oleh implementasi
  • Implementasi isxdigit() seperti berikut dapat membaca memori tak dikenal dengan indeks negatif
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • Jika memori itu adalah area pemetaan I/O, dampaknya bisa melampaui nilai acak atau crash dan sampai memicu perilaku perangkat keras
  • Ini lebih mungkin terjadi di sistem embedded daripada aplikasi desktop, tetapi bahkan di ruang pengguna pun ada kasus seperti driver jaringan user-space di mana perlindungannya tidak cukup kuat

Masalah cast dari float ke int

  • Kode yang mengubah nilai float dalam detik menjadi int milidetik seperti berikut sangat umum, tetapi mengandung UB
    int milliseconds(float seconds) {
            int tmp = (int)(seconds * 1000.0); /* WRONG */
            return tmp + 1; /* WRONG separately (signed overflow is UB) */
    }
    
  • C23 6.3.1.4 menetapkan bahwa saat nilai floating-point riil berhingga dikonversi ke tipe integer, perilakunya tidak didefinisikan jika bagian bulatnya tidak dapat direpresentasikan oleh tipe integer tersebut
  • Untuk nilai tak berhingga pun tidak ada definisi eksplisit, sehingga itu juga menjadi UB
  • Bahkan membandingkan float dengan INT_MAX pun tidak sesederhana yang terlihat
    • Melakukan cast float ke int bisa justru memicu UB yang ingin dihindari
    • Melakukan cast INT_MAX ke float tidak menjamin nilainya dapat direpresentasikan dengan tepat
    • Jika INT_MAX dibulatkan saat menjadi float lalu berubah menjadi nilai yang tak bisa direpresentasikan oleh int, perbandingan kehilangan makna representatifnya
  • Agar aman, perlu pemeriksaan isfinite(), perbandingan dengan batas aman seperti INT_MIN + 1000 dan INT_MAX - 1000, serta pemeriksaan tambahan setelah konversi sebelum penjumlahan
    int milliseconds(float seconds) {
            const float ftmp = seconds * 1000.0f;
            if (!isfinite(ftmp)) {
                    return 0;
            }
            if ((float)(INT_MIN + 1000) > ftmp) {
                    return 0;
            }
            if ((float)(INT_MAX - 1000) < ftmp) {
                    return 0;
            }
            const int tmp = (int)ftmp;
            if (INT_MAX == tmp) {
                    return 0;
            }
            return tmp + 1;
    }
    
  • Hanya untuk mengubah float ke int saja, versi aman kodenya menjadi jauh lebih panjang

Objek di alamat 0 dan null pointer

  • Di kernel OS atau kode embedded, ada situasi di mana orang ingin menempatkan objek di alamat 0
  • Secara praktis, bisa dikatakan tidak ada cara yang benar-benar sesuai standar C untuk menempatkan objek nyata di alamat 0
  • Dalam C 6.3.2.3, konstanta integer 0 dan nullptr yang bisa dikonversi ke pointer adalah “null pointer constant”, dan di sini dapat disebut NULL
  • C tidak menetapkan bahwa pointer NULL yang nyata menunjuk ke alamat mesin 0
  • Standar C berbicara tentang mesin abstrak C, bukan perangkat keras, dan hanya menjamin bahwa NULL dan 0 akan bernilai sama saat dibandingkan
  • Kesamaan itu bisa saja terjadi karena integer 0 dikonversi ke nilai NULL native platform tersebut, yang bahkan bisa bernilai 0xffff
  • Melakukan dereference pada null pointer adalah UB apa pun nilainya, dan ini adalah contoh representatif di C 3.4.3
  • Karena itu, kita tidak bisa mengasumsikan memset(&ptr, 0, sizeof(ptr)); akan menghasilkan pointer NULL
  • Cara menginisialisasi struct dengan nol lalu menganggap pointer anggotanya menjadi NULL juga dapat menimbulkan masalah nyata bagi kebanyakan programmer
  • Secara historis, memang pernah ada mesin yang menggunakan pointer NULL bukan nol

Masalah menganggap ada fungsi di alamat 0

  • Bahkan jika di mesin modern NULL menunjuk ke alamat 0 dan benar-benar ada objek atau fungsi di alamat itu, C 6.3.2.3 menyatakan bahwa NULL tidak sama dengan objek atau fungsi mana pun
  • Karena itu, kode berikut adalah UB
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • Dari sudut pandang C, artinya adalah “tidak ada fungsi di sana”, dan compiler mungkin tidak punya cara untuk mengekspresikan niat lain di internalnya
  • Kita tidak bisa begitu saja mengasumsikan compiler akan mengeluarkan instruksi call ke alamat yang semua bitnya nol
  • Pada x86 16-bit pun tidak jelas apakah “semua nol” berarti 0000:0000 atau CS:0000

Argumen variadik dan ketidakcocokan tipe

  • Argumen terakhir execl() harus berupa pointer, jadi memberikan makro NULL atau integer 0 secara langsung bisa menjadi UB
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • Bentuk yang benar adalah melakukan cast eksplisit ke tipe pointer
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • Makro NULL bisa ditafsirkan sebagai integer 0, dan pada argumen variadik informasi tipe yang diperlukan tidak ikut diteruskan
  • Pada printf() pun, jika format specifier tidak cocok dengan tipe argumen sebenarnya, itu adalah UB
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • Untuk mencetak uint64_t, gunakan PRIu64
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • Untuk mencetak uid_t, salah satu caranya mungkin dengan cast ke uintmax_t lalu memakai PRIuMAX, tetapi bahkan apakah uid_t bertipe unsigned pun tidak selalu pasti
  • Dalam kasus terburuk, alih-alih -1, yang tercetak bisa saja nilai tak bermakna

Pembagian dengan 0 dan masalah keamanan

  • Fakta bahwa membagi dengan 0 adalah UB sudah dikenal luas, tetapi jika penyebut berasal dari input yang tidak tepercaya, ini menjadi masalah keamanan
  • Yang penting adalah bahwa UB bisa terjadi tepat di batas validasi input, bukan sekadar sebagai error runtime biasa

Bukan UB, tetapi integer promotion juga berbahaya

  • Aturan integer promotion sulit diterapkan sambil sekilas membaca kode, dan hasilnya bisa bertentangan dengan intuisi
  • Pada kode berikut, overflowed menjadi 0, bukan 1
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • Pada kode berikut, meski semua variabel tampak unsigned, hasilnya bukan 2147483648 (0x80000000) melainkan 18446744071562067968 (ffffffff80000000)
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • Sekalipun bukan UB, aturan integer di C/C++ tetap tidak intuitif dan mudah menimbulkan cacat

Deteksi UB dengan LLM

  • LLM modern, jika diminta mencari UB dalam kode C acak, hampir selalu menemukan masalah, dan hasilnya umumnya benar
  • Setelah menemukan UB di kode pribadi, pendekatan yang sama diterapkan pada kode OpenBSD yang matang dan ditulis dengan disiplin ketat
  • Beberapa masalah ditemukan pada alat find, yang menjadi target pertama
  • Patch dikirim ke OpenBSD untuk penulisan di luar batas dan untuk bug logika yang bukan UB
  • Untuk banyak UB lain yang masih tersisa, patch tidak dikirim
    • Ada pengalaman masa lalu bahwa proyek OpenBSD tidak terlalu menerima laporan bug
    • Ada penilaian bahwa beberapa kasus mungkin sebenarnya aman
    • Jika OpenBSD ingin menghapus UB dari basis kodenya, dibutuhkan proyek yang lebih besar daripada sekadar meneruskan patch satu per satu antara LLM dan proyek

Arah realistis untuk menangani basis kode C/C++

  • Basis kode C/C++ yang sudah ada tidak bisa dibuang, tetapi membiarkannya dalam keadaan rusak secara mendasar juga bukan pilihan
  • UB perlu diperbaiki secara massal dengan cara yang tidak memasukkan perubahan buruk buatan AI ke commit, sekaligus tidak membebani reviewer manusia
  • Pada 2026, menulis C atau C++ tanpa pengawasan UB dari LLM bisa dipandang seperti pelanggaran SOX dan tindakan yang tidak bertanggung jawab
  • Jika pengembang OpenBSD pun tidak berhasil menemukan semua masalah ini selama lebih dari 30 tahun, peluang proyek lain lebih kecil lagi
  • Dalam proyek pribadi, orang bisa meminta LLM untuk mencari UB, menjelaskannya bila perlu, memperbaikinya, lalu manusia memeriksa hasilnya
  • Namun, untuk memverifikasi hasil tetap dibutuhkan pakar, dan para pakar biasanya sibuk dengan hal lain
  • Pekerjaan ini tampak seperti pekerjaan bersih-bersih, tetapi terlalu halus untuk diserahkan kepada programmer junior yang secara tradisional sering diberi tugas semacam itu

Materi terkait

1 komentar

 
GN⁺ 4 jam lalu
Komentar Hacker News
  • C punya banyak perilaku tak terdefinisi yang mengejutkan dan aneh, tetapi tulisan ini tidak terlalu berhasil menunjukkannya dan hanya menggores permukaannya saja
    Contoh yang lebih aneh adalah volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);. Jika x hanya int, itu tidak masalah, tetapi jika volatile maka itu menjadi perilaku tak terdefinisi. Dalam standar C, akses volatile adalah efek samping bahkan hanya dengan membaca, efek samping yang tidak berurutan pada objek skalar yang sama adalah perilaku tak terdefinisi, dan evaluasi argumen fungsi tidak memiliki urutan yang ditentukan satu sama lain
    Biasanya data race berarti thread yang berbeda mengakses objek yang sama secara bersamaan dan setidaknya salah satunya adalah penulisan, tetapi di C situasi mirip data race bisa muncul bahkan dalam satu thread tanpa penulisan

    • Sebagai penulis, saya setuju. Tujuan tulisan ini bukan untuk mencantumkan 283 tempat dalam standar yang memunculkan kata undefined, atau semua kasus tak terdefinisi yang muncul karena penghilangan penjelasan
      Poinnya adalah ini tidak bisa dihindari. Setidaknya sejak C muncul pada 1972, belum pernah ada manusia yang benar-benar berhasil menghindarinya
      Jika selama 54 tahun belum berhasil, maka “berusahalah lebih keras” atau “jangan membuat kesalahan” bukanlah solusi. Satu celah yang dapat dieksploitasi yang ditemukan Mythos di OpenBSD dinilai cukup baik oleh para pengembang OpenBSD, tetapi bahkan ketika alat dijalankan pada kode yang paling sederhana pun, perilaku tak terdefinisi bermunculan di mana-mana
      Misalnya, find membaca variabel otomatis status yang belum diinisialisasi setelah waitpid(&status) dan sebelum memeriksa apakah waitpid() gagal, dan itu juga perilaku tak terdefinisi, meskipun sulit membayangkan arsitektur atau compiler tempat hal itu bisa dieksploitasi
      Seperti yang saya tulis di artikel, maksudnya bukan mencantumkan semua perilaku tak terdefinisi di dunia, melainkan menyampaikan bahwa semua kode C/C++ yang tidak sepele mengandung perilaku tak terdefinisi
    • volatile adalah hack pada sistem tipe. Seharusnya ada solusi yang lebih berprinsip, dan bahasa modern tidak seharusnya menirunya seolah “karena C melakukan itu, berarti itu ide bagus”
      Compiler C awal selalu menuliskan nilai ke memori, jadi jika pointer diarahkan ke perangkat keras memory-mapped I/O, setiap kali x berubah, instruksi CPU benar-benar melakukan penulisan ke memori dan kode driver pun bekerja
      Tetapi ketika optimisasi muncul, compiler melihat bahwa ia hanya terus memodifikasi x dan menyimpannya di register saja, sehingga driver rusak. volatile di C adalah hack untuk memberi tahu compiler “jangan lakukan optimisasi itu”, sedangkan solusi yang benar, yaitu menyediakan intrinsic memory-mapped I/O di tingkat library, akan menjadi pekerjaan yang jauh lebih besar
      Alasan intrinsic diperlukan adalah karena ia bisa mengekspresikan secara tepat perilaku yang mungkin dan yang tidak mungkin. Pada beberapa target, penulisan 1 byte, 2 byte, dan 4 byte masing-masing memiliki perilaku berbeda, dan perangkat keras pun membedakannya. Beberapa perangkat mengharapkan penulisan RGBA 4 byte, dan jika yang dikeluarkan adalah empat penulisan 1 byte, perangkat bisa bingung atau tidak berfungsi. Beberapa target bahkan mendukung penulisan per bit. Dengan volatile saja, tidak ada cara untuk mengetahui apa yang sedang terjadi atau apa maknanya
    • Kita perlu membedakan perilaku tak terdefinisi dari race. Perbedaan seperti ini sering hilang dalam pembahasan perilaku tak terdefinisi
      Setelah program C dikompilasi lalu di-disassemble, hasilnya menjadi program assembly yang tidak memiliki perilaku tak terdefinisi. Itu karena assembly tidak punya konsep perilaku tak terdefinisi
      Perilaku tak terdefinisi adalah properti program sumber, bukan properti file eksekusi. Artinya spesifikasi bahasa tempat sumber itu ditulis tidak memberikan makna pada program tersebut. Sebaliknya, file eksekusi hasil kompilasi diberi makna oleh spesifikasi mesin
      Race adalah properti dari perilaku program. Jadi kita bisa mengatakan bahwa program C mengandung perilaku tak terdefinisi, tetapi kita tidak bisa mengatakan bahwa file eksekusinya benar-benar mengalami race. Tentu saja, compiler bebas mengompilasi program dengan perilaku tak terdefinisi sesuka hati, jadi bisa saja ia memperkenalkan race, tetapi jika ia mengompilasikannya tanpa membuat thread baru, maka tidak ada race
    • Makna volatile justru bahwa nilainya dapat berubah oleh sesuatu yang lain. Jika itu variabel global, “sesuatu yang lain” itu bisa berupa thread lain, interupsi, atau signal handler. Jika itu pointer yang membaca alamat tertentu, nilainya bisa berasal dari register perangkat keras yang berubah
      Konsep variabel volatile itu sendiri bukan masalah. Jika suatu bahasa ingin mendukung routine interupsi dan memory-mapped I/O, compiler perlu diberi cara untuk mengetahui bahwa membaca register perangkat keras yang sama dua kali berbeda dengan membaca lokasi memori yang sama dua kali
      Masalah sebenarnya adalah interaksi antara fitur bahasa dan pembatasannya tidak ditata dengan baik. Sangat konyol jika kita sudah menyatakan “nilai ini bisa berubah kapan saja”, tetapi justru karena alasan itu beberapa pemakaiannya dianggap perilaku tak terdefinisi. Seharusnya ada pengecualian untuk variabel volatile dalam definisi “efek samping yang tidak berurutan”
    • Inti tulisan ini adalah bahwa Anda bahkan tidak perlu menulis kode aneh untuk menemukan perilaku tak terdefinisi
      Banyak orang salah paham dan mengira C dan C++ itu “sangat fleksibel karena membiarkan Anda melakukan apa pun yang diinginkan”. Kenyataannya, hampir semua teknik yang tampak kuat dan keren adalah ladang ranjau perilaku tak terdefinisi
  • Perilaku tak terdefinisi pada pointer yang tidak aligned lebih buruk lagi. Pointer yang tidak aligned itu sendiri sudah perilaku tak terdefinisi, bukan hanya saat diakses
    Jadi melakukan cast implisit dari void* v ke int* i, misalnya i=v di C atau f(v) ketika f menerima int*, juga merupakan perilaku tak terdefinisi jika pointer hasilnya tidak memenuhi syarat alignment int
    Penting untuk menekankan bahwa ini masalah pada level C. Jika program C mengandung perilaku tak terdefinisi, maka secara formal program C itu tidak valid dan salah. Ini bukan masalah perangkat keras, juga tidak terkait crash atau bug
    Cast dari void* ke int* biasanya tidak menghasilkan instruksi apa pun pada kode perangkat keras, dan karena tipe hanya ada di C, perangkat keras juga tidak akan crash pada cast itu. Orang bisa saja berpikir bahwa selama nilai integer dalam register masih baik-baik saja, maka tidak apa-apa, tetapi intinya bukan apakah pointer di perangkat keras benar-benar integer, melainkan bahwa pada saat cast ke pointer yang tidak aligned, program C itu sudah rusak menurut definisinya

    • Sebagai penulis, benar. Ini dibahas pada bagian “Actually, it was UB even before that” di artikel
      Saya juga mencoba menyampaikan bahwa perilaku tak terdefinisi tidak berada di perangkat keras, dan tidak berkaitan dengan crash atau bug. Pada saat yang sama, saya ingin menunjukkan contoh kepada orang-orang yang berkata “tapi lihat, ini berjalan baik-baik saja”, padahal sebenarnya tidak demikian
    • Itu wajar dan bisa diprediksi. Programmer yang baik tahu bahwa pointer cast jelas merupakan wilayah yang berbahaya
    • Bisa tunjukkan di bagian mana standar menyatakan bahwa pointer yang tidak aligned itu sendiri adalah perilaku tak terdefinisi?
    • Jika kita membuat struct dengan #pragma pack(push, 1), apakah itu berarti kita tidak bisa menggunakan pointer anggota kecuali anggota itu kebetulan aligned?
    • Konsep perilaku tak terdefinisi di C awalnya berarti memberi compiler kebebasan untuk memetakan kode ke perangkat keras meskipun instruksi mesin sedikit berbeda antar arsitektur. Program C yang sama bisa mengekspresikan perilaku berbeda tergantung arsitektur tempat ia dijalankan
      Perilaku tak terdefinisi jenis ini tidak masalah, dan hampir tidak ada orang yang benar-benar menganggap bug akibat perbedaan perangkat keras itu sendiri sebagai masalah besar
      Namun seiring waktu, penafsiran yang agresif telah mengubah C menjadi semacam bahasa design by contract implisit, dan pembatasannya menjadi tidak terlihat. Ini menimbulkan masalah yang mirip dengan pemanggilan destructor implisit di RAII yang juga tidak terlihat
      Di C, ketika Anda melakukan dereference pointer, compiler menambahkan batasan implisit bahwa pointer itu tidak boleh null ke signature fungsi. Jika Anda meneruskan pointer yang bisa null ke fungsi tanpa pemeriksaan atau assertion, bukannya muncul kesalahan karena pointer nullable dikirim, compiler justru diam-diam menyebarkan batasan non-null itu ke pointer tersebut. Jika ia bisa membuktikan bahwa batasan itu salah, fungsi itu ditandai tidak dapat dicapai, dan pemanggilan fungsi yang tidak dapat dicapai itu membuat fungsi pemanggil ikut dianggap tidak dapat dicapai
  • Lima tahap mempelajari perilaku tak terdefinisi di C
    Penyangkalan: “Saya tahu apa yang terjadi dengan signed overflow di mesin saya”
    Marah: “Compiler ini sampah! Kenapa tidak melakukan seperti yang saya suruh?”
    Tawar-menawar: “Saya akan mengajukan proposal ini ke wg14 untuk memperbaiki C…”
    Depresi: “Apakah ada kode C yang benar-benar bisa dipercaya?”
    Penerimaan: “Jangan gunakan perilaku tak terdefinisi saja”

    • Tahap “buat compiler mendefinisikan hal yang tidak terdefinisi” masuk ke mana?
      Akses yang tidak aligned bisa ditangani dengan packed struct. Compiler akan secara ajaib menghasilkan kode yang benar. Sebenarnya compiler memang selalu tahu cara melakukannya dengan benar, hanya saja tidak melakukannya
      Aturan strict aliasing bisa diatasi dengan type punning via union. Compiler yang penting biasanya mendokumentasikan bahwa itu bekerja meskipun standar tidak mengatakannya. Atau bisa juga dimatikan dengan -fno-strict-aliasing. Anda bisa menafsirkan ulang memori sesuka hati, dan meskipun tetap ada sudut tajam, setidaknya itu bukan berasal dari compiler
      Overflow bisa didefinisikan dengan -fwrapv. Jika +, -, * diganti dengan __builtin_*_overflow, Anda juga mendapatkan pemeriksaan error eksplisit secara gratis. Antarmuka fungsionalnya juga bagus dan kode yang dihasilkan efisien
      Penerimaan yang sesungguhnya lebih dekat ke “orang normal tidak peduli pada standar C”. Standarnya buruk, dan yang penting adalah compiler. Compiler punya banyak fitur yang sangat berguna untuk menghindari sebagian besar masalah ini. Alasan orang tidak memakainya adalah karena mereka ingin menulis C yang “portabel” dan “standar”, dan keluar dari pola pikir itulah penerimaan yang sebenarnya
      Dengan logika ini saya membuat interpreter Lisp di C freestanding dan bahkan lolos UBSan. Awalnya saya kira akan meledak, ternyata tidak, dan kalau saya bisa melakukannya maka siapa pun bisa
    • Sebagai penulis, inti artikel ini adalah bahwa “jangan gunakan perilaku tak terdefinisi” itu mustahil
      Selama manusia yang menulis kode, itu tidak bisa menjadi keadaan akhir. Tidak ada manusia yang bisa sepenuhnya menghindari perilaku tak terdefinisi di C/C++
    • “Jangan gunakan perilaku tak terdefinisi saja” terdengar, paling bagus pun, masih seperti tahap tawar-menawar
    • Coba kerja di perangkat embedded seperti saya. Menulis perangkat lunak untuk CPU tertentu itu benar-benar nyaman
    • Dalam C, penerimaan lebih dekat ke “saya akan menggunakan perilaku tak terdefinisi, dan suatu hari hal buruk akan terjadi”
  • Contoh-contohnya lebih mirip kasus yang bisa menjadi perilaku tak terdefinisi tergantung input atau keadaan, daripada benar-benar perilaku tak terdefinisi yang aktual
    Kalau definisinya dilonggarkan sejauh itu, maka setiap pemanggilan fungsi juga perilaku tak terdefinisi karena bisa saja kehabisan ruang stack. Sebenarnya dalam arti seperti itu, hampir semua bahasa juga begitu
    C sudah punya cukup banyak sisi kasar yang nyata, dan sensasionalisme seperti ini justru bisa mengalihkan perhatian, terutama bagi pemula, dan malah merugikan

    • Ada 83 tidak menganggap stack overflow saat pemanggilan sebagai perilaku tak terdefinisi. Di reference manual ada exception STORAGE_ERROR yang didefinisikan
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      Di situ tertulis bahwa exception ini juga terjadi “jika selama eksekusi pemanggilan subprogram, ruang penyimpanan yang tersedia tidak mencukupi”
    • Itu sama sekali tidak benar
      Pertama, apa yang terjadi ketika ruang stack habis itu bisa didefinisikan. Selain itu, tidak semua program membutuhkan stack berukuran sembarang; beberapa program hanya memerlukan ukuran konstan yang dapat dihitung sebelumnya. Bahkan ada implementasi bahasa yang sama sekali tidak menggunakan stack
      Bahasa juga bisa menyediakan alat untuk memeriksa sisa ruang stack dan memberi jaminan sesuai itu. Atau memungkinkan pemasangan handler yang akan dijalankan ketika ruang stack habis
    • Perilaku tak terdefinisi yang muncul tergantung input juga bisa menjadi jalur eksploitasi
    • Contoh-contohnya jelas merupakan perilaku tak terdefinisi. Selesai
      Cara berpikir yang benar adalah bahwa begitu perilaku tak terdefinisi terjadi, Anda tidak lagi berada di bawah perlindungan standar bahasa. Bisa saja semuanya tetap berjalan baik untuk sementara, bahkan mungkin selamanya. Tetapi dalam praktiknya Anda tanpa sadar menjadi bergantung pada keinginan toolchain, pergantian atau upgrade compiler, arsitektur, runtime, atau perbedaan versi libc
      Pada akhirnya Anda membangun fondasi di atas pasir, dan itulah bahaya perilaku tak terdefinisi
    • Tulisan ini hampir seperti definisi FUD itu sendiri
  • Masalah perilaku tak terdefinisi bukan bahwa ia bisa crash di arsitektur tertentu
    Masalah sebenarnya adalah compiler menganggap kode seperti itu tidak akan pernah muncul. Namun jika Anda tetap menulis kode dengan perilaku tak terdefinisi, compiler, terutama optimizer, bisa menerjemahkannya ke bentuk apa pun yang nyaman bagi jalur normal. Dan “apa pun” itu kadang bisa sangat tak terduga, seperti menghapus potongan kode yang besar

    • Contoh terkaitnya adalah syarat bahwa setiap fungsi harus selesai atau memiliki efek samping. Saya belum pernah mengalaminya sendiri, tetapi cukup mudah membayangkan situasi ketika seseorang tanpa sengaja menulis loop tak berujung atau rekursi, lalu fungsinya dihapus
      Jika ditambah tail recursion, bug itu bahkan mungkin tidak terlihat di build debug karena loop tak berujungnya tidak tercapai, lalu baru muncul saat level optimisasi dinaikkan
    • Crash adalah salah satu bentuk perilaku tak terdefinisi yang paling ringan. Setidaknya ia mudah terlihat
      Yang lebih buruk adalah ketika program diam-diam terus berjalan dengan nilai sampah, memformat hard disk, atau menyerahkan kunci kerajaan kepada penyerang
    • Benar, tetapi itu juga fungsi paling berguna sekaligus alasan keberadaan perilaku tak terdefinisi
      Orang yang berkata “ya sudah, definisikan saja” atau jadikan unspecified behavior melewatkan inti bahwa compiler bisa menghapus bagian besar program
      Jika Anda menulis kode yang menjadi perilaku tak terdefinisi untuk input tertentu, maka untuk input itu Anda memang berniat agar program tidak memiliki perilaku apa pun. Anda ingin compiler menghilangkan jalur itu lewat optimisasi, atau melakukan hal lain yang membantu perilaku kasus lain yang terdefinisi
      Cukup memuaskan menaruh string log yang hanya bisa dicapai lewat perilaku tak terdefinisi, lalu melihat string itu tidak tersisa di binary
    • Bagian di artikel yang mengatakan ini bukan masalah optimisasi sangat menarik perhatian saya
      Dulu saya pernah menulis analysis pass dengan asumsi ia dijalankan di akhir pipeline transformasi, dan asumsi itu diperlukan untuk korektness. Karena setelah itu tidak ada lagi optimisasi, saya menganggapnya aman, tetapi sekarang saya tidak lagi yakin
    • Itu bukan bug, itu fitur
  • Saya sudah memakai C selama 20 tahun, tetapi dalam 6 bulan terakhir di Hacker News saya merasa mendengar soal perilaku tak terdefinisi lebih banyak daripada sebelumnya
    Dalam percakapan nyata topik ini hampir tidak pernah muncul. Orang menulis kode, kalau tidak jalan ya di-debug lalu diperbaiki atau diakali. Saya tidak mengerti kenapa topik perilaku tak terdefinisi di C terus muncul ke halaman depan seperti ini

    • Hacker News memang masih cenderung lebih tertarik pada bahasa pemrograman daripada pemrograman yang sebenarnya. Mungkin ada juga warisan Lisp dari Y Combinator
      Selalu ada sebagian kecil lulusan ilmu komputer yang menganggap mengembangkan atau memakai bahasa pemrograman baru adalah hal paling menarik di dunia, dan sebagian dari mereka terus berpikir begitu
      Wajar jika orang seperti itu tertarik pada aspek desain bahasa, dan perilaku tak terdefinisi di C termasuk dalam wilayah tersebut. Hanya saja, banyak bagiannya pada awalnya lebih merupakan upaya menampung arsitektur CPU lama tanpa kehilangan performa, sehingga sulit menyebutnya “pilihan desain” seperti halnya mengatakan roda itu bulat adalah pilihan desain
    • Maksud Anda apa? Saya juga memakai C dan C++ 20 tahun lalu, dan saat itu pun perilaku tak terdefinisi sudah menjadi bagian besar dari percakapan dan kurikulum
      Sekitar era GCC 3.2, compiler mulai memanfaatkan perilaku tak terdefinisi jauh lebih agresif untuk optimisasi, dan ada sejumlah “skandal” yang cukup terkenal karena itu. Akibatnya banyak orang bertahan lama di GCC 2.95. GCC 3.2 keluar pada 2002
    • Dulu komputer itu keren, sekarang komputer jadi berbahaya
      Semua perusahaan terus menekankan keamanan dan paparan, yaitu bisa masuk berita, sehingga narasi yang menentang “ketidakamanan” menjadi berlebihan
      Dunia baru ini mirip orang kota yang belum pernah melihat alam liar lalu takut pada mesin pemotong rumput. Bilahnya berputar? Gila!
    • Lingkungan operasinya bisa berupa arsitektur yang benar-benar berbeda, jadi detail seperti ini sangat penting
      Jika target Anda sebenarnya adalah sistem embedded kecil di menara komunikasi terpencil, maka “di mesin saya jalan” tidak ada gunanya. Tentu sebagian besar orang tidak mengerjakan itu, dan kebanyakan developer di sini mungkin web developer, tetapi ini tetap diskusi yang menarik meskipun belum pernah mengalaminya sendiri. Bahkan mungkin justru lebih menarik karena itu
    • Lebih tepatnya, penulisan dilakukan bukan terhadap spesifikasi khayalan, melainkan terhadap target yang dituju. Spesifikasi hanya berguna untuk memprediksi kira-kira apa yang dilakukan target tersebut, bukan sebagai norma
      Compiler bisa punya bug yang membuat perilaku yang menurut spesifikasi seharusnya berjalan ternyata tidak berjalan, ada banyak ekstensi yang tidak punya padanan di standar, dan ada juga perilaku yang menurut standar tak terdefinisi tetapi oleh implementasi tertentu diberi hasil yang bermakna
  • Saya umumnya setuju dengan pengantarnya, tetapi contohnya kurang bagus dan keseluruhan tulisannya terasa seperti kemasan untuk mendorong coding dengan LLM

    • Betul. Contoh-contohnya satu per satu adalah hal-hal standar yang memang dihindari saat menulis kode portabel, atau hal yang tidak perlu seperti mengakses objek di alamat 0
      Terkesan seperti orang yang ingin menulis kode sesuka hati lalu berharap hasilnya sama di semua lingkungan. Jika bahasa dibuat seperti itu, maka hilanglah keuntungan bahwa Anda bisa menulis sesuai platform ketika memang dibutuhkan
    • Dalam arti apa tidak bagus? Kalau benar, itu cukup serius
  • Kode C++ di tulisan ini sebagian sudah tidak idiomatis selama lebih dari 10 tahun, dan hari ini bisa dianggap bau kode
    Bahasanya telah berevolusi cukup jauh dari saat pertama kali dibuat. Begitu terlihat banyak raw pointer dan akses pointer langsung, sudah jelas bahwa sebagian tulisan itu perlu dibaca dengan sedikit filter
    Masalah jelas lainnya adalah sudut pandang yang menggabungkan C dan C++ seolah keduanya hampir sama. Saat ini kedua bahasa itu sebenarnya sudah cukup jauh berbeda

    • Saya sempat hendak menunjukkan bahwa kodenya bukan C++ melainkan C, tetapi setelah memeriksa lagi ternyata memang tertulis std::atomic, bukan atomic_int
  • Apakah pemahaman tentang perilaku tak terdefinisi di C seperti ini benar?
    Program P memiliki himpunan input A yang tidak memicu perilaku tak terdefinisi, dan himpunan komplemen B yang memicunya
    Compiler yang benar mengompilasi P menjadi executable P'. Untuk semua input di A, P' harus berperilaku sama dengan P
    Tetapi untuk input mana pun di B, perilaku P' tidak memiliki persyaratan apa pun

    • Secara intuitif, ya. Program dikompilasi seolah input B tidak akan pernah diberikan, dan ini bisa termasuk menghapus kode yang mencoba mendeteksi input B tersebut
    • Ringkasan yang bagus
  • Contoh konkret perilaku tak terdefinisi yang disebabkan pointer tidak aligned: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • Ini kasus pada x86, yang sering diasumsikan orang sebagai arsitektur yang seharusnya tidak bermasalah untuk hal seperti ini