Przedefiniowanie NULL

118

Piszę kod C dla systemu, w którym adres 0x0000 jest prawidłowy i zawiera port I / O. Dlatego wszelkie możliwe błędy, które uzyskują dostęp do wskaźnika NULL, pozostaną niewykryte i jednocześnie spowodują niebezpieczne zachowanie.

Z tego powodu chcę przedefiniować NULL na inny adres, na przykład na adres, który jest nieprawidłowy. Jeśli przypadkowo uzyskam dostęp do takiego adresu, otrzymam przerwanie sprzętowe, w którym mogę obsłużyć błąd. Tak się składa, że ​​mam dostęp do stddef.h dla tego kompilatora, więc mogę faktycznie zmienić standardowy nagłówek i przedefiniować NULL.

Moje pytanie brzmi: czy będzie to sprzeczne ze standardem C? O ile wiem z 7.17 w standardzie, makro jest zdefiniowane w implementacji. Czy w normie jest coś, co mówi, że NULL musi wynosić 0?

Inną kwestią jest to, że wiele kompilatorów przeprowadza statyczną inicjalizację, ustawiając wszystko na zero, bez względu na typ danych. Mimo że standard mówi, że kompilator powinien ustawić liczby całkowite na zero, a wskaźniki na NULL. Gdybym przedefiniował NULL dla mojego kompilatora, to wiem, że taka statyczna inicjalizacja się nie powiedzie. Czy mogę to uznać za nieprawidłowe zachowanie kompilatora, mimo że odważnie ręcznie zmieniłem nagłówki kompilatora? Ponieważ wiem na pewno, że ten konkretny kompilator nie ma dostępu do makra NULL podczas wykonywania statycznej inicjalizacji.

Lundin
źródło
3
To naprawdę dobre pytanie. Nie mam dla ciebie odpowiedzi, ale muszę zapytać: czy na pewno nie jest możliwe przeniesienie prawidłowych danych na 0x00 i pozwolenie NULL na niepoprawny adres, jak w "normalnych" systemach? Jeśli nie możesz, jedynymi bezpiecznymi, nieprawidłowymi adresami, których możesz użyć, byłyby te, które możesz mieć pewność , że możesz je przydzielić, a następnie mprotectzabezpieczyć. Lub, jeśli platforma nie ma ASLR lub podobnego, adresy poza fizyczną pamięcią platformy. Powodzenia.
Borealid
8
Jak to będzie działać, jeśli Twój kod używa if(ptr) { /* do something on ptr*/ }? Czy zadziała, jeśli NULL jest zdefiniowane inaczej niż 0x0?
Xavier T.
3
Wskaźnik C nie ma wymuszonego związku z adresami pamięci. Dopóki przestrzegane są zasady arytmetyki wskaźników, wartość wskaźnika może być dowolna. Większość implementacji wybiera użycie adresów pamięci jako wartości wskaźnika, ale mogą używać wszystkiego, o ile jest to izomorfizm.
datenwolf
2
@bdonlan To również naruszyłoby (doradcze) zasady w MISRA-C.
Lundin
2
@Andreas Tak, to też moje myśli. Ludzie od sprzętu nie powinni mieć możliwości projektowania sprzętu, na którym powinno działać oprogramowanie! :)
Lundin

Odpowiedzi:

84

Standard C nie wymaga, aby wskaźniki zerowe znajdowały się pod adresem zerowym maszyny. JEDNAK rzutowanie 0stałej na wartość wskaźnika musi skutkować NULLwskaźnikiem (§6.3.2.3 / 3), a obliczanie wskaźnika pustego jako wartości logicznej musi być fałszywe. To może być nieco niewygodne, jeśli naprawdę nie chcesz adres zerowy, a NULLnie jest adresem zero.

Niemniej jednak, przy (ciężkich) modyfikacjach kompilatora i biblioteki standardowej, nie jest niemożliwe, aby NULLbyć reprezentowanym za pomocą alternatywnego wzoru bitowego, przy jednoczesnym zachowaniu ścisłej zgodności z biblioteką standardową. To nie wystarczy po prostu zmienić definicję NULLjednak sama, jak wtedy NULLbędzie oceniać true.

W szczególności musisz:

  • Rozmieść dosłowne zera w przypisaniach do wskaźników (lub rzutach na wskaźniki), które mają zostać zamienione na inną magiczną wartość, taką jak -1.
  • Rozmieść testy równości między wskaźnikami a stałą liczbą całkowitą, 0aby zamiast tego sprawdzić magiczną wartość (§6.5.9 / 6)
  • Rozmieść dla wszystkich kontekstów, w których typ wskaźnika jest oceniany jako wartość logiczna, aby sprawdzić równość z wartością magiczną zamiast sprawdzać zero. Wynika to z semantyki testowania równości, ale kompilator może wewnętrznie zaimplementować to inaczej. Patrz §6.5.13 / 3, §6.5.14 / 3, §6.5.15 / 4, §6.5.3.3 / 5, §6.8.4.1 / 2, §6.8.5 / 4
  • Jak wskazał caf, zaktualizuj semantykę inicjalizacji obiektów statycznych (§6.7.8 / 10) i częściowych inicjatorów złożonych (§6.7.8 / 21), aby odzwierciedlić nową reprezentację wskaźnika zerowego.
  • Utwórz alternatywny sposób dostępu do prawdziwego adresu zero.

Jest kilka rzeczy, którymi nie musisz się zajmować. Na przykład:

int x = 0;
void *p = (void*)x;

Po tym pNIE gwarantuje się, że wskaźnik będzie pusty. Obsługiwane są tylko stałe przypisania (jest to dobre podejście do dostępu do prawdziwego adresu zero). Również:

int x = 0;
assert(x == (void*)0); // CAN BE FALSE

Również:

void *p = NULL;
int x = (int)p;

xnie jest gwarantowane 0.

Krótko mówiąc, ten sam warunek był najwyraźniej rozważany przez komitet językowy C i rozważany dla tych, którzy wybraliby alternatywną reprezentację dla NULL. Wszystko, co musisz teraz zrobić, to wprowadzić duże zmiany w kompilatorze i hej, gotowe :)

Na marginesie, może być możliwe zaimplementowanie tych zmian na etapie transformacji kodu źródłowego przed właściwym kompilatorem. Oznacza to, że zamiast normalnego przepływu preprocesora -> kompilatora -> assemblera -> konsolidatora, można dodać preprocesor -> transformację NULL -> kompilator -> asembler -> linker. Następnie możesz wykonać transformacje, takie jak:

p = 0;
if (p) { ... }
/* becomes */
p = (void*)-1;
if ((void*)(p) != (void*)(-1)) { ... }

Wymagałoby to pełnego parsera C, a także parsera typów i analizy typów definicji typów i deklaracji zmiennych w celu określenia, które identyfikatory odpowiadają wskaźnikom. Jednak w ten sposób można uniknąć konieczności wprowadzania zmian w częściach generowania kodu przez kompilator. clang może być przydatny do realizacji tego - rozumiem, że został zaprojektowany z myślą o takich transformacjach. Oczywiście nadal prawdopodobnie będziesz musiał wprowadzić zmiany w bibliotece standardowej.

bdonlan
źródło
2
Ok, nie znalazłem tekstu w §6.3.2.3, ale podejrzewałem, że gdzieś będzie takie stwierdzenie :). Myślę, że to odpowiada na moje pytanie, standardowo nie mogę ponownie zdefiniować NULL, chyba że mam ochotę napisać nowy kompilator C, który będzie mnie
wspierał
2
Dobrą sztuczką jest włamanie się do kompilatora tak, aby pointer <-> konwertuje liczbę całkowitą XOR na określoną wartość, która jest nieprawidłowym wskaźnikiem i nadal jest na tyle trywialna, że ​​architektura docelowa może to zrobić tanio (zwykle byłaby to wartość z jednym ustawionym bitem , na przykład 0x20000000).
Simon Richter,
2
Inną rzeczą, którą musiałbyś zmienić w kompilatorze, jest inicjalizacja obiektów z typem złożonym - jeśli obiekt jest częściowo zainicjowany, to wszystkie wskaźniki, dla których nie ma jawnego inicjatora, muszą zostać zainicjowane NULL.
kawiarnia
20

Standard stwierdza, że ​​wyrażenie stałe będące liczbą całkowitą o wartości 0 lub takie wyrażenie przekonwertowane na void *typ jest stałą wskaźnika o wartości null. Oznacza to, że (void *)0zawsze jest wskaźnikiem NULL, ale biorąc pod uwagę int i = 0;, (void *)inie musi być.

Implementacja C składa się z kompilatora wraz z jego nagłówkami. Jeśli zmodyfikujesz nagłówki, aby przedefiniować NULL, ale nie zmodyfikujesz kompilatora w celu naprawienia statycznych inicjalizacji, oznacza to, że utworzyłeś niezgodną implementację. To cała implementacja razem wzięta ma niepoprawne zachowanie, a jeśli ją zepsujesz, naprawdę nie masz nikogo innego do zarzucenia;)

Musisz naprawić ponad Initialisations tylko statycznych, oczywiście - biorąc pod uwagę wskaźnik p, if (p)jest równoznaczne z if (p != NULL)powodu powyższej reguły.

kawiarnia
źródło
8

Jeśli używasz biblioteki std C, napotkasz problemy z funkcjami, które mogą zwracać NULL. Na przykład dokumentacja malloc stwierdza:

Jeśli funkcja nie może przydzielić żądanego bloku pamięci, zwracany jest pusty wskaźnik.

Ponieważ malloc i powiązane funkcje są już skompilowane do plików binarnych z określoną wartością NULL, jeśli przedefiniujesz NULL, nie będziesz mógł bezpośrednio używać biblioteki std C, chyba że będziesz mógł przebudować cały łańcuch narzędzi, w tym biblioteki std C.

Również ze względu na użycie wartości NULL przez bibliotekę std, jeśli ponownie zdefiniujesz NULL przed dołączeniem nagłówków std, możesz nadpisać definicję NULL wymienioną w nagłówkach. Cokolwiek wstawione byłoby niespójne w skompilowanych obiektach.

Zamiast tego zdefiniowałbym twój własny NULL, „MYPRODUCT_NULL”, do własnych celów i albo unikałbym, albo tłumaczył z / do biblioteki std C.

Doug T.
źródło
6

Pozostaw NULL w spokoju i traktuj IO do portu 0x0000 jako przypadek specjalny, być może używając procedury napisanej w asemblerze, a zatem nie podlegającej standardowej semantyce C. IOW, nie redefiniuj NULL, przedefiniuj port 0x00000.

Zwróć uwagę, że jeśli piszesz lub modyfikujesz kompilator C, praca wymagana do uniknięcia wyłuskiwania NULL (zakładając, że w twoim przypadku procesor nie pomaga) jest taka sama bez względu na to, jak zdefiniowano NULL, więc łatwiej jest pozostawić zdefiniowaną NULL jako zero i upewnij się, że zero nigdy nie zostanie wyłuskane z C.

Apalala
źródło
Problem pojawi się tylko w przypadku przypadkowego uzyskania dostępu do NULL, a nie w przypadku celowego uzyskania dostępu do portu. Dlaczego miałbym w takim razie przedefiniować port I / O? Już działa tak, jak powinno.
Lundin
2
@Lundin Przypadkowo lub nie, NULL może tylko być dereferencjonowane w programie C używając *p, p[]lub p(), więc tylko kompilator musi troszczyć się o tych, w celu ochrony portu IO 0x0000.
Apalala
@Lundin Druga część twojego pytania: Kiedy ograniczysz dostęp do adresu zero z poziomu C, potrzebujesz innego sposobu na dotarcie do portu 0x0000. Może to zrobić funkcja napisana w asemblerze. Z poziomu C port można zmapować na 0xFFFF lub cokolwiek innego, ale najlepiej użyć funkcji i zapomnieć o numerze portu.
Apalala
3

Biorąc pod uwagę ogromną trudność w redefiniowaniu NULL, o czym wspominali inni, być może łatwiej będzie przedefiniować dereferencje dla dobrze znanych adresów sprzętowych. Podczas tworzenia adresu dodaj 1 do każdego dobrze znanego adresu, aby Twój dobrze znany port IO wyglądał następująco:

  #define CREATE_HW_ADDR(x)(x+1)
  #define DEREFERENCE_HW_ADDR(x)(*(x-1))

  int* wellKnownIoPort = CREATE_HW_ADDR(0x00000000);

  printf("IoPortIs" DEREFERENCE_HW_ADDR(wellKnownIoPort));

Jeśli adresy, które Cię interesują, są zgrupowane razem i możesz czuć się bezpiecznie, że dodanie 1 do adresu nie będzie z niczym kolidować (co nie powinno w większości przypadków), możesz to zrobić bezpiecznie. A potem nie musisz się martwić o przebudowę łańcucha narzędzi / biblioteki standardowej i wyrażeń w postaci:

  if (pointer)
  {
     ...
  }

wciąż pracuję

Wiem, że to szalone, ale pomyślałem, że wyrzucę tam pomysł :).

Doug T.
źródło
Problem pojawi się tylko w przypadku przypadkowego uzyskania dostępu do NULL, a nie w przypadku celowego uzyskania dostępu do portu. Dlaczego miałbym w takim razie przedefiniować port I / O? Już działa tak, jak powinno.
Lundin
@LundIn Myślę, że musisz wybrać, co jest bardziej bolesne, poprawiając odbudowę całego zestawu narzędzi lub zmieniając tę ​​jedną część kodu.
Doug T.
2

Wzorzec bitowy dla wskaźnika zerowego może nie być taki sam jak wzorzec bitowy dla liczby całkowitej 0. Ale rozwinięcie makra NULL musi być stałą wskaźnika zerowego, to znaczy stałą liczbą całkowitą o wartości 0, która może być rzutowana do (void *).

Aby osiągnąć pożądany efekt, zachowując zgodność, będziesz musiał zmodyfikować (lub może skonfigurować) swój łańcuch narzędzi, ale jest to osiągalne.

AProgrammer
źródło
1

Prosisz o kłopoty. PrzedefiniowanieNULL do wartości innej niż null spowoduje przerwanie tego kodu:

   if (myPointer)
   {
      // myPointer nie jest null
      ...
   }
Tony the Pony
źródło