Dalam apa yang seharusnya menjadi putaran terakhir dari loop, Anda menulis array[10]
, tetapi hanya ada 10 elemen dalam array, diberi nomor 0 hingga 9. Spesifikasi bahasa C mengatakan bahwa ini adalah "perilaku tidak terdefinisi". Apa artinya ini dalam praktik adalah bahwa program Anda akan mencoba untuk menulis ke int
bagian memori yang berukuran yang terletak segera setelah array
dalam memori. Apa yang terjadi kemudian tergantung pada apa yang sebenarnya, terletak di sana, dan ini tidak hanya tergantung pada sistem operasi tetapi lebih pada kompiler, pada opsi kompiler (seperti pengaturan optimisasi), pada arsitektur prosesor, pada kode di sekitarnya. , dll. Bahkan dapat bervariasi dari eksekusi ke eksekusi, misalnya karena mengalamatkan ruang pengalamatan (mungkin tidak pada contoh mainan ini, tetapi hal itu terjadi dalam kehidupan nyata). Beberapa kemungkinan termasuk:
- Lokasi tidak digunakan. Loop berakhir secara normal.
- Lokasi itu digunakan untuk sesuatu yang kebetulan memiliki nilai 0. Loop berakhir secara normal.
- Lokasi berisi alamat pengembalian fungsi. Loop berakhir secara normal, tetapi kemudian program crash karena mencoba untuk melompat ke alamat 0.
- Lokasi berisi variabel
i
. Loop tidak pernah berakhir karena i
restart pada 0.
- Lokasi berisi beberapa variabel lain. Loop berakhir secara normal, tetapi hal-hal "menarik" terjadi.
- Lokasi adalah alamat memori yang tidak valid, misalnya karena
array
tepat di akhir halaman memori virtual dan halaman berikutnya tidak dipetakan.
- Setan terbang keluar dari hidung Anda . Untungnya kebanyakan komputer tidak memiliki perangkat keras yang diperlukan.
Apa yang Anda amati pada Windows adalah bahwa kompiler memutuskan untuk menempatkan variabel i
segera setelah array dalam memori, sehingga array[10] = 0
akhirnya ditugaskan i
. Di Ubuntu dan CentOS, kompiler tidak ditempatkan di i
sana. Hampir semua implementasi C melakukan pengelompokan variabel lokal dalam memori, pada tumpukan memori , dengan satu pengecualian utama: beberapa variabel lokal dapat ditempatkan sepenuhnya dalam register . Bahkan jika variabel ada di stack, urutan variabel ditentukan oleh kompiler, dan itu mungkin tidak hanya bergantung pada urutan dalam file sumber tetapi juga pada tipenya (untuk menghindari pemborosan memori untuk menyelaraskan batasan yang akan meninggalkan lubang) , pada nama mereka, pada beberapa nilai hash yang digunakan dalam struktur data internal kompiler, dll.
Jika Anda ingin mengetahui apa yang diputuskan oleh kompiler Anda, Anda dapat memerintahkannya untuk menunjukkan kepada Anda kode assembler. Oh, dan belajar menguraikan assembler (lebih mudah daripada menulisnya). Dengan GCC (dan beberapa kompiler lain, terutama di dunia Unix), berikan opsi -S
untuk menghasilkan kode assembler alih-alih biner. Misalnya, inilah cuplikan assembler untuk loop dari kompilasi dengan GCC di amd64 dengan opsi pengoptimalan -O0
(tanpa optimasi), dengan komentar ditambahkan secara manual:
.L3:
movl -52(%rbp), %eax ; load i to register eax
cltq
movl $0, -48(%rbp,%rax,4) ; set array[i] to 0
movl $.LC0, %edi
call puts ; printf of a constant string was optimized to puts
addl $1, -52(%rbp) ; add 1 to i
.L2:
cmpl $10, -52(%rbp) ; compare i to 10
jle .L3
Di sini variabelnya i
adalah 52 byte di bawah bagian atas stack, sedangkan array dimulai 48 byte di bawah bagian atas stack. Jadi kompiler ini kebetulan telah ditempatkan i
tepat sebelum array; Anda akan menimpa i
jika Anda kebetulan menulis array[-1]
. Jika Anda mengubah array[i]=0
ke array[9-i]=0
, Anda akan mendapatkan sebuah loop tak terbatas pada platform tertentu dengan opsi compiler tertentu.
Sekarang mari kita kompilasi program Anda dengan gcc -O1
.
movl $11, %ebx
.L3:
movl $.LC0, %edi
call puts
subl $1, %ebx
jne .L3
Itu lebih pendek! Kompiler tidak hanya menolak untuk mengalokasikan lokasi stack untuk i
- itu hanya pernah disimpan dalam register ebx
- tetapi juga tidak mau repot untuk mengalokasikan memori apa pun array
, atau untuk menghasilkan kode untuk mengatur elemen-elemennya, karena ia menyadari bahwa tidak ada elemen yang pernah digunakan.
Untuk membuat contoh ini lebih jitu, mari pastikan bahwa tugas array dilakukan dengan menyediakan sesuatu yang tidak dapat dioptimalkan oleh kompiler. Cara mudah untuk melakukannya adalah dengan menggunakan array dari file lain - karena kompilasi terpisah, kompiler tidak tahu apa yang terjadi pada file lain (kecuali itu dioptimalkan pada waktu tautan, yang gcc -O0
atau gcc -O1
tidak). Buat file sumber yang use_array.c
berisi
void use_array(int *array) {}
dan ubah kode sumber Anda menjadi
#include <stdio.h>
void use_array(int *array);
int main()
{
int array[10],i;
for (i = 0; i <=10 ; i++)
{
array[i]=0; /*code should never terminate*/
printf("test \n");
}
printf("%zd \n", sizeof(array)/sizeof(int));
use_array(array);
return 0;
}
Kompilasi dengan
gcc -c use_array.c
gcc -O1 -S -o with_use_array1.c with_use_array.c use_array.o
Kali ini kode assembler terlihat seperti ini:
movq %rsp, %rbx
leaq 44(%rsp), %rbp
.L3:
movl $0, (%rbx)
movl $.LC0, %edi
call puts
addq $4, %rbx
cmpq %rbp, %rbx
jne .L3
Sekarang array ada di stack, 44 byte dari atas. Bagaimana dengan i
? Itu tidak muncul di mana pun! Tetapi penghitung loop disimpan dalam register rbx
. Ini tidak persis i
, tetapi alamat array[i]
. Compiler telah memutuskan bahwa karena nilai i
tidak pernah digunakan secara langsung, tidak ada gunanya melakukan aritmatika untuk menghitung di mana menyimpan 0 selama setiap putaran dijalankan. Alih-alih alamat itu adalah variabel loop, dan aritmatika untuk menentukan batas-batas dilakukan sebagian pada waktu kompilasi (kalikan 11 iterasi dengan 4 byte per elemen array untuk mendapatkan 44) dan sebagian saat run time tetapi sekali dan untuk semua sebelum loop dimulai ( lakukan pengurangan untuk mendapatkan nilai awal).
Bahkan pada contoh yang sangat sederhana ini, kita telah melihat bagaimana mengubah opsi kompiler (menghidupkan optimasi) atau mengubah sesuatu yang kecil ( array[i]
menjadi array[9-i]
) atau bahkan mengubah sesuatu yang tampaknya tidak berhubungan (menambahkan panggilan ke use_array
) dapat membuat perbedaan yang signifikan terhadap apa yang dihasilkan oleh program yang dapat dieksekusi yang dihasilkan oleh kompiler tidak. Optimalisasi kompiler dapat melakukan banyak hal yang mungkin tampak tidak intuitif pada program yang menjalankan perilaku tidak terdefinisi . Itu sebabnya perilaku yang tidak terdefinisi dibiarkan sepenuhnya tidak terdefinisi. Ketika Anda menyimpang sedikit dari trek, dalam program dunia nyata, akan sangat sulit untuk memahami hubungan antara apa yang dilakukan kode dan apa yang seharusnya dilakukan, bahkan untuk programmer yang berpengalaman.