1 puan yazan GN⁺ 4 시간 전 | 1 yorum | WhatsApp'ta paylaş
  • Tanımsız davranış (UB), derleyicinin kötü niyetli optimizasyonları değil; kodun geçerli olduğu varsayımıyla imkânsız yürütme yollarını ele almak zorunda olmamasını belirleyen bir kuraldır
  • Sıradan olmayan C/C++ kodlarında yalnızca double-free veya sınır dışı erişim değil; hizalama, cast etme, başlatma ve tür uyuşmazlığı gibi daha ince UB türleri de yaygın biçimde gizlidir
  • Hizalanmamış int* veya std::atomic<int>* erişimleri, platforma göre SIGBUS, çekirdeğin düzeltmesi ya da normal çalışıyormuş gibi görünen sonuçlar üretebilir; ancak standarda göre bunlar zaten UB'dir
  • isxdigit() fonksiyonuna signed char vermek, float değerini inte çevirmek veya NULL ile değişken argümanları yanlış kullanmak gibi yaygın kodlar da kolayca standardın dışına çıkar
  • Mevcut kod tabanlarını çöpe atmak mümkün değil; ancak bunu büyük ölçekte düzeltmek için LLM tabanlı UB tespiti ile uzman doğrulamasını birleştirmek gerekir ve konu junior geliştiricilere bırakılmayacak kadar incedir

C/C++'ta tanımsız davranış bir optimizasyon sorunu değildir

  • Tanımsız davranış (UB), derleyicinin geliştirici hatalarını “istismar ettiği” anlamına gelmez; programın standart açısından geçerli olduğunu varsayabileceği anlamına gelir
  • İnsan açısından niyet açık görünse bile, bu niyeti derleyici aşamalarında ya da modüller arasında ifade etmek zor olabilir
  • Derleyici, “gerçekleşmesi mümkün olmayan” özel durumları kod üretiminde ele almak zorunda değildir ve donanımı da kapsayan yürütme yolunda niyetten farklı sonuçlar ortaya çıkabilir
  • Optimizasyonu kapatmak UB'yi güvenli hâle getirmez; ayrıca aynı davranışın bugünkü veya gelecekteki derleyici ve mimarilerde korunacağına dair de garanti yoktur

UB yalnızca anormal kodlarda bulunmaz

  • double-free, use-after-free, nesne sınırlarının dışına erişim ve başlatılmamış belleğe erişim iyi bilinen UB örnekleridir; ancak bunlar sektörde hâlâ tekrar tekrar görülür
  • Daha ince ve sezgisel olmayan pek çok UB türü de vardır; bu yüzden sıradan görünen C/C++ kodu kolayca standardın dışına çıkabilir
  • C23 standardında “undefined” sözcüğü 283 kez geçer; açıkça yazılmayıp yine de tanımsız kalan durumlar da düşünülürse kapsam daha da geniştir
  • Sıradan olmayan C/C++ kodlarında UB her yere dağılmış durumdadır ve bunu yalnızca tek tek programcıların dikkatsizliğine bağlamak zordur

Hizalanmamış nesne erişimi

  • Aşağıdaki gibi int* işaretçisini dereference eden bir fonksiyon, işaretçi doğru hizalanmamışsa UB olur
    int foo(const int* p) {
       return *p;
    }
    
  • Hizalama (alignment), çoğu zaman sizeof(int) katı adres anlamına gelebilir; ancak gerçek gereksinimler platforma ve uygulamaya göre değişebilir
  • Linux Alpha'da bazı durumlarda çekirdek trap'i yakalayıp amaçlanan erişimi yazılımla taklit edebilirdi; ancak başka durumlarda program SIGBUS ile sonlanabilirdi
  • SPARC'ta SIGBUS oluşur; x86/amd64'te ise çoğu zaman sorun çıkmadan çalışabilir veya atomik okuma gibi görünebilir
  • ARM, RISC-V ve gelecekteki mimarilerde sonuçlar genellenemez; gelecekte bir mimari int* için alt bitleri kullanmayan özel yazmaçlar da kullanabilir
  • Derleyici farklı bir load komutu kullanırsa, daha önce çekirdeğin düzelttiği erişimler artık düzeltilemeyebilir
  • Derleyicinin hizalanmamış işaretçilerde de çalışacak assembly üretme zorunluluğu yoktur; çünkü o erişimin kendisi zaten UB'dir

Atomik türlerde de yanlış hizalama zaten UB'dir

  • Aşağıdaki gibi std::atomic<int>* üzerinde store() veya load() çağrısı yapsanız bile, nesne doğru hizalanmamışsa davranış UB'dir
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • “Hizalanmamış nesnelerde bu işlem atomik mi?” sorusu, standart açısından anlamlı bir soru değildir
  • Gerçek donanımda atomiklik konusu önemli olabilir; ancak standart açısından bundan önce zaten UB söz konusudur
  • Atomik olarak okunduğu sanılan nesne bir sayfa sınırını aşıyorsa durum daha da karmaşıklaşır; ama sonuç “sorun yok” değil, yine UB'dir

Sadece işaretçi oluşturmak bile sorun olabilir

  • Hizalanmamış bir işaretçiyi dereference etmeden önce bile belirli bir türe cast etmek başlı başına sorun olabilir
    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.
            […]
    }
    
  • Buradaki sorun foo() çağrısı değil, (const int*)bytes cast işlemidir
  • Standart açısından derleyicinin int* işaretçisinin alt bitlerine çöp toplama veya güvenlik etiket biti gibi anlamlar yüklemesi de mümkündür

isxdigit() fonksiyonuna char vermek

  • Aşağıdaki kod basit görünür; ancak char türünün signed olduğu mimarilerde giriş değeri 0–127 aralığının dışındaysa UB olabilir
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit(), bir karakterin onaltılık karakter olup olmadığını kontrol eden fonksiyondur ve argüman olarak EOF da alabilir
  • C23 7.4p1'e göre EOF, int türündedir ve unsigned char ile temsil edilemeyen bir değer olduğu çıkarımı yapılabilir
  • isxdigit() char değil int alır; chardan inte dönüşüm mümkün olsa da signed charın negatif değerleri sorun yaratır
  • C23 6.2.5 paragraf 20'ye göre charın signed olup olmadığı implementation-defined'dır
  • Aşağıdaki gibi yazılmış bir isxdigit() uygulaması, negatif indeksle bilinmeyen bellek okuyabilir
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • Bu bellek I/O eşlemeli bir bölgeyse, rastgele değer ya da çökme üretmenin ötesinde doğrudan donanım davranışını da tetikleyebilir
  • Bu, masaüstü işletim sistemlerindeki uygulamalardan çok gömülü sistemlerde daha olasıdır; ancak kullanıcı alanı ağ sürücüleri gibi yalnızca kullanıcı alanında olmasına rağmen korumanın yetersiz kaldığı durumlar da vardır

floattan inte cast etme sorunu

  • Aşağıdaki gibi saniye cinsinden float değeri milisaniye cinsinden inte dönüştüren kod yaygındır; ancak UB içerir
    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, sonlu bir gerçek kayan nokta değerini tamsayı türüne dönüştürürken tamsayı kısmı hedef tamsayı türüyle temsil edilemiyorsa davranışın tanımsız olduğunu belirtir
  • Sonlu olmayan değerler için de açık bir tanım yoktur; bu yüzden onlar da UB olur
  • float değeri INT_MAX ile karşılaştırmak da sanıldığı kadar basit değildir
    • floatı inte cast etmek, kaçınmaya çalıştığınız UB'yi doğurabilir
    • INT_MAX değerini floata cast ettiğinizde tam olarak temsil edilip edilmediği bilinmez
    • INT_MAX, floata yuvarlandığında int ile temsil edilemeyen bir değere dönüşürse karşılaştırma temsil gücünü kaybedebilir
  • Güvenli hâle getirmek için isfinite() kontrolü, INT_MIN + 1000 ve INT_MAX - 1000 gibi pay bırakılmış sınır karşılaştırmaları ve dönüşümden sonra toplama öncesi ek kontroller gerekir
    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;
    }
    
  • İnsan sadece floatı inte çevirmek istiyor gibi görünse de, güvenli kod çok daha uzun olur

0 adresindeki nesne ve null pointer

  • İşletim sistemi çekirdeklerinde veya gömülü kodda, 0 adresine nesne yerleştirme ihtiyacı doğabilir
  • C standardına uygun biçimde gerçekten 0 adresinde nesne bulundurmanın pratik bir yolu olmadığı söylenebilir
  • C 6.3.2.3'e göre işaretçiye dönüştürülebilen tamsayı sabiti 0 ve nullptr, “null pointer constant”tır; burada buna NULL denebilir
  • C, gerçek NULL pointer'ın makine adresi 0'ı gösterdiğini belirtmez
  • C standardı donanımı değil, C soyut makinesini ele alır; NULL ile 0 karşılaştırıldığında yalnızca eşit oldukları garanti edilir
  • Bu eşitlik, tamsayı 0'ın ilgili platformun yerel NULL değerine dönüştürülmesinden kaynaklanıyor olabilir ve bu değer 0xffff bile olabilir
  • null pointer'ı dereference etmek, değeri ne olursa olsun UB'dir ve C 3.4.3'teki tipik örneklerden biridir
  • Bu nedenle memset(&ptr, 0, sizeof(ptr)); ifadesinin NULL pointer ürettiği varsayılamaz
  • Bir struct'ı sıfırlayıp içindeki işaretçi üyelerin NULL olduğunu varsaymak, çoğu programcı açısından da gerçek bir sorun kaynağıdır
  • Tarihsel olarak 0 olmayan NULL pointer kullanan makineler da vardı

0 adresinde fonksiyon olduğunu varsayma sorunu

  • Modern makinelerde NULL 0 adresini gösterse ve gerçekten o adreste bir nesne veya fonksiyon bulunsa bile, C 6.3.2.3 NULL'ın hiçbir nesneye veya fonksiyona eşit olmadığını söyler
  • Dolayısıyla aşağıdaki kod UB'dir
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • C açısından bunun anlamı “orada fonksiyon yok” demektir ve derleyicinin içinde bu niyeti ifade etmenin bir yolu olmayabilir
  • Sadece tüm bitleri 0 olan bir adrese call komutu üretileceğini varsayamazsınız
  • 16 bit x86'da “tümü 0” değerinin 0000:0000 mı yoksa CS:0000 mı olduğu bile net değildir

Değişken argümanlar ve tür uyuşmazlığı

  • execl() fonksiyonunun son argümanı bir işaretçi olmalıdır; bu yüzden NULL makrosunu veya tamsayı 0'ı doğrudan vermek UB olabilir
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • Doğru biçim, açıkça işaretçi türüne cast etmektir
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • NULL makrosu tamsayı 0 olarak yorumlanabilir ve değişken argümanlarda gereken tür bilgisi aktarılmaz
  • printf() içinde de format belirteci ile gerçek argüman türü uyuşmuyorsa UB oluşur
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • uint64_t yazdırmak için PRIu64 kullanılmalıdır
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • uid_t yazdırmak için uintmax_t türüne cast edip PRIuMAX kullanmak bir seçenek olabilir; ancak uid_t türünün unsigned olduğundan bile emin olunamayabilir
  • En kötü durumda -1 yerine anlamsız bir değer yazdırılabilir

0'a bölme ve güvenlik sorunları

  • 0'a bölmenin UB olduğu yaygın olarak bilinir; ancak payda güvenilmeyen girdiden geliyorsa bu bir güvenlik sorunu hâline gelir
  • Buradaki kritik nokta, bunun yalnızca basit bir runtime hatası değil; girdi doğrulama sınırında ortaya çıkan bir UB olmasıdır

UB olmasa da integer promotion da risklidir

  • Integer promotion kuralları kodu hızlıca gözden geçirirken uygulanması zor kurallardır ve sezgiye aykırı sonuçlar üretebilir
  • Aşağıdaki kodda overflowed değeri 1 değil 0 olur
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • Aşağıdaki kodda tüm değişkenler unsigned gibi görünse de sonuç 2147483648 (0x80000000) değil, 18446744071562067968 (ffffffff80000000) olur
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • UB olmasa bile C/C++'ın tamsayı kuralları sezgisel değildir ve hata üretmeye çok elverişlidir

LLM ile UB tespiti

  • Güncel LLM'lere rastgele bir C kodunda UB bulmaları söylendiğinde, neredeyse her zaman bir sorun buluyorlar ve çoğunlukla doğru sonuç veriyorlar
  • Kişisel kodda UB bulunduktan sonra, aynı yaklaşım olgun ve sıkı yazılmış OpenBSD koduna da uygulandı
  • İlk akla gelen araç olan find üzerinde çeşitli sorunlar bulundu
  • OpenBSD'ye sınır dışı yazma için bir patch ve UB olmayan mantık hatası için başka bir patch gönderildi
  • Kalan birçok UB için patch gönderilmedi
    • OpenBSD projesinin geçmişte hata raporlarına pek açık olmadığı yönünde bir deneyim vardı
    • Bazı durumlarda pratikte sorun çıkmayabileceği düşünüldü
    • OpenBSD'nin kod tabanından UB'yi temizlemesi için, LLM ile proje arasında tek tek patch taşımaktan daha büyük ölçekli bir girişim gerekir

C/C++ kod tabanları için gerçekçi yönelim

  • Mevcut C/C++ kod tabanlarını atmak mümkün değil; ama onları özünde bozuk hâlde bırakmak da bir seçenek değil
  • Yapay zekânın ürettiği düşük kaliteli değişiklikleri commit etmeden ve insan gözden geçirenleri ezmeden, UB büyük ölçekte düzeltilmeli
  • 2026'da LLM tabanlı UB denetimi olmadan C veya C++ yazmak, SOX ihlali gibi görülebilir ve sorumsuzluk sayılabilir
  • OpenBSD geliştiricileri 30 yılı aşkın sürede tüm bu sorunları bulamadıysa, diğer projelerin şansı daha da düşüktür
  • Kişisel projelerde, LLM'den UB bulması, gerekirse açıklaması ve düzeltmesi istenebilir; ardından bir insan sonucu doğrulayabilir
  • Ancak sonucu doğrulamak için uzman gerekir ve uzmanlar genelde başka işlerle meşguldür
  • Bu iş bir temizlik işi gibi görünse de, geleneksel olarak böyle işlere verilen junior programcılara bırakılmayacak kadar incedir

İlgili kaynaklar

1 yorum

 
GN⁺ 4 시간 전
Hacker News yorumları
  • C'de şaşırtıcı ve tuhaf pek çok tanımsız davranış var, ama bu yazı bunu pek iyi göstermiyor; sadece yüzeyini hafifçe kazıyor
    Daha da tuhaf bir örnek olarak volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x); verilebilir. x normal bir int ise sorun yok, ama volatile ise bu tanımsız davranış oluyor. C standardında volatile erişimi sadece okumak bile yan etkidir; aynı skaler nesne üzerindeki sırasız yan etkiler tanımsız davranıştır ve fonksiyon argümanlarının değerlendirilme sırası da birbirine göre belirli değildir
    Genelde veri yarışı, farklı thread'lerin aynı nesneye aynı anda erişmesi ve bunlardan en az birinin yazma yapması anlamına gelir, ama C'de tek bir thread içinde de yazma olmadan veri yarışına benzer bir durum oluşabilir

    • Yazının yazarı olarak katılıyorum. Bu yazının amacı, standartta undefined kelimesinin geçtiği 283 yeri ya da eksiklikler nedeniyle tanımsız olan tüm durumları tek tek saymak değildi
      Asıl mesele bunun kaçınılmaz olması. En azından 1972'de C ortaya çıktığından beri insanlar bunu tamamen önleyebilmiş değil
      54 yıldır başarılamadıysa, çözüm “daha çok çabala” ya da “hata yapma” değildir. Mythos'un OpenBSD'de bulduğu istismar edilebilir bir açık OpenBSD geliştiricileri açısından oldukça iyi bir puan sayılırdı, ama en basit kodda araçları çalıştırınca bile her yerde tanımsız davranış çıktı
      Örneğin find içinde waitpid(&status) sonrasında, waitpid() hatasını kontrol etmeden önce başlatılmamış otomatik değişken status'un okunması da tanımsız davranıştır; gerçi bunun istismar edilebilir olacağı bir mimari ya da derleyici hayal etmek zor
      Yazıda da belirttiğim gibi amaç dünyadaki tüm tanımsız davranışları listelemek değil, önemsiz olmayan tüm C/C++ kodlarında tanımsız davranış vardır noktasını vurgulamak
    • volatile, tip sistemi hilesidir. Daha ilkesel bir çözüm yapılmalıydı ve modern diller de “C böyle yaptıysa iyi bir fikirdir” diye bunu taklit etmemeli
      İlk C derleyicileri değerleri her zaman belleğe yazıyordu; bu yüzden işaretçiyi memory-mapped I/O donanımına uygun yere koyarsanız, x her değiştiğinde CPU komutu gerçekten belleğe yazıyor ve sürücü kodu çalışıyordu
      Ama optimizasyon gelince derleyici bunun sadece x'i değiştirmek olduğunu düşünüp değeri register'da tutmaya başladı ve sürücü bozuldu. C'deki volatile, derleyiciye “o optimizasyonu yapma” diyen bir hack'tir; oysa doğru çözüm olan kütüphane düzeyinde memory-mapped I/O içsel fonksiyonları sağlamak çok daha büyük bir iş olurdu
      İçsel fonksiyonların gerekli olmasının nedeni, mümkün olan ve olmayan davranışları tam olarak ifade edebilmesidir. Bazı hedeflerde 1 bayt, 2 bayt, 4 bayt yazmaların her biri farklı davranış üretir ve donanım bunu ayırt eder. Bazı cihazlar 4 baytlık RGBA yazımı bekler; bunun yerine 1 baytlık dört yazma gönderirseniz cihaz şaşırabilir ya da çalışmayabilir. Bazı hedefler bit düzeyinde yazmayı da destekler. Yalnızca volatile ile ne olup bittiğini ve bunun ne anlama geldiğini bilmenin yolu yoktur
    • Tanımsız davranış ile yarışı ayırmak gerekir. Tanımsız davranış tartışmalarında bu ayrım sık sık atlanıyor
      Bir C programını derledikten sonra tersine çevrilip assembly olarak incelerseniz, ortada tanımsız davranışı olmayan bir assembly programı kalır. Çünkü assembly'de tanımsız davranış diye bir kavram yoktur
      Tanımsız davranış kaynak programın özelliğidir, çalıştırılabilir dosyanın değil. Yani programın yazıldığı dilin tanımı o programa bir anlam vermez. Buna karşılık derleme sonucunda oluşan çalıştırılabilir dosyaya anlamı makine tanımı verir
      Yarış ise programın çalışmasının bir özelliğidir. Bu yüzden bir C programında tanımsız davranış olduğunu söyleyebilirsiniz, ama çalıştırılabilir dosyada gerçekten yarış olduğunu söyleyemezsiniz. Elbette derleyici tanımsız davranış içeren bir programı istediği gibi derleyebilir ve yarış da ortaya çıkarabilir; ama yeni thread'ler oluşturmadan derlenirse yarış da olmaz
    • volatileın anlamı zaten değerin başka bir şey tarafından değiştirilebilir olmasıdır. Global bir değişkense bu başka şey yalnızca başka bir thread değil, interrupt ya da signal handler da olabilir. Belirli bir adresi okuyan bir işaretçiyse, değeri değişen bir donanım aygıtı register'ı da olabilir
      volatile değişken kavramının kendisi sorun değil. Interrupt rutinlerini ve memory-mapped I/O'yu desteklemek isteyen bir dilin, aynı donanım register'ını iki kez okumanın aynı bellek konumunu iki kez okumakla aynı şey olmadığını derleyiciye anlatacak bir yola ihtiyacı vardır
      Asıl sorun, dil özellikleri ile kısıtlar arasındaki etkileşimin yeterince netleştirilmemiş olması. “Bu değer her an değişebilir” diye özellikle belirtip sonra tam da bu nedenle bazı kullanımları tanımsız davranış saymak saçma. volatile değişkenler için “sırasız yan etkiler” tanımında bir istisna olmalıydı
    • Yazının özü, tanımsız davranışla karşılaşmak için garip kod yazmanın bile gerekmediği
      Birçok kişi C ve C++'ın “istediğin her şeyi yapmana izin verdiği için çok esnek” olduğunu sanıyor. Gerçekte güçlü ve havalı görünen neredeyse her teknik bir tanımsız davranış mayın tarlası
  • Hizalanmamış işaretçi tanımsız davranışı daha da kötü. Hizalanmamış işaretçi, erişim anında değil, işaretçinin kendisiyle bile tanımsız davranıştır
    Bu yüzden void* v'yi int* i'ye örtük dönüştürmek, örneğin C'de i=v ya da int* alan f(v) çağrısı da ortaya çıkan işaretçi int hizalama koşulunu karşılamıyorsa tanımsız davranıştır
    Bunun C düzeyinde bir sorun olması önemli. Bir C programında tanımsız davranış varsa, o C programı biçimsel olarak geçerli değildir ve hatalı bir programdır. Bu bir donanım sorunu değildir; çökme ya da arıza ile de doğrudan ilgili değildir
    void*'den int*'ye dönüşümün donanım kodunda genelde hiçbir karşılığı yoktur ve tipler yalnızca C'de bulunduğu için donanım bu dönüşüm yüzünden çökmez de. Register içindeki tamsayı değeri ise sorun yok sanabilirsiniz, ama mesele donanımda işaretçinin gerçekten tamsayı olup olmaması değil; hizalanmamış işaretçiye dönüştürdüğünüz anda C programının tanım gereği bozulmuş olmasıdır

    • Yazar olarak doğru. Yazının “Actually, it was UB even before that” bölümünde bundan bahsetmiştim
      Tanımsız davranışın donanımda olmadığını ve çökme ya da arızayla aynı şey olmadığını anlatmaya çalışıyordum. Aynı zamanda “ama bakınca düzgün çalışıyor” diyenlere örnek göstermek istedim; aslında öyle değil
    • Bu normal ve öngörülebilir bir durum. İyi programcılar işaretçi dönüştürmenin bariz bir tehlike bölgesi olduğunu bilir
    • Hizalanmamış işaretçinin kendisinin tanımsız davranış olduğunu standartta tam olarak nerede söylediğini gösterebilir misin?
    • #pragma pack(push, 1) ile struct oluşturursam, tesadüfen hizalanmış bir üye değilse üye işaretçilerini kullanamayacağım anlamına mı geliyor?
    • C'deki tanımsız davranış kavramı aslında, makine komutları mimariden mimariye biraz farklı olsa bile derleyiciye kodu donanıma eşleme serbestliği vermek için vardı. Aynı C programı, çalıştığı mimariye göre farklı davranışları ifade edebiliyordu
      Bu tür tanımsız davranışlar makuldü ve donanım farkları yüzünden hata çıkmasını büyük bir sorun gören pek kimse yoktu
      Ama zamanla saldırgan yorumlar C'yi örtük sözleşmeyle tasarım diline benzetti ve kısıtlar görünmez hale geldi. Bu da RAII'de örtük yıkıcı çağrılarının görünmez olmasına benzer bir sorun yaratıyor
      C'de bir işaretçiyi dereference ettiğinizde derleyici fonksiyon imzasına örtük bir null olamaz kısıtı ekliyor. Null olabilecek işaretçiyi fonksiyona verip kontrol ya da assertion yazmama hatası yerine, derleyici bu null olamaz kısıtını işaretçi üzerinde sessizce yayıyor. Sonra bu kısıtın yanlış olduğunu kanıtlarsa fonksiyonu erişilemez sayıyor ve erişilemez fonksiyon çağrısı da çağıran fonksiyonu erişilemez hale getiriyor
  • C'de tanımsız davranışı öğrenmenin 5 aşaması
    İnkar: “İşaretli taşmanın benim makinemde nasıl davrandığını biliyorum”
    Öfke: “Bu derleyici çöp! Neden dediğimi yapmıyor?”
    Pazarlık: “C'yi düzeltmek için wg14'e şu öneriyi göndereceğim...”
    Depresyon: “C kodunda güvenilebilecek bir şey var mı?”
    Kabul: “Sadece tanımsız davranış kullanma”

    • “Derleyicinin tanımsız olanı tanımlamasını sağlarsın” aşaması bunun neresine düşüyor?
      Hizalanmamış erişim için packed struct kullanırsın. Derleyici sihirli biçimde doğru kodu üretir. Aslında derleyici bunu hep yapabiliyordu, sadece yapmıyordu
      Strict aliasing için union tabanlı tür dönüştürmesi kullanırsın. Önemli derleyiciler, standart söylemese bile bunun çalıştığını belgelemektedir. Ya da -fno-strict-aliasing ile tamamen kapatırsın. Belleği istediğin gibi yeniden yorumlarsın; keskin köşeler olabilir ama en azından derleyiciden kaynaklanmaz
      Taşmayı -fwrapv ile tanımlarsın. +, -, * yerine __builtin_*_overflow kullanırsan açık hata kontrolünü de bedavaya alırsın. Fonksiyonel arayüzü de güzeldir, verimli kod da üretir
      Asıl kabul, “normal insanlar C standardını umursamaz” noktasına daha yakın. Standart berbattır; önemli olan derleyicidir. Derleyicilerde bu sorunların çoğunu aşmaya yarayan son derece kullanışlı özellikler var. İnsanların bunları kullanmamasının nedeni “taşınabilir”, “standart” C yazmak istemeleri; o düşünce tarzından çıkmak asıl kabuldür
      Bu mantıkla freestanding C ortamında bir Lisp yorumlayıcısı yaptım ve UBSan'den de geçti. Başta patlayacağını sanıyordum ama olmadı; ben yapabildiysem herkes yapabilir
    • Yazının yazarı olarak, “sadece tanımsız davranış kullanma” yaklaşımının imkansız olması zaten yazının ana fikri
      Kod yazan insanlar var oldukça bu son durak olamaz. Hiçbir insan C/C++'ta tanımsız davranıştan tamamen kaçınamaz
    • “Sadece tanımsız davranış kullanma” en iyi ihtimalle hâlâ pazarlık aşaması gibi geliyor
    • Benim gibi embedded cihazlarda çalışırsan olur. Belirli bir CPU'yu hedefleyerek yazılım geliştirmek gerçekten rahat
    • C'de kabul daha çok “Ben tanımsız davranış kullanacağım ve bir gün kötü bir şey olacak” noktasına benziyor
  • Örnekler, gerçekten tanımsız davranıştan çok, girdiye ya da koşullara bağlı olarak tanımsız davranışa dönüşebilecek durumlara benziyor
    Bu kadar geniş bakarsan her fonksiyon çağrısı da stack alanını aşabileceği için tanımsız davranış olur. Aslında benzer bir anlamda bunu neredeyse her dil için söyleyebilirsin
    C'de dikkat çekmeye yetecek kadar gerçek pürüz zaten var; bu tür sansasyonellik özellikle yeni başlayanların dikkatini dağıtıp daha da zararlı olabilir

    • Ada 83, çağrı stack'inin taşmasını tanımsız davranış saymaz. Referans kılavuzunda STORAGE_ERROR istisnası tanımlıdır
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      Buna göre “alt program çağrısının yürütülmesi sırasında yeterli depolama alanı yoksa” bu istisna yükseltilir
    • Hiç doğru değil
      Öncelikle stack alanı bittiğinde ne olacağını tanımlamak mümkündür. Ayrıca her program keyfi büyüklükte stack gerektirmez; bazı programlar önceden hesaplanabilir sabit büyüklükte stack ile yetinir. Hatta bazı dil uygulamaları hiç stack kullanmaz
      Bir dil, kalan stack alanını kontrol etmeye yarayan araçlar sunup buna göre garanti verebilir. Ya da stack alanı tükendiğinde çalışacak bir handler kurulmasına izin verebilir
    • Girdiye bağlı oluşan tanımsız davranış da bir istismar yolu olabilir
    • Örnekler açıkça tanımsız davranış. Nokta
      Doğru bakış açısı, tanımsız davranış oluştuğu anda artık dil standardının koruması altında olmadığını kabul etmektir. Bir süre, hatta belki sonsuza kadar düzgün çalışabilir. Ama fiiliyatta farkında olmadan araç zincirinin, derleyici değişiminin ya da yükseltmesinin, mimarinin, runtime'ın, libc sürümü farklarının keyfine bağlı hale gelirsin
      Sonunda kum üstüne temel atmış olursun; tanımsız davranışın tehlikesi de budur
    • Bu yazı neredeyse FUD'un tanımı gibi
  • Tanımsız davranışın sorunu, bazı mimarilerde çökebilmesi değil
    Asıl sorun, derleyicinin böyle kodun asla oluşmayacağını varsayması. Yine de tanımsız davranış içeren kod yazarsan, derleyici, özellikle optimize edici, bunu normal yürütme yoluna uygun gördüğü herhangi bir biçimde çevirebilir. Bu “herhangi bir biçim” bazen büyük kod bloklarının silinmesi gibi çok beklenmedik şeyler olabilir

    • Bununla ilgili bir örnek olarak, tüm fonksiyonların ya sonlanması ya da yan etki üretmesi gerektiği koşulu var. Bunu bizzat yaşamadım ama yanlışlıkla sonsuz döngü ya da sonsuz özyineleme yazıp fonksiyonun silinmesi gayet mümkün görünüyor
      Üstüne tail recursion da gelirse, debug derlemede sonsuz döngüye hiç ulaşmayan hata ancak optimizasyon seviyesini artırınca ortaya çıkabilir
    • Çökme, tanımsız davranışın en hafif sonuçlarından biridir. En azından göze çarpar
      Daha kötü durumlarda program sessizce çöp verilerle çalışmaya devam edebilir, hard diski biçimlendirebilir ya da saldırgana krallığın anahtarlarını teslim edebilir
    • Doğru, ama bu aynı zamanda tanımsız davranışın en yararlı özelliği ve varlık nedenidir
      “Bunu tanımlı ya da unspecified yapalım” diyenler, derleyicinin programın büyük bölümlerini atabilmesinin asıl mesele olduğunu kaçırıyor
      Belirli girdilerde tanımsız davranış olan kod yazıyorsan, o girdiler için programın hiçbir davranışı olmamasını kastetmiş olursun. Derleyicinin o yolu optimizasyonla kaldırmasını ya da tanımlı diğer durumların davranışına yardımcı olacak başka işlemler yapmasını beklersin
      Yalnızca tanımsız davranış üzerinden erişilebilen bir log dizesi koyup, bu dizenin binary içinde kalmadığını görmek oldukça tatmin edici
    • Yazıdaki optimizasyon meselesi değil vurgusu özellikle dikkatimi çekti
      Bir zamanlar dönüşüm hattının en sonunda çalışacağı varsayımıyla bir analiz geçişi yazmıştım ve doğruluk için bu varsayım gerekliydi. Artık optimizasyon olmayacağı için güvenli sanıyordum, ama artık o kadar emin değilim
    • Bu bir sorun değil, özellik
  • 20 yıldır C kullanıyorum ama son 6 ayda Hacker News'te gördüğüm kadar çok tanımsız davranış konuşulduğunu daha önce hiç görmedim
    Gerçek hayattaki sohbetlerde neredeyse hiç geçmiyordu. Kodu yazarsın; çalışmazsa debug eder, düzeltir ya da etrafından dolaşırsın. C'deki tanımsız davranış konusu neden bu kadar düzenli biçimde ana sayfaya çıkıyor, anlamıyorum

    • Hacker News hâlâ gerçek programlamadan çok programlama dillerine ilgi duyan tarafa eğilimli. Y Combinator'ın Lisp mirasının da payı olabilir
      Yeni programlama dilleri geliştirmeyi ya da kullanmayı dünyadaki en ilginç şey sayan küçük ama sürekli bir bilgisayar bilimi kitlesi var; bunların bir kısmı bunu yıllarca sürdürüyor
      Böyle insanların dil tasarımına ilgi duyması doğal ve C'deki tanımsız davranış da bu alanın içinde. Yine de bunun önemli bir bölümü başlangıçta eski CPU mimarilerini performans kaybı olmadan kapsama çabasından doğmuştu; dolayısıyla buna, tekerleğin yuvarlak olması kadar “tasarım tercihi” demek de biraz tuhaf kaçıyor
    • Ne demek istiyorsun? 20 yıl önce de C ve C++ kullanıyordum ve o zaman da tanımsız davranış sohbetlerde ve eğitimlerde önemli yer tutuyordu
      GCC 3.2 civarında derleyiciler optimizasyonda tanımsız davranışı çok daha saldırgan biçimde kullanmaya başlayınca epey ünlü “skandallar” yaşandı ve bu yüzden birçok kişi uzun süre GCC 2.95'te kaldı. GCC 3.2, 2002'de çıktı
    • Eski bilgisayarlar harikaydı, bugünküler tehlikeli hale geldi
      Tüm şirketler güvenlik ve görünürlük, yani haberlere çıkma meselesini sürekli öne çıkardığı için “güvensizliğe karşı” anlatı aşırı büyüdü
      Yeni dünya, vahşi doğayı hiç görmemiş şehir insanlarının çim biçme makinesini görünce korkmasına benziyor. Bıçaklar mı dönüyor? Olacak şey değil!
    • Çalışma ortamı bambaşka bir mimari olabilir; bu yüzden bu ayrıntılar çok önemli
      Gerçek hedef uzak bir iletişim kulesinin üstündeki küçük bir embedded sistemse, “benim makinemde çalışıyor” hiçbir işe yaramaz. Elbette çoğu kişi böyle iş yapmıyor ve buradaki geliştiricilerin büyük kısmı muhtemelen web geliştiricisi, ama bunu yaşamamış olsan bile ilginç bir tartışma. Hatta belki de tam bu yüzden daha ilginç
    • Tam olarak hayali bir spesifikasyona göre değil, hedef platforma göre yazıyorsun. Spesifikasyon, hedef platformun kabaca ne yapacağını öngörmede yararlıdır ama nihai otorite değildir
      Derleyicide spesifikasyona göre çalışması gerekirken çalışmayan hatalar olabilir; standartta karşılığı olmayan çok sayıda genişletme vardır; ayrıca standartta tanımsız olsa bile belirli uygulamalarda anlamlı sonuç verilmiş davranışlar da bulunur
  • Giriş kısmına genel olarak katılıyorum ama örnekler iyi değil ve yazının tamamı LLM ile kodlamayı pazarlamak için paketlenmiş gibi duruyor

    • Evet. Örnekler ya taşınabilir kod yazarken zaten kaçınılan standart şeyler ya da adres 0'daki nesneye erişim gibi gereksiz durumlar
      Sanki istediği gibi her türlü kodu yazıp bunun tüm ortamlarda aynı davranmasını bekleyen biri gibi görünüyor. Dili böyle tasarlarsan, gerektiğinde platforma özgü yazabilme avantajını kaybedersin
    • Nasıl iyi değil? Eğer doğruysa bu oldukça ciddi
  • Yazıdaki C++ kodunun bazı bölümleri 10 yıldan uzun süredir deyimsel değil; bugün ise kod kokusu sayılabilir
    Dil, ilk tasarlandığı zamandan oldukça farklı bir şeye evrildi. Her yerde ham işaretçiler ve doğrudan işaretçi erişimi görünce, yazının bazı kısımlarını filtreleyerek okumak gerektiği açık oldu
    Bir diğer bariz sorun da C ile C++'ı neredeyse aynı dilmiş gibi bir arada ele alması. Günümüzde bu iki dil gerçekte epey uzaklaştı

    • Kodun C++ değil C olduğunu söyleyecektim ama tekrar bakınca gerçekten atomic_int değil std::atomic yazdığını gördüm
  • C'deki tanımsız davranışı şöyle anlamak doğru mu?
    Program P için, tanımsız davranış üretmeyen bir A girdi kümesi ve bunu üreten tamamlayıcı bir B kümesi vardır
    Doğru bir derleyici, P'yi yürütülebilir P' dosyasına derler. A içindeki tüm girdiler için P', P ile aynı davranmalıdır
    Ama B içindeki herhangi bir girdi için P' davranışı üzerinde hiçbir gereklilik yoktur

    • Sezgisel olarak evet. Program, B girdilerinin asla verilmeyeceği varsayımıyla derlenir; buna B girdilerini tespit etmeye çalışan kodun kaldırılması da dahil olabilir
    • Güzel özet
  • Hizalanmamış işaretçi nedeniyle oluşan tanımsız davranışa dair somut örnek: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • Özellikle sorun çıkarmayacağı sıkça varsayılan x86 üzerindeki bir örnek