Undefined behavior

Katalog UB, flag peringatan compiler, dan sanitizer ASan/UBSan/TSan.

Daftar isi

Topik ini membahas undefined behavior (UB), yaitu kode yang membuat standar C++ berhenti menjamin apa pun terhadap programnya. Pembahasan mencakup katalog UB yang paling sering ditemui, flag peringatan compiler yang menandai pola berisiko sejak kompilasi, dan sanitizer UBSan yang menangkap pelanggaran pada saat program berjalan. Setelah membaca topik ini, pembaca mengetahui cara membangun program dengan pengawasan berlapis dan mengenali pesan yang dihasilkan setiap lapisannya.

Apa itu undefined behavior#

Undefined behavior adalah kondisi yang tidak diberi makna oleh standar C++, sehingga compiler tidak berkewajiban menghasilkan perilaku tertentu terhadapnya. Sebaliknya, compiler bebas mengasumsikan kondisi itu tidak pernah terjadi, dan optimasi memanfaatkan asumsi tersebut. Akibatnya, kode ber-UB sering tampak berjalan dengan benar pada build tertentu, lalu berubah total ketika flag optimasi, versi compiler, atau arsitektur prosesor berganti.

Kesalahannya bukan sekadar error yang segera terlihat. Membaca variabel sebelum diinisialisasi, mengakses array di luar batasnya, meluapkan nilai bertanda melebihi kapasitasnya, melepaskan memori dua kali, dan membaca memori yang sudah dilepas semuanya masuk katalog UB. Program dapat mencetak nilai yang aneh, berjalan seperti biasa, atau crash dengan pola yang tidak dapat direproduksi. Karena itu UB dicegah dengan pengawasan sejak kompilasi, bukan ditebak dari hasil uji coba.

Yang membuat UB sulit dilacak adalah cara optimizer memperlakukannya. Compiler berhak mengasumsikan kondisi ber-UB tidak pernah terjadi, sehingga kode yang bergantung pada asumsi itu dapat dihilangkan seluruhnya. Pemeriksaan p != nullptr setelah *p sempat dibaca, misalnya, dapat dibuang karena dereference tersebut dianggap sudah membuktikan pointer-nya sah. Program yang tampak benar pada build tanpa optimasi pun dapat berperilaku berbeda setelah flag -O2 dinyalakan, dan perbedaannya bukan bug compiler.

Katalog yang sering ditemui#

Tiga contoh berikut sengaja memuat UB dan tidak dijalankan. Contoh pertama membaca indeks 10 pada array berukuran empat, sehingga program mengakses memori milik objek lain.

cpp Buffer overflow: akses di luar batas array
// tidak-dijalankan (akses di luar batas array: undefined behavior)
int main() {
    int data[4] = {};
    return data[10];
}

Contoh kedua membaca objek dinamis setelah delete, yaitu pola use-after-free yang menjadi sumber celah keamanan paling terkenal.

cpp Use-after-free: membaca memori yang sudah dilepas
// tidak-dijalankan (use-after-free: undefined behavior)
#include <iostream>

int main() {
    int* angka = new int{7};
    delete angka;
    std::cout << *angka << '\n';
    return 0;
}

Contoh ketiga menambahkan satu pada nilai int terbesar, operasi yang tanpa penjagaan apa pun berarti UB, bukan sekadar pembulatan yang teratur.

cpp Signed integer overflow tanpa penjagaan
// tidak-dijalankan (signed integer overflow: undefined behavior)
#include <limits>

int main() {
    int maks = std::numeric_limits<int>::max();
    int hasil = maks + 1;
    return hasil;
}

Katalog lengkapnya jauh lebih panjang daripada tiga contoh di atas, dan beberapa yang sering ditemui dalam kode nyata tercantum di bawah ini.

  • Dereferensi pointer kosong, misalnya hasil pencarian yang gagal tetapi tetap diakses tanpa pemeriksaan.
  • Pergeseran bit sebesar lebar tipe atau lebih, misalnya menggeser int sebanyak 32 posisi.
  • Pembagian bilangan bulat dengan nol, yang berbeda dari pembagian pecahan yang menghasilkan nilai khusus.
  • Mengembalikan referensi atau pointer ke variabel lokal, yang hidupnya berakhir bersama fungsinya.
  • Data race, yaitu dua thread mengubah memori yang sama tanpa sinkronisasi.

Data race pada butir terakhir tidak dapat direproduksi pada program satu thread, sehingga pengujiannya membutuhkan alat yang menyertakan penjadwalan thread. Seluruh pola pada daftar tersebut menyerupai kode yang sah secara sintaks, dan compiler tidak menolaknya. Pemeriksaan membutuhkan alat, yang dibahas pada dua bagian berikut.

Peringatan compiler#

Lapisan pertama pengawasan berada pada flag peringatan. Flag -Wall mengaktifkan peringatan yang paling berguna, -Wextra menambah yang lebih ketat, dan -Wpedantic menolak ekstensi di luar standar. Berkas berikut dipakai sepanjang topik ini dan sengaja memuat dua pola berisiko.

cpp main.cpp
// tidak-dijalankan (memuat kode ber-UB; dibangun melalui dua blok bash di bawah)
#include <iostream>

int tambah(int a, int b) {
    return a + b;
}

int main() {
    int cadangan;

    unsigned int batas = 0;
    int nilai = -1;
    if (nilai < batas) {
        std::cout << "cabang ini tidak pernah dicetak\n";
    }

    volatile int maks = 2147483647;
    int hasil = tambah(maks, 1);
    std::cout << "hasil: " << hasil << '\n';
    return 0;
}

Perintah berikut mengompilasi berkas tersebut dengan ketiga flag tanpa menautkan program, sehingga hanya peringatan yang muncul.

bash
# kompilasi dengan peringatan lengkap, tanpa menautkan program
g++ -Wall -Wextra -Wpedantic -o /tmp/ub.o -c main.cpp
text Keluaran
main.cpp: In function ‘int main()’:
main.cpp:13:15: warning: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Wsign-compare]
   13 |     if (nilai < batas) {
      |         ~~~~~~^~~~~~~
main.cpp:9:9: warning: unused variable ‘cadangan’ [-Wunused-variable]
    9 |     int cadangan;
      |         ^~~~~~~~

Peringatan pertama menandai perbandingan int dengan unsigned int. Pada perbandingan itu -1 dikonversi menjadi bilangan tak bertanda yang sangat besar, sehingga cabangnya tidak pernah terpilih dan pernyataan cetaknya tidak pernah dieksekusi. Peringatan kedua menandai variabel yang tidak pernah dipakai, pola yang lazim menyisakan bug setelah refactor. Keduanya diperiksa compiler tanpa biaya apa pun pada program akhir. Karena itu, membangun dengan -Wall -Wextra -Wpedantic adalah kebiasaan minimal yang disarankan, dan opsi -Werror dapat dipakai untuk menjadikan setiap peringatan sebagai error.

Sanitizer pada saat program berjalan#

Flag peringatan tidak menjangkau nilai yang baru diketahui saat program berjalan, misalnya luapan 2147483647 + 1 di dalam fungsi tambah. Lapisan kedua, yaitu sanitizer, menyisipkan pemeriksaan runtime ke dalam biner. UBSan (-fsanitize=undefined) menangkap luapan bilangan bertanda, konversi yang hasilnya melampaui rentang tipe tujuannya, dan sejumlah pelanggaran lain.

bash
# bangun ulang dengan UBSan, lalu jalankan programnya
g++ -fsanitize=undefined main.cpp -o /tmp/ub && /tmp/ub
text Keluaran
main.cpp:5:16: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
hasil: -2147483648

UBSan melaporkan baris dan operasi yang melanggar, lalu programnya tetap dilanjutkan secara default. Nilai -2147483648 yang tercetak bukan hasil yang dijamin standar, melainkan salah satu kemungkinan hasil dari undefined behavior, dan itulah inti peringatannya.

ASan (-fsanitize=address) menjadi pilihan untuk dua contoh pertama pada katalog di atas, yaitu akses di luar batas dan use-after-free, karena menangkapnya sebelum terjadi kerusakan. Laporannya mencantumkan alamat memori dan jejak alokasinya, sehingga keluarannya tidak ditampilkan di topik ini. TSan (-fsanitize=thread) menutup butir terakhir katalog, yaitu data race. Binari yang disisipi sanitizer berjalan lebih lambat dan memakai memori lebih banyak, sehingga lazimnya hanya dipakai pada build pengujian dan debug, bukan pada build produksi.

Memilih lapisan pemeriksaan#

Seluruh lapisan sebelumnya saling melengkapi, dan pemilihan yang tepat mengikuti kategori kesalahannya.

Kategori kesalahanContohLapisan yang menangkap
Pola berisiko pada kode sumberperbandingan tanda berbeda, variabel tak terpakai-Wall, -Wextra
Pelanggaran nilai saat program berjalansigned overflow, shift berlebihUBSan
Kesalahan memori dinamisout-of-bounds, use-after-freeASan
Sinkronisasi antar threaddata raceTSan

Profil build yang lazim memisahkan build pengujian dari build produksi. Build pengujian mengaktifkan sebanyak mungkin pengawasan, sedangkan build produksi mempertahankan flag peringatan tanpa biaya runtime sanitizer. Opsi -fno-sanitize-recover=all membuat program berhenti pada pelanggaran pertama, sehingga laporan pertama tidak tertutup oleh pelanggaran berikutnya. Opsi -g menjaga laporan tetap menunjuk baris sumber.

Ringkasan#

  • Undefined behavior membuat standar berhenti menjamin apa pun, dan optimizer mengasumsikan kondisi itu tidak pernah terjadi.
  • Buffer overflow, use-after-free, dan signed integer overflow termasuk katalog UB yang paling sering ditemui.
  • -Wall, -Wextra, dan -Wpedantic menandai pola berisiko sejak kompilasi tanpa biaya runtime, dan -Werror menjadikan peringatan sebagai error.
  • UBSan menangkap luapan bilangan bertanda saat program berjalan, ASan menangkap kesalahan memori dinamis, dan TSan menangkap data race.
  • Opsi -fno-sanitize-recover=all menghentikan program pada pelanggaran pertama, sedangkan opsi -g menjaga laporan menunjuk baris sumber.

Referensi#