22 сентября 2006 г.

В поисках истины

Недавно, при создании небольшой утилитки, столкнулся с проблемой инициализации указателей на массивы char. Точнее, не с самой инициализацией, а последующим использованием такого указателя. Дело было под Linux Slackware, компилятором выступал gcc.

Проблема выглядела примерно так:

#include <stdlib.h>

void myfunc (char *p)
{
  *p = 'X';
}

int main ()
{
  char *str0 = "Hello";
  char str1[] = "Hello2";

  printf("str1 => %s\n", str1);
  myfunc(str1);
  printf("str1 => %s\n", str1);

  printf("str0 => %s\n", str0);
  myfunc(str0);
  printf("str0 => %s\n", str0);

  return 0;
}


Если откомпилировать этот код и попытаться выполнить, то результат будет плачевным:

> gcc a.c
> ./a.out
str1 => Hello2
str1 => Xello2
str0 => Hello
Segmentation fault

Хм, странно... Что-то не помню я, чтобы такая проблема у меня раньше возникала. А в чем же причина?

Похоже, что проблема в разнице объявления и инициализации переменных str0 и str1. Первое, что пришло в голову, — посмотреть, во что превращается СИшный код на этапе ассемблирования:

> gcc -S a.c
> cat a.s

Смотрим, что там получилось. Ага, вот оно:

...
LC0:
        .ascii "Hello\0"
LC1:
        .ascii "Hello2\0"
...
        movl    $LC0, -12(%ebp)
        movl    LC1, %eax
        movl    %eax, -40(%ebp)
        movzwl  LC1+4, %eax
        movw    %ax, -36(%ebp)
        movzbl  LC1+6, %eax
        movb    %al, -34(%ebp)
...

При компиляции место для обеих строк резервируется в секции .rdata. Но при инициализации указателя str0 в память стека просто записывается адрес размещения строки "Hello" (LC0), а при инициализации str1 в стеке резервируется нужное количество байт и туда полностью копируется содержимое второй строки (LC1). Когда мы пытаемся изменить вторую строку (которая скопирована в стек), то она без проблем меняется. А при попытке изменить первую — мы лезем в сегмент данных. Вот тут-то нас и поджидает Seqmentation fault. Неплохо...

Немного странно, что выражение char *str0 = "..." в результате дает константную строку. Ну, да ладно.

Интересно, что Seqmentation fault, как правило, возникает в системах с защитой памяти. А ну-ка, проверим, что там у нас в других системах: Solaris, gcc — то же самое; Windows, MSYS, gcc — то же... Ну, что ж, все понятно. Ура.

Стоп. Раз уж я полез в другие системы, может быть и компилятор другой попробовать. Что у нас там в Windows есть? Компилятор cl.exe от Microsoft:

> cl.exe a.c
> a.exe
str1 => Hello2
str1 => Xello2
str0 => Hello
str0 => Xello

Ой... :-/ Это что такое? А где же "unexpected exception" или "memory access denied"?.. Неужели cl генерирует принципиально другой код? Ну-ка, cl, покажи, что там у тебя в ассемблере:

...
$SG829  DB 'Hello', 00H
        ORG $+2
$SG831  DB 'Hello2', 00H
        ORG $+1
...
        mov DWORD PTR _str0$[ebp], OFFSET FLAT:$SG829
        mov eax, DWORD PTR $SG831
        mov DWORD PTR _str1$[ebp], eax
        mov cx, WORD PTR $SG831+4
        mov WORD PTR _str1$[ebp+4], cx
        mov dl, BYTE PTR $SG831+6
        mov BYTE PTR _str1$[ebp+6], dl
...

Странно, но все то же самое — для первой строки в стек загоняется только указатель, а вторая полностью копируется в стек. Получается, что система тут не при чем? Все дело в компиляторе?

Пришлось лезть в MSDN и читать документацию. После недолгих поисков был найден некий ключ /GF, который, помимо прочего, заставляет компилятор размещать строковые константы в read-only memory. "If you try to modify strings under /GF, an application error occurs." Да, это обеспечит защиту строковых "констант" от модификации, но только если указать нужный ключ компилятора.

А что же gcc? Внимательно просмотрев документацию, обнаружил раздел "Incompatibilities of GCC", первый пункт которого гласил: "GCC normally makes string constants read-only." Вот так. То есть и gcc и cl могут сгенерировать код с одинаковым поведением, но первый делает это по умолчанию, а второй — только, если указать нужную опцию.

Вот тут-то и пришла в голову идея заглянуть, наконец-таки, в стандарт языка Си. Параграф 6.7.8 стандарта гласит:
"...the declaration char *p = "abc"; defines p with type 'pointer to char' and initializes it to point to an object with type 'array of char' with length 4 whose elements are initialized with a character string literal. If an attempt is made to use p to modify the contents of the array, the behavior is undefined."

Эээ... И что?

Читаем внимательней — "behavior is undefined". Именно эти три слова являются причиной такого поведения моего кода под разными компиляторами. "Поведение неопределено" — этого должно быть вполне достаточно для нормальных людей, чтобы не использовать конструкции вида char *p = "abc".

Мда... Я зачем-то провел "масштабное исследование" проблемы, которое все равно меня привело туда, куда я должен был пойти в самом начале. К Стандарту. Стыдно? Да.

В чем мораль этого рассказа? Follow the standards.

16 сентября 2006 г.

Задачи на собеседованиях: строки и указатели

На собеседованиях частенько задают задачи на знание Си-строк и указателей.

  • Первый вариант. Нужно написать программу копирования строк без использования strcpy, strlen и прочих им подобным. Входные данные - только указатель на строку, оканчивающуюся нулем, и указатель на destination область.

  • Второй вариант: "развернуть" строку при копировании. То есть, если у нас есть "abcdef", то после выполнения программы мы должны получить "fedcba". В качестве входных данных - указатель на строку и можно воспользоваться strlen.

  • Третий вариант: "развернуть" строку на месте, не пользуясь дополнительными блоками памяти. Входные данные - как в варианте 2.

    Подобные задачи советует задавать на собеседованиях Джоэль Спольски. Какими бы простыми не казались эти задачи, именно на них отсеиваются большинство кандидатов.