poniedziałek, 10 maja 2021

C - Przepełnienie zmiennych całkowitych oraz nieujemnych

W tym poście chciałbym opisać błąd przepełnienia liczby całkowitej.


Liczby nieujemne:


Tutaj określenie przepełnienie jest nieodpowiednie. W przypadku wartości typu unsigned (np. uint16_t) wynik może być przewidziany. Wartość zostanie obcięta do ilości bitów jakie mogą zostać umieszczone w danym miejscu w pamięci.

  1. uint16_t val = 10000 * 10000;

Wynik oczywiście będzie poza zakresem dla wartości 16 bitowej (0xFFFF - 65535). Poprawnym wynikiem operacji mnożenia powinna być wartość 100 000 000. Co zapisane w postaci bitowej wygląda następująco:

101 1111 0101 1110 0001 0000 0000

Powyższy wynik mnożenia dla ARM GCC 8.2 wyświetlił wynik 57600. Co odpowiada dolnym 16 bitom z oczekiwanego wyniku:

1110 0001 0000 0000

Jak widać w tym przypadku straciliśmy bity z poza zakresu dla wybranego typu zmiennej. Natomiast wynik można było w oczywisty sposób przewidzieć.

Najlepszym sposobem na zabezpieczenie się przed takimi zdarzeniami jest sprawdzenie rozmiaru wprowadzanych danych wejściowych:

  1. uint16_t prepare_output_data(uint16_t a, uint16_t b)
  2. {
  3.     if(a > 400) { a = 400; }
  4.     if(b > 400) { b = 400; }
  5.  
  6.     return (a * b);
  7. }

Tutaj w zależności od potrzeb i późniejszych rozwiązań w programie można albo przypisać danym wartości maksymalne dopuszczalne lub od razu po wykryciu przekroczenia limitu wyświetlić błąd.

  1. uint16_t prepare_output_data(uint16_t a, uint16_t b)
  2. {
  3.     if(a > 400) { return 0; }
  4.     if(b > 400) { return 1; }
  5.  
  6.     return a * b;
  7. }

Innym rozwiązaniem może być także sprawdzenie czy operacja arytmetyczna przekroczy dopuszczalny zakres danych. 

  1. #define U16_MAX 65535
  2.  
  3. uint16_t prepare_output_data(uint16_t a, uint16_t b)
  4. {
  5.     if((a*b) > U16_MAX) {  
  6.         return 0;
  7.     }
  8.     return a * b;
  9. }

Bez względu na wybrany rodzaj zabezpieczenia zawsze warto zdawać sobie z prawą z maksymalnej wartości jaka może być przypisana dla danego typu w wybranym systemie. I jaka maksymalna wartość może zostać do niej wprowadzona przez użytkownika. Pozwoli to na uniknięcie późniejszych, często trudnych do wykrycia błędów w systemie. 

Liczby całkowite:


Zdarzenie przepełnienia liczby całkowitej jest zdarzeniem nieokreślonym. Wynik takiej operacji zależy od wielu czynników jak np. od kompilatora, architektura procesora czy typ zmiennej. 

W przypadku przypisanie do zmiennej za dużej wartości, większość kompilatorów zwróci odpowiednie ostrzeżenie:


Dotyczy to tylko i wyłącznie bezpośredniego wprowadzania danych do zmiennej np. w taki sposób:

  1. int val = 1000000 * 10000000;

Jeśli dane wprowadzone będą nie bezpośrednio np. jako dane wyjściowe z funkcji, lub jako dane wprowadzone przez użytkownika, to takiej informacji już nie uzyskamy. Ponieważ kompilator w momencie przygotowywania programu nie wie czy ta zmienna wyjdzie po za zakres.

Wynikiem takiej operacji może być np. taka wartość:


Lub taka:


W przypadku licz całkowitych dużo trudniej przewidzieć wynik działania po przekroczeniu dopuszczalnego zakresu.

Aby maksymalnie zabezpieczyć się przed skutkami takiego działania można wprowadzić zabezpieczania jak te opisane dla licz nieujemnych. Należy także uwzględnić dolny zakres tych liczb. Dodatkowo zamiast typu int, warto stosować typy które będą miały odpowiednią wielkość w każdym systemie np. int16_t, int32_t. 

Kolejnym przydatnym elementem jest biblioteka limits.h, która zawiera maksymalne oraz minimalne wartości jakie mogą zostać przyjęte przez dany typ danych (INT_MAX, INT_MIN, INT16_MAX, INT16_MIN, INT32_MAX, INT32_MIN).

Trzeba także pamiętać, że jeśli korzystamy z podstawowego typu int, to on ma zdeklarowaną wielkość  16 bitów (minimum). Natomiast w zależności od systemu może ich być znacznie więcej np. 32 bity.