Menu

C'de Tanımsız Davranış: Ne Demektir ve Neden Canınızı Yakar

Tanımsız davranış, C standardının hiçbir gereksinim koymadığı koddur - dolayısıyla iyileştiricinin denetimlerinizi silmesi dahil her şey olabilir. İşte ona ne yol açtığı, "benim makinemde çalışıyor"un neden hiçbir şey kanıtlamadığı ve onu yakalayan bayraklar.

Bu sayfada çalıştırılabilir editörler var - düzenle, çalıştır ve sonucu anında gör.

"Tanımsız davranış", "öngörülemez"in jargon hâli gibi geliyor. Bundan daha güçlüdür. C standardı bir yapının tanımsız davranışı olduğunu söylediğinde, standardın programın ne yaptığı konusunda hiçbir gereksinim koymadığını kastediyordur - ne değer üzerinde, ne deyim üzerinde, ne de program üzerinde.

İnsanları şaşırtan kısım budur: tanımsız davranış sorunlu satırla sınırlı değildir. Bir derleyicinin bunun hiç olmadığını varsayması ve çevredeki kodu o varsayımla yeniden yazması serbesttir. Sonuç, açıkça yazdığınız bir denetimin ikili dosyada var olmadığı bir program olabilir.

Sözleşme Modeli

Standardı sizinle derleyici arasında bir sözleşme olarak düşünün. Belirli şeyleri yapmayacağınıza söz verirsiniz; karşılığında derleyici programınızın söylediği şeyi ifade edeceğine söz verir.

Bir dizinin dışını indekslemeyin. İşaretli bir tam sayıyı taşırmayın. İlk değersiz bir değeri okumayın. Bir göstericiyi serbest bıraktıktan sonra kullanmayın. Aralarında bir sıra noktası olmadan tek bir ifadede aynı nesneyi iki kez değiştirmeyin.

Bir maddeyi çiğneyin, anlaşma iptal olur - yalnızca o satır için değil, tüm program için. "Makul bir geri dönüş" yoktur ve çökme zorunluluğu yoktur.

Ayırmaya değer üç ilgili terim:

  • Tanımsız davranış - her şey olabilir. Sınır dışı erişim, işaretli taşma, serbest-bırakma-sonrası-kullanım.
  • Belirtilmemiş davranış - birkaç geçerli sonuçtan biri ve derleyicinin hangisi olduğunu söylemesi gerekmez. Örneğin fonksiyon argümanlarının değerlendirilme sırası.
  • Gerçekleştirime bağlı davranış - gerçekleştirim seçer ve seçimini belgelemek zorundadır. char'ın işaretli olup olmadığı, bir int'in ne kadar büyük olduğu.

"İyileştirici kodumu sildi" anlamında tehlikeli olan yalnızca birincisidir.

Büyük Kaynaklar

İşaretli Tam Sayı Taşması

İşaretsiz aritmetik sarar ve standart bunu söyler. İşaretli aritmetik sarmaz - aralığı aşmak tanımsızdır.

if (a > INT_MAX - b) denetimi tamamen geçerli aralığın içinde çalışır ki bu onu doğru bir taşma testi yapan şeydir. if (a + b < 0) yazmak taşmayı önce gerçekleştirir ve sonra sonucu sorar - ve taşmanın hiç olmadığını varsaymaya hakkı olan derleyici denetimi kaldırabilir.

Sınır Dışı Erişim

Bir dizinin dışını okumak ya da yazmak, çökse de çökmese de tanımsızdır:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: 5 indeksi yok */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* bu göstericiyi hesaplamak bile UB */

O son satıra dikkat edin: sonun bir ötesinden fazlasına gösterici oluşturmak, onu hiç dereferans etmeseniz bile tanımsızdır. Standart arr + 5'e (sonun bir ötesi, döngü sonlandırması için) izin verir ama arr + 6'ya izin vermez.

Küçük taşmalar tehlikeli olanlardır. Genellikle segfault vermezler; komşu bir değişkenin sessizce üzerine yazarlar ve yanlış cevap alakasız bir yerde ortaya çıkar.

İlk Değersiz Okumalar

int x;
printf("%d\n", x);     /* UB: belirsiz bir değeri okumak */

int *p;
*p = 42;               /* UB: belirsiz bir göstericiyi dereferans etmek */

"Yalnızca çöp tutuyor" diye düşünmek caziptir, ama standart bunu söylemez ve derleyiciler farkı kullanır. GCC'nin, atamadan önce okunan bir değişkenin istediği herhangi bir değeri tutabileceği sonucuna vardığı - bir dalın katlanıp kaybolmasını sağlayan değer dahil - bilinir.

Sarkan Göstericiler

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: serbest bırakma sonrası kullanım */
free(p);               /* UB: çifte serbest bırakma */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: nesnenin ömrü bitti */

Çalışma zamanı sonuçları segmentation fault sayfasında ele alınıyor; buradaki nokta, çökmenin şanslı sonuç olduğudur.

Yanlış printf Belirteci

printf("%d\n", 3.14);        /* UB: bir double ile %d */
printf("%s\n", 42);          /* UB: bir int ile %s - genellikle çöker */
printf("%d %d\n", 1);        /* UB: belirteçlerden az argüman */
long n = 5;
printf("%d\n", n);           /* long'un int'ten geniş olduğu sistemlerde UB */

printf değişken argümanlıdır: argümanları biçim metnine göre okur ve onları denetleyemez. Bir uyumsuzluk, yanlış yerden yanlış sayıda bayt okumasına yol açar. -Wall ile derleyin, derleyici biçim metnini sizin için denetler - bu, kümedeki en değerli uyarılardan biridir.

Tek Bir İfadede Bir Nesneyi İki Kez Değiştirmek

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

Bunlar yalnızca "derleyiciye bağlı" değil tanımsızdır. i = i++ + ++i'nin neye değerlendiğini soran ders kitabı bulmacalarının doğru bir cevabı yoktur.

Katı Takma Ad

Bir nesneye uyumsuz tipte bir gösterici üzerinden erişmek tanımsızdır ve bu, deneyimli programcıları şaşırtır:

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: bir float'ı bir int * üzerinden okumak */

Derleyici bir int * ile bir float *'ın asla aynı belleği göstermediğini varsayar ve buna göre yeniden sıralar. Baytları yeniden yorumlamanın tanımlı yolu memcpy'dir (aynı komutlara iyileştirilir) ya da bir birleşimdir:

char * istisnadır - herhangi bir nesnenin baytlarını her zaman unsigned char * üzerinden inceleyebilirsiniz.

"Benim Makinemde Çalışıyor" Neden Hiçbir Şey Kanıtlamaz

Tanımsız davranış çoğu zaman çalışıyor gibi görünür ve onu tehlikeli kılan da budur. Program geliştirme ve test boyunca doğru çalışır, sonra tamamen alakasız bir şey değiştiğinde bozulur:

  • Daha akıllı bir iyileştiriciye sahip yeni bir derleyici sürümü.
  • Yayın derlemesi için -O0'dan -O2'ye geçmek.
  • Alakasız bir fonksiyon eklemek ve yığın yerleşimini kaydırarak bir taşmanın artık önemli bir şeye düşmesine yol açmak.
  • Farklı bir makine, farklı bir libc, farklı bir işletim sistemi.

Çalışıyor görünmesi doğruluğun kanıtı değildir, çünkü standart hiçbir şey vaat etmedi. Uykudaki bir hatadır ve tetikleyici genellikle yayın derlemesidir.

İyileştirici Onu Nasıl Kullanır

İşte tartışmayı genellikle bitiren örnek. Bir programcı bir null denetimi yazar:

void process(int *p) {
    int value = *p;              /* dereferans */
    if (p == NULL) {             /* sonra null denetimi */
        return;
    }
    printf("%d\n", value * 2);
}

Sıra yanlış - denetim dereferanstan sonra geliyor - ama denetim yine de çalışır, değil mi?

Çalışmak zorunda değil. Derleyici şöyle akıl yürütür: *p dereferans edildi, dolayısıyla p NULL olamaz (NULL'u dereferans etmek tanımsızdır, dolayısıyla tanımlı davranışı olan herhangi bir programda NULL değildir), dolayısıyla p == NULL her zaman yanlıştır, dolayısıyla tüm if gövdesi ölü koddur ve silinebilir.

Derlenmiş fonksiyonun içinde hiç null denetimi yoktur. Bu kalıbın Linux çekirdeğindeki gerçek bir örneği CVE-2009-1897 oldu; orada GCC tam olarak böyle bir denetimi kaldırdı ve zararsız görünen bir sıralama hatasını sömürülebilir bir açığa dönüştürdü.

İkinci, daha küçük bir örnek:

/* Çalışmayan bir taşma denetimi */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "sardı mı?" */
        return -1;
    }
    return sum;
}

İşaretli tipler için derleyici a + b'nin taşmadığını varsayabilir; o durumda sum < a yalnızca b < 0 iken mümkündür. Bu varsayımla denetim yazarın kastettiğinden başka bir şeyi test eder ve b >= 0 ile tamamen iyileştirilip kaldırılabilir. Çalışan sürüm önceden denetler:

Her iki test de gösterilebilir aralığın içinde kalır, dolayısıyla hiç taşma olmaz ve iyileştiricinin varsayıp yok sayacağı bir şey yoktur. (GCC ve clang ayrıca bunu tek komutta yapan __builtin_add_overflow'u sağlar.)

Onu Saptamak

Önce durağan uyarılar - bedavadırlar:

gcc -Wall -Wextra -Wpedantic program.c -o program

Bu, biçim metni uyumsuzluklarını, bazı ilk değersiz okumaları, şüpheli karşılaştırmaları ve erişilemez kodu yakalar.

Sonra programı enstrümante eden ve ihlal anında bildiren denetleyiciler:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

Bellek yarısı için AddressSanitizer ile birleştirin:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

Birlikte sınır dışı erişimleri, serbest-bırakma-sonrası-kullanımı, çifte serbest bırakmaları, işaretli taşmayı, geçersiz kaydırmaları, hizalanmamış göstericileri ve null dereferansları yakalarlar - her biri bir dosya, bir satır ve bir yığın iziyle. Kabaca 2 kat yavaş ki geliştirme sırasında bu hiçbir şeydir.

valgrind ./program yeniden derleme gerektirmez ve ilk değersiz okumalar ile bellek hatalarını yakalar, ama aritmetik UB'yi yakalamaz. clang --analyze ve gcc -fanalyzer, programı hiç çalıştırmadan bir kısmını bulur.

Pratik kural: testlerinizi CI'da denetleyiciler altında çalıştırın. Yerelde çalışıyor görünen UB, tam olarak onların açığa çıkarmak için var olduğu şeydir.

Onunla Yaşamak

Tanımsız davranıştan dikkatli olarak kaçınamazsınız - herkes eninde sonunda yazar. İşe yarayan şey onu yüksek sesli kılmaktır:

  • İlk günden -Wall -Wextra ile derleyin ve her uyarıyı düzeltin.
  • Testleri -fsanitize=address,undefined altında çalıştırın.
  • Her değişkene bildirimde, her göstericiye NULL ilk değeri verin.
  • Dizi indekslerini uzunluğa karşı denetleyin ve i < n ile döngü kurun.
  • <limits.h> kullanarak aritmetikten önce taşmayı denetleyin.
  • Serbest bıraktıktan sonra göstericiyi NULL yapın.
  • Sarmanın amaçlanan davranış olduğu yerde unsigned tipler kullanın - orada tanımlıdır.
  • Baytları yeniden yorumlarken gösterici dönüşümlerine memcpy'yi tercih edin.

C'nin hızı, derleyicinin sözleşmeyi tuttuğunuzu varsaymasına izin verilmesinden gelir. Bu bir tasarım kusuru değil gerçek bir takastır - ve yukarıdaki araçlar, geliştirme zamanında hiçbir maliyet karşılığında güvenliğin çoğunu size geri verir.

Bu kuralların çalışma zamanı yüzü için bkz. segmentation fault; önce gelen derleme zamanı hataları için sık yapılan hatalar.

Sıkça Sorulan Sorular

C'de tanımsız davranış nedir?

C standardının hiçbir gereksinim koymadığı koddur. Derleyici her şeyi üretmekte özgürdür: bir çökme, yanlış bir cevap, çalışıyor gibi görünen kod ya da sorunlu dalın tamamen kaldırıldığı kod. "Gerçekleştirime bağlı" ya da "rastgele" değildir - bozduğunuz bir sözleşmedir ve sonrasında hiçbir şey vaat edilmez.

C'nin neden her şeyi tanımlamak yerine tanımsız davranışı var?

Hız ve taşınabilirlik. Her dizi erişiminde sınır denetimi istemek, C'nin ödememek üzere tasarlandığı bir başarım maliyeti getirirdi; işaretli taşmayı sarma olarak tanımlamak, bunun yerine hata veren donanımda fazladan komut zorlardı. O durumları tanımsız bırakmak, derleyicinin hiç olmadıklarını varsayıp buna göre iyileştirme yapmasını sağlar.

C'de işaretli tam sayı taşması tanımsız davranış mıdır?

Evet. INT_MAX + 1 tanımsızdır - INT_MIN'e sarması garanti değildir. İşaretsiz taşma farklıdır: tamamen tanımlıdır ve 2^N'e göre sarar. Derleyicilerin işaretli bir x için x + 1 > x'in her zaman doğru olduğunu varsayabilmesinin ve böyle yazılmış bir taşma denetimini silebilmesinin sebebi budur.

C programımdaki tanımsız davranışı nasıl saptarım?

Derleyicinin durağan olarak görebildiklerini yakalamak için -Wall -Wextra ile derleyin, sonra testlerinizi -fsanitize=address,undefined ile çalıştırın; bu, sınır dışı erişimleri, serbest-bırakma-sonrası-kullanımı, işaretli taşmayı ve dahasını olduğu anda dosya ve satır adıyla bildirir. valgrind, yeniden derlemeden benzer bir kümeyi yakalar.

Coddy programming languages illustration

Coddy ile kodlamayı öğren

BAŞLA