Dlaczego literały łańcuchowe C są tylko do odczytu?

29

Jakie zalety literałów łańcuchowych jako tylko do odczytu uzasadniają (-ies / -ied):

  1. To kolejny sposób na zastrzelenie się w stopę

    char *foo = "bar";
    foo[0] = 'd'; /* SEGFAULT */
    
  2. Niemożność eleganckiego zainicjowania tablicy słów do odczytu i zapisu w jednym wierszu:

    char *foo[] = { "bar", "baz", "running out of traditional placeholder names" };
    foo[1][2] = 'n'; /* SEGFAULT */ 
    
  3. Komplikowanie samego języka.

    char *foo = "bar";
    char var[] = "baz";
    some_func(foo); /* VERY DANGEROUS! */
    some_func(var); /* LESS DANGEROUS! */
    

Zapisujesz pamięć? Czytałem gdzieś (nie mogłem znaleźć źródła) już dawno temu, kiedy brakowało pamięci RAM, kompilatory próbowały zoptymalizować wykorzystanie pamięci, łącząc podobne ciągi.

Na przykład „more” i „regex” stają się „moregex”. Czy nadal tak jest w dzisiejszych czasach w erze cyfrowych filmów Blu-ray? Rozumiem, że systemy wbudowane nadal działają w środowisku o ograniczonych zasobach, ale ilość dostępnej pamięci dramatycznie wzrosła.

Problemy ze zgodnością? Zakładam, że starszy program, który próbowałby uzyskać dostęp do pamięci tylko do odczytu, zawiesiłby się lub kontynuował z nieodkrytym błędem. W związku z tym żaden starszy program nie powinien próbować uzyskać dostępu do literału łańcuchowego, a zatem zezwolenie na zapis do literału łańcuchowego nie zaszkodzi prawidłowym, niehackalnym, przenośnym starszym programom.

Czy są jakieś inne powody? Czy moje rozumowanie jest nieprawidłowe? Czy rozsądne byłoby rozważenie zmiany literałów ciągowych do odczytu i zapisu w nowych standardach C lub przynajmniej dodanie opcji do kompilatora? Czy rozważano to wcześniej, czy też moje „problemy” były zbyt małe i nieistotne, aby komuś przeszkadzać?

Marius Macijauskas
źródło
12
Zakładam, że sprawdziłeś, jak literały łańcuchowe wyglądają w skompilowanym kodzie ?
2
Spójrz na zespół, który zawiera link, który podałem. To jest tutaj.
8
Twój przykład „moregex” nie działałby z powodu zerowego zakończenia.
dan04,
4
Nie chcesz zapisywać stałych, ponieważ to zmieni ich wartość. Następnym razem, gdy będziesz chciał użyć tej samej stałej, będzie inaczej. Kompilator / środowisko wykonawcze musi skądś pozyskiwać stałe, a gdziekolwiek to nie powinno być dozwolone modyfikowanie.
Erik Eidt
1
„Czyli literały łańcuchowe są przechowywane w pamięci programu, a nie w pamięci RAM, a przepełnienie bufora spowodowałoby uszkodzenie samego programu?” Obraz programu również znajduje się w pamięci RAM. Mówiąc ściślej, literały ciągów są przechowywane w tym samym segmencie pamięci RAM, w którym przechowywany jest obraz programu. I tak, zastąpienie ciągu może uszkodzić program. W czasach MS-DOS i CP / M nie było ochrony pamięci, można było robić takie rzeczy i zwykle powodowało to straszne problemy. Pierwsze wirusy komputerowe użyłyby takich sztuczek do zmodyfikowania programu, aby sformatował dysk twardy podczas próby jego uruchomienia.
Charles E. Grant,

Odpowiedzi:

40

Historycznie (być może przez przepisanie jej części) było odwrotnie. Na pierwszych komputerach z początku lat 70. (być może PDP-11 ) z prototypowym embrionalnym C (być może BCPL ) nie było MMU ani ochrony pamięci (która istniała na większości starszych komputerów mainframe IBM / 360 ). Więc każdy bajt pamięci (w tym obsługi dosłowne ciągi lub kodu maszynowego) może być zastąpiony przez błędną programu (wyobrazić program zmieniający niektóre %się /w printf () 3 ciąg formatu). Stąd dosłowne ciągi znaków i stałe były zapisywalne.

Jako nastolatek w 1975 r. Kodowałem w paryskim muzeum Palais de la Découverte na starych komputerach z lat 60. bez ochrony pamięci: IBM / 1620 miał tylko pamięć rdzeniową - którą można zainicjować za pomocą klawiatury, więc trzeba było wpisać kilkadziesiąt cyfr do odczytu początkowego programu na perforowanych taśmach; CAB / 500 miał magnetyczną pamięć bębna; można wyłączyć zapisywanie niektórych ścieżek za pomocą przełączników mechanicznych w pobliżu bębna.

Później komputery otrzymały jakąś formę jednostki zarządzania pamięcią (MMU) z pewną ochroną pamięci. Było urządzenie zabraniające CPU nadpisywania jakiejś pamięci. Tak więc niektóre segmenty pamięci, w szczególności segment kodu (inaczej .textsegment) stały się tylko do odczytu (z wyjątkiem systemu operacyjnego, który załadował je z dysku). Kompilator i linker w naturalny sposób umieścili literalne ciągi w tym segmencie kodu, a ciągi literalne stały się tylko do odczytu. Gdy twój program próbował je zastąpić, było to złe, niezdefiniowane zachowanie . A posiadanie segmentu kodu tylko do odczytu w pamięci wirtualnej daje znaczącą przewagę: kilka procesów uruchomionych w tym samym programie ma tę samą pamięć RAM ( pamięć fizycznastron) dla tego segmentu kodu (zobacz MAP_SHAREDflagę dla mmap (2) w systemie Linux).

Obecnie tanie mikrokontrolery mają pamięć tylko do odczytu (np. Flash lub ROM) i przechowują tam swój kod (oraz dosłowne ciągi znaków i inne stałe). A prawdziwe mikroprocesory (takie jak ten na tablecie, laptopie lub komputerze stacjonarnym) mają wyrafinowaną jednostkę zarządzania pamięcią i mechanizm pamięci podręcznej wykorzystywany do wirtualnej pamięci i stronicowania . Tak więc segment kodu programu wykonywalnego (np. W ELF ) jest mapowany w pamięci jako segment tylko do odczytu, współdzielony i wykonywalny (przez mmap (2) lub execve (2) w Linuksie; BTW możesz podać dyrektywy dla ldaby uzyskać zapisywalny segment kodu, jeśli naprawdę tego chcesz). Pisanie lub nadużywanie jest ogólnie błędem segmentacji .

Tak więc standard C jest barokowy: prawnie (tylko z powodów historycznych) dosłowne ciągi znaków nie są const char[]tablicami, a jedynie char[]tablicami, których nadpisywanie jest zabronione.

BTW, kilka obecnych języków pozwala na zastąpienie literałów ciągów (nawet Ocaml, który historycznie - i źle - miał napisalne ciągi literalne, zmienił to zachowanie ostatnio w 4.02, a teraz ma ciągi tylko do odczytu).

Obecne kompilatory C są w stanie zoptymalizować i mieć "ions"i "expressions"współużytkować swoje ostatnie 5 bajtów (w tym kończący bajt zerowy).

Spróbuj skompilować kod C w pliku foo.cz gcc -O -fverbose-asm -S foo.ci zajrzeć do wnętrza wygenerowanego pliku asemblera foo.sprzez GCC

W końcu semantyka języka C jest wystarczająco złożona (czytaj więcej o CompCert i Frama-C, które próbują go uchwycić), a dodanie zapisywalnych ciągów literałów sprawi, że będzie jeszcze bardziej tajemny, a programy słabsze i jeszcze mniej bezpieczne (i przy mniej zdefiniowane zachowanie), więc jest bardzo mało prawdopodobne, aby przyszłe standardy C akceptowały zapisywalne ciągi literalne. Być może wręcz przeciwnie, const char[]stworzą z nich tablice takie, jakie powinny być moralnie.

Zauważ też, że z wielu powodów zmienne dane są trudniejsze do obsługi przez komputer (spójność pamięci podręcznej), do kodowania, do zrozumienia przez programistę, niż stałe dane. Dlatego lepiej, aby większość twoich danych (a zwłaszcza ciągów literalnych) pozostała niezmienna . Przeczytaj więcej o paradygmacie programowania funkcjonalnego .

W dawnych czasach Fortran77 na IBM / 7094 błąd mógł nawet zmienić stałą: jeśli ty CALL FOO(1)i jeśli FOOzdarzy ci się zmodyfikować jego argument przekazany przez odniesienie do 2, implementacja może zmienić inne wystąpienia 1 na 2, a to było naprawdę niegrzeczny robak, dość trudny do znalezienia.

Basile Starynkevitch
źródło
Czy to ma chronić ciągi jako stałe? Nawet jeśli nie są zdefiniowane jak constw standardzie ( stackoverflow.com/questions/2245664/... )?
Marius Macijauskas
Czy na pewno pierwsze komputery nie miały pamięci tylko do odczytu? Czy to nie było znacznie tańsze niż baran? Również umieszczenie ich w pamięci RO nie powoduje, że UB próbuje błędnie je zmodyfikować, ale polega na tym, że OP tego nie robi, a on narusza to zaufanie. Zobacz na przykład programy Fortran, w których wszystkie literały 1nagle zachowują się jak te 2i taka zabawa ...
Deduplicator
1
Jako nastolatek w muzeum kodowałem w 1975 roku na starych komputerach IBM / 1620 i CAB500. Żaden z nich nie miał pamięci ROM: IBM / 1620 miał pamięć rdzeniową, a CAB500 miał magnetyczny bęben (niektóre ścieżki można wyłączyć, aby można je było zapisać za pomocą przełącznika mechanicznego)
Basile Starynkevitch
2
Warto również zwrócić uwagę: Umieszczenie literałów w segmencie kodu oznacza, że ​​mogą one być współużytkowane przez wiele kopii programu, ponieważ inicjalizacja odbywa się w czasie kompilacji, a nie w czasie wykonywania.
Blrfl,
@Deduplicator Cóż, widziałem maszynę z uruchomionym wariantem BASIC, który pozwalał ci zmieniać stałe liczb całkowitych (nie jestem pewien, czy musiałeś to oszukać, np. Przekazywanie argumentów „byref” lub prosty let 2 = 3działał). Spowodowało to oczywiście dużo ZABAWY (w definicji słowa Dwarf Fortress). Nie mam pojęcia, jak zaprojektowano tłumacza, że ​​na to pozwala, ale tak było.
Luaan
2

Kompilatory nie mógł połączyć "more"i "regex", ponieważ pierwszy ma zerowy bajt po edrugi natomiast ma x, ale wiele kompilatorów łączyłby literały ciągów znaków, które idealnie dopasowane, a niektóre również dopasować literały ciągów że mieli wspólnego ogon. Kod, który zmienia literał łańcucha, może zatem zmienić inny literał łańcucha, który jest używany do zupełnie innych celów, ale zawiera te same znaki.

Podobny problem pojawiłby się w FORTRAN przed wynalezieniem C. Argumenty zawsze przekazywane były przez adres, a nie przez wartość. Procedura dodawania dwóch liczb byłaby zatem równoważna z:

float sum(float *f1, float *f2) { return *f1 + *f2; }

W przypadku, gdy chcemy przekazać stałą wartość (np. 4.0) sum, kompilator utworzy zmienną anonimową i zainicjuje ją 4.0. Jeśli ta sama wartość zostałaby przekazana do wielu funkcji, kompilator przekazałby ten sam adres do wszystkich z nich. W rezultacie, jeśli funkcja, która zmodyfikowała jeden z jej parametrów, otrzyma stałą zmiennoprzecinkową, wartość tej stałej w innym miejscu programu może zostać w wyniku tego zmieniona, co prowadzi do powiedzenia „Zmienne nie będą; stałe nie są „t”.

supercat
źródło