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.

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

    Немного о тестировании

    В процессе изучения разных подходов к тестированию программного обеспечения и многолетних попыток их практического применения, в голове сформировались некоторые мысли. Попытаюсь их сформулировать, пока не убежали... Вернее, это даже не мысли, а мои убеждения, основанные на результатах применения тех или иных подходов.
    1. Тестирование в проекте должно быть. Если тестирования нет — на проекте можно ставить крест. Еще лучше — даже не начинать такие проекты :)

    2. Разработчики должны сами тестировать свой код. Причем, тесты должны создаваться разработчиками до того, как они напишут свою первую строку кода ("test first" — этот подход описан в книге Кента Бека "Test Driven Development" и применяется в экстремальном программировании). Для чего:

      Во-первых, такой подход позволит создавать "чистый" код, то есть код, не содержащий ничего лишнего. Добавить в процессе кодирования пару-другую "кульных" фич, которые якобы обязательно будут использоваться — по-моему, этим болеют все разработчики. Пересилить себя и не делать этого очень трудно.

      Во-вторых, тестируя свой код, разработчики начнут задумываться о том, как они будут это делать. Иногда проблема невозможности тестирования доходит до крайности, когда нужно все выбрасывать и начинать заново. Разумеется, наличие толкового архитектора должно свести такие проблемы к минимуму, но опыт показывает, что разработчики, которым наплевать на тестирование, могут свести на нет все благие начинания. Да и толковых архитекторов не так уж легко найти... Как, впрочем, и разработчиков. Но это совсем другая история :)

      Третье, и самое главное достоинство этого подхода, — у разработчиков появится четкое понимание задачи, поставленной перед ними. Не редкость, когда разработчик приступает к реализации решения, не разобравшись толком, что же на самом деле требует ТЗ. Как результат — переделывание уже выполненной работы, иногда по нескольку раз, а значит, потеря драгоценного времени.

    3. Наличие в проекте команды тестеров. Это совсем необязательно на ранних этапах проекта, но, как говорится, "must be" уже со следующей итерации. Чем они должны заниматься: анализ существующих тестов на предмет актуальности и полноты (например, на основе статического анализа кода можно выяснить, что покрытие кода тестами не приближается и к 15%, даже несмотря на то, что разработчики старательно выписывают тесты, как написано в предыдущем пункте); создание новых сценариев и тестов, которые еще не были реализованы; вообще, стремиться всячески поставить разрабатываемую систему в такую позу, чтобы она (система) с перепугу начала делать что-нибудь не то, или выдала какой-нибудь непотребный Segmentation fault. Также эти ребята должны проводить и другие виды тестирования, например, динамическое, нагрузочное и стрессовое тестирование. При этом наличие тестеров предполагает, что разработчики будут продолжать создавать часть тестов самостоятельно.

    4. Тестирование должно быть автоматизированным! Как говорят военные, у продукта должно быть только две кнопки — "вкл." и "пуск ракеты". Этот очень логичный подход должен применяться и здесь. Существует множество фреймворков, чтобы облегчить реализацию этого пункта. Например, семейство xUnit — эффективные и простые в обращении продукты, к тому же совершенно бесплатные. Казалось бы, автоматизация тестов — вполне логичный подход, но, к сожалению, с ужасающей регулярностью приходится наблюдать тестирование "в живую". Это когда разработчик пишет, например, функцию сложения двух чисел и вместо того, чтобы запускать набор тестов, он каждый раз запускает свою программу, вводит с клавиатуры разные числа и смотрит, что же там его программулина наскладывала... Жуть!

    Фух! Ну вот, вроде бы удалось изложить мысли не сильно сумбурно :)

    10 мая 2006 г.

    2>&1

    Я долго пытался понять правила перенаправления потоков вывода в юниксовой оболочке bash. Действительно, вроде всё понятно, но...

    Например, было непонятно, почему работает команда, перенаправляющая stderr в stdout:
    ls *.txt *.err >file 2>&1

    Но при этом не работает вот так:
    ls *.txt *.err 2>&1 >file

    И уж совсем китайской грамотой казалась строка:
    cat file 3>&2 2>&1 1>&3

    Да, мне понятно, что эта команда меняет местами потоки вывода stderr и stdout. То, что выводилось в stdout, теперь будет выводиться в stderr и наоборот. Но почему именно так и никак иначе?..

    Всё оказалось не просто, а очень просто :) Спасибо замечательной книге "Unix Power Tools" издательства O'Reilly.

    Дело в том, что оболочка, при разборе параметров командной строки, читает параметры строго слева направо. Этот факт кажется совсем неудивительным, но оказывается именно это правило оказывает влияние на результат обработки командной строки.

    Разберем, как интерпретируется строка "ls *.txt *.err >file 2>&1":
    1. параметр ">file" — означает "перенаправить стандартный поток вывода (stdout) в файл c именем file";

    2. параметр "2>&1" — означает "перенаправить стандартный поток ошибок (2) в стандартный поток вывода (1). Но, так как стандартный поток вывода уже перенаправлен в файл, то и стандартный поток ошибок перенаправляется туда же.
    В результате оба потока будут перенаправлены в файл.

    А как интерпретируется строка "ls *.txt *.err 2>&1 >file"?
    1. параметр "2>&1" — означает "перенаправить стандартный поток ошибок в стандартный поток вывода". На этот момент поток вывода (1) направляется в терминал, а значит, что поток ошибок тоже перенаправится в терминал.

    2. параметр ">file" — означает "перенаправить стандартный поток вывода (stdout) в файл c именем file".
    Как результат — поток вывода будет направлен в файл, а поток ошибок — в терминал.

    После этого совсем не трудно понять, как работает команда "cat file 3>&2 2>&1 1>&3". Тут тоже все просто:
    1. поток вывода 3 перенаправить туда же, куда выводится поток вывода 2 — в stderr;

    2. поток вывода 2 перенаправить туда же, куда выводится поток 1 — в stdout;

    3. поток вывода 1 перенаправить туда же, куда выводится поток 3 — в stderr
    В результате, потоки вывода stderr и stdout как будто меняются местами — поток 1 идет в stderr, а поток 2 идет в stdout.

    25 февраля 2006 г.

    Приоритеты операций в Си

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

    Операции с одинаковым приоритетом выполняются слева направо в порядке следования.

    ПриоритетОператорОписание
    1++Префиксный инкремент
    --Префиксный декремент
    ()Вызов функции или подвыражение
    []Выделение элемента массива
    ->Указатель структуры
    .Член структуры
    2!Логическое отрицание
    ~Поразрядное логическое НЕ (двоичная инверсия)
    -Унарный минус (изменение знака)
    +Унарный плюс
    (type)Преобразование к типу
    *Разыменование указателя
    &Определение адреса переменной
    sizeofОпределение размера в байтах
    3*Умножение
    /Деление
    %Остаток от деления
    4+Сложение
    -Вычитание
    5>>Поразрядный сдвиг вправо
    <<Поразрядный сдвиг влево
    6<Меньше
    >Больше
    <=Меньше или равно
    >=Больше или равно
    7==Равно
    !=Не равно
    8&Поразрядное логическое И
    9^Поразрядное исключающее ИЛИ
    10|Поразрядное логическое ИЛИ
    11&&Логическое И
    12||Логическое ИЛИ
    13?:Оператор условия
    14=Присваивание
    +=Составное сложение
    -=Составное вычитание
    *=Составное умножение
    /=Составное деление
    %=Составное определение остатка от деления
    >>=Составной поразрядный сдвиг вправо
    <<=Составной поразрядный сдвиг влево
    &=Составное поразрядное логическое И
    ^=Составное поразрядное исключающее ИЛИ
    |=Составное поразрядное логическое ИЛИ
    15,Операция "запятая"
    ++Постфиксный инкремент
    --Постфиксный декремент