W tym, co powinno być ostatnim uruchomieniem pętli, piszesz array[10]
, ale w tablicy jest tylko 10 elementów, ponumerowanych od 0 do 9. Specyfikacja języka C mówi, że jest to „niezdefiniowane zachowanie”. W praktyce oznacza to, że twój program będzie próbował zapisać int
w pamięci o odpowiednim rozmiarze, która leży bezpośrednio po niej array
w pamięci. To, co się dzieje, zależy od tego, co tam właściwie leży, a to zależy nie tylko od systemu operacyjnego, ale bardziej od kompilatora, opcji kompilatora (takich jak ustawienia optymalizacji), architektury procesora, otaczającego kodu itp. Może nawet różnić się w zależności od wykonania, np. z powodu randomizacji przestrzeni adresowej (prawdopodobnie nie w tym przykładzie zabawki, ale dzieje się tak w prawdziwym życiu). Niektóre możliwości obejmują:
- Lokalizacja nie była używana. Pętla kończy się normalnie.
- Lokalizacja została użyta do czegoś, co miało wartość 0. Pętla kończy się normalnie.
- Lokalizacja zawierała adres zwrotny funkcji. Pętla kończy się normalnie, ale następnie program ulega awarii, ponieważ próbuje przejść do adresu 0.
- Lokalizacja zawiera zmienną
i
. Pętla nigdy się nie kończy, ponieważ i
uruchamia się ponownie od 0.
- Lokalizacja zawiera inną zmienną. Pętla kończy się normalnie, ale zdarzają się „interesujące” rzeczy.
- Lokalizacja jest nieprawidłowym adresem pamięci, np. Ponieważ
array
znajduje się na końcu strony pamięci wirtualnej, a następna strona nie jest mapowana.
- Demony wylatują z twojego nosa . Na szczęście większość komputerów nie ma wymaganego sprzętu.
W systemie Windows zaobserwowano, że kompilator postanowił umieścić zmienną i
bezpośrednio po tablicy w pamięci, więc array[10] = 0
ostatecznie przypisał ją i
. W Ubuntu i CentOS kompilator nie umieścił i
tam. Prawie wszystkie implementacje języka C grupują zmienne lokalne w pamięci na stosie pamięci , z jednym głównym wyjątkiem: niektóre zmienne lokalne można całkowicie umieścić w rejestrach . Nawet jeśli zmienna znajduje się na stosie, kolejność zmiennych jest określana przez kompilator i może zależeć nie tylko od kolejności w pliku źródłowym, ale także od ich typów (aby uniknąć marnowania pamięci na ograniczenia wyrównania, które pozostawią dziury) , na ich nazwy, na niektóre wartości skrótu używane w wewnętrznej strukturze danych kompilatora itp.
Jeśli chcesz dowiedzieć się, co zrobił twój kompilator, możesz mu powiedzieć, żeby pokazał ci kod asemblera. Aha i naucz się rozszyfrowywać asembler (to łatwiejsze niż pisanie). W GCC (i niektórych innych kompilatorach, szczególnie w świecie Unixowym), podaj opcję -S
tworzenia kodu asemblera zamiast pliku binarnego. Na przykład, oto fragment asemblera dla kompilacji pętli z GCC na amd64 z opcją optymalizacji -O0
(bez optymalizacji), z komentarzami dodanymi ręcznie:
.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
Tutaj zmienna i
znajduje się 52 bajty poniżej górnej części stosu, podczas gdy tablica zaczyna 48 bajtów poniżej górnej części stosu. Tak się składa, że ten kompilator umieścił i
tuż przed tablicą; nadpisujesz, i
jeśli zdarzy ci się pisać array[-1]
. Jeśli zmienisz array[i]=0
na array[9-i]=0
, dostaniesz nieskończoną pętlę na tej konkretnej platformie z tymi konkretnymi opcjami kompilatora.
Teraz skompilujmy Twój program gcc -O1
.
movl $11, %ebx
.L3:
movl $.LC0, %edi
call puts
subl $1, %ebx
jne .L3
To krócej! Kompilator nie tylko odmówił przydzielenia lokalizacji stosu i
- jest on zawsze przechowywany w rejestrze ebx
- ale nie zadał sobie trudu, aby przydzielić pamięć array
lub wygenerować kod do ustawienia swoich elementów, ponieważ zauważył, że żaden z elementów są kiedykolwiek używane.
Aby ten przykład był bardziej wymowny, upewnijmy się, że przypisania tablic są wykonywane przez dostarczenie kompilatorowi czegoś, czego nie jest w stanie zoptymalizować. Prostym sposobem na to jest użycie tablicy z innego pliku - z powodu osobnej kompilacji kompilator nie wie, co dzieje się w innym pliku (chyba że zoptymalizuje się w czasie łącza, który nie, gcc -O0
lub gcc -O1
nie). Utwórz plik źródłowy use_array.c
zawierający
void use_array(int *array) {}
i zmień kod źródłowy na
#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;
}
Połącz z
gcc -c use_array.c
gcc -O1 -S -o with_use_array1.c with_use_array.c use_array.o
Tym razem kod asemblera wygląda następująco:
movq %rsp, %rbx
leaq 44(%rsp), %rbp
.L3:
movl $0, (%rbx)
movl $.LC0, %edi
call puts
addq $4, %rbx
cmpq %rbp, %rbx
jne .L3
Teraz tablica jest na stosie, 44 bajty od góry. Co i
? Nigdzie się nie pojawia! Ale licznik pętli jest przechowywany w rejestrze rbx
. To nie jest dokładnie i
, ale adres array[i]
. Kompilator zdecydował, że skoro wartość i
nigdy nie była używana bezpośrednio, nie było sensu wykonywać arytmetyki, aby obliczyć, gdzie przechowywać 0 podczas każdego przebiegu pętli. Zamiast tego ten adres jest zmienną pętli, a arytmetyka określająca granice została wykonana częściowo w czasie kompilacji (pomnóż 11 iteracji przez 4 bajty na element tablicy, aby uzyskać 44), a częściowo w czasie wykonywania, ale raz na zawsze, zanim pętla się rozpocznie ( wykonaj odejmowanie, aby uzyskać wartość początkową).
Nawet na tym bardzo prostym przykładzie widzieliśmy, jak zmiana opcji kompilatora (włączenie optymalizacji) lub zmiana czegoś drobnego ( array[i]
na array[9-i]
) lub nawet zmiana czegoś pozornie niezwiązanego (dodanie wywołania use_array
) może mieć znaczący wpływ na to, co program wykonywalny wygenerował przez kompilator. Optymalizacje kompilatora mogą zrobić wiele rzeczy, które mogą wydawać się nieintuicyjne w programach wywołujących niezdefiniowane zachowanie . Dlatego niezdefiniowane zachowanie jest całkowicie niezdefiniowane. Nawet jeśli nieco odbiegasz od ścieżek, w rzeczywistych programach może być bardzo trudno zrozumieć związek między tym, co robi kod, a tym, co powinien był zrobić, nawet dla doświadczonych programistów.