С-шные трюки от мыщъх'а (выпуск 10h)

Автор: (c)Крис Касперски ака мыщъх

Этот выпуск трюков в некотором смысле особенный, а особенный он потому, что юбилейный (в шестнадцатеричной нотации). Мыщъх долго готовился к такому знаменательному событию, отбирая самые вкусные трюки, но... В конце концов, трюков оказалось столько (и один вкуснее другого), что пришлось просто подкинуть монетку, выбрав четыре трюка наугад.

Трюк 1 - обход префикса "_"

Си-соглашение о передаче параметров (обычно обозначаемое как cdecl от "C Declaration"), которому подчиняются все Си-функции, если только их тип не специфицирован явно, заставляет компилятор помещать префикс "_" перед именем каждой функции, чтобы линкер мог определить, что он имеет дело именно с cdecl, а не, скажем, stdcall.

Поэтому категорически не рекомендуется использовать перед функциями знак подчеркивания, особенно при смешанном стиле программирования (то есть, когда cdecl-функции используются наряду с stdcall), в противном случае линкер может запутаться, вызвав совсем не ту функцию или выдать ошибку - дескать, нет такой функции и ничего линковать я не буду, хотя такая функция на самом деле есть. Обычно это случается при портиировании программы, написанной в одной среде разработке, под другие платформы.

Хорошо, а как быть, если текст программы уже кишит функциями с префиксами знака подчеркивания, что в частности любит делать Microsoft, отмечая таким образом нестандартные функции, отсутствующие в ANSI C. Переделывать программу, заменяя знаки подчеркивания на что-нибудь другое - себе обойдется дороже. Хорошо, если она вообще потом соберется, а если даже и соберется, нет гарантий, что не появится кучи ошибок в самых разных местах.

И вот тут на помощь нам приходит трюкачество. А именно - макросы. Допустим, мы имеем функцию _f() и хотим избавиться от знака подчеркивания. Как это мы делаем? Да очень просто:

#define _f() x_f()
x_f();

Листинг 1. Избавляемся от префиксов знака подчеркивания через макросы.

Фокус в том, что макросы "разворачиваются" препроцессором в Си-код, в котором зловредных префиксов уже не оказывается и риск развалить программу - минимален (однако не стоит забывать, что макросы вносят множество побочных эффектов и обращаться с ними следует крайне осторожно).

Трюк 2 - динамические массивы

Известно, что язык Си не поддерживает динамических массивов. Ну, не поддерживает и все тут. Хоть тресни. Хоть убейся о "Газель". Хоть грызи зубами лед. А динамические массивы все равно нужны. Функции семейства malloc не в счет, поскольку они выделяют именно блок памяти, а не массив, что совсем не одно и то же.

И вот на этот случай есть один хитрый древний трюк. Когда-то это был широко известный трюк, но потом позабытый, что очень странно, поскольку это не простой трюк, а очень даже нужный и важный. Короче, рассмотрим следующую структуру:

        struct string
        {
                int length; // длина строки
                char data [1]; // память, зарезервированная для строки
        };

Листинг 2. Структура, реализующая динамический массив.

Элемент "length" хранит длину строки, а "char data [1]" - это не сама строка (как это можно подумать поначалу), а место, зарезервированное под нее. Осталось только научиться, как с этой структурой обращаться.

Рассмотрим следующий фрагмент кода, реализующий настоящий динамический массив:

        // некая строка с динамическим массивом внутри
        string* p2 = ...
        ...

        // выделение памяти, необходимой для строки размеров p2->length
        // минус один заранее зарезервированный байт

        struct string s = malloc(sizeof(struct string) + p2->length - 1);

        // инициализация элемента структуры length
        s->length = p2->length;

        // копирование строки из p2 в s
        strncpy(s->data, p2->data, p2->length);
        ...
        // освобождение s
        free(s);
        ...

Листинг 3. Практический пример использования динамических массивов.

Ну, и в чем здесь прикол? А в том, что язык Си с его вольностями в трактовке типов позволяет нам выделить блок памяти произвольной длины и "натянуть" на него структуру string. При этом первые ячейки займет элемент length типа int, а остальные - данные строки, длина которой может и не совпадать с data[1]. Действуя таким образом, мы можем, например, имитировать PASCAL-строки (однако следует сказать, что в С++ данный трюк не работает, точнее работает, но дает непредсказуемый результат и потому применять его крайне опасно - это может позволить себе только опытный программист).

Трюк 3 - экономия памяти

Допустим, нам потребовалось выделить три локальные переменные типа char и еще один массив типа char[5]. Ну, потребовалось, ну что тут такого? Хорошо, тогда попробуйте ответить на вопрос: сколько байт мы при этом израсходовали? Голос из толпы: восемь! Всего восемь байт?! Это же за компилятор такой у вас, ась?! Берем MS VC (впрочем, с тем же успехом можно брать и любой другой) и компилируем следующий код:

foo()
{
        char a;
        char b;
        char c;
        char d[5];
}

Листинг 4. Функция с тремя переменными типа char и одной char[5].

Смотрим на откомпилированный код, дизассемблированный IDA Pro (крепко держась за стул):

.text:00000000 _foo      proc near
.text:00000000           push ebp
.text:00000001           mov  ebp, esp
.text:00000003           sub  esp, 14h
.text:00000006           mov  esp, ebp
.text:00000008           pop  ebp
.text:00000009           retn
.text:00000009 _foo      endp

Листинг 5. Откомпилированный результат листинга 4.

Откуда тут взялось 14h (20) байт локальной памяти?! Все очень просто. Компилятор в угоду производительности самопроизвольно выравнивает все переменные по границе двойного слова. Итого мы получаем 3 * max(1, 4) + max(5, 8) = 12 + 8 = 20. Вот они наши "оптимизированные" 20 байт вместо ожидаемых 5.

А что делать, если нам не нужна такая "оптимизация"?! Все просто - гоним переменные в структуру, предварительно отключив выравнивание соответствующей прагмой компилятора (в частности, у MS VC за это отвечает ключевое слово "#pragma pack( [ n] )", где n - желаемая кратность выравнивания, в данном случае равная единице, то есть выравнивание производится по границе одного байта или, говоря иными словами, не производится вовсе).

Переписанный код будет выглядеть приблизительно так:

#pragma pack( 1 )
struct bar
{
        char a;
        char b;
        char c;
        char d[5];
};

foo()
{
        struct bar baz;
}

Листинг 6. Оптимизированный вариант с отключенным выравниванием.

Смотрим на откомпилированный код, дизассемблированный все той же IDA Pro.

.text:00000000 _foo      proc near
.text:00000000           push ebp
.text:00000001           mov  ebp, esp
.text:00000003           sub  esp, 8
.text:00000006           mov  esp, ebp
.text:00000008           pop  ebp
.text:00000009           retn
.text:00000009 _foo      endp

Листинг 7. Дизассемблированный код с отключенным выравниванием.

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

Трюк 4 - загадка чистых виртуальных методов

В предыдущих выпусках этой рубрики мы не касались вопросов приплюснутого Си, но на случай юбилея сделаем исключение. Как известно, в любом учебнике по Си++ черным по белому написано, что невозможно создать экземпляр (instantiate) класса, имеющего чистый виртуальный метод (pure virtual method), при условии, что он никогда не вызывается. В этом, собственно говоря, и заключается суть концепции абстрактных классов.

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

class base
{
        public:
                base();
                virtual void f() = 0;
};

class derived : public base
{
        public:
                virtual void f() {}
};

void G(base& b){}

base::base() {G(*this);}

main()
{
        derived d;
}

Листинг 8. Трюковый код, создающий экземпляр объекта с чистой виртуальной функций, которая никогда не вызывается.

После компиляции (в данном случае использовался компилятор Microsoft Visual C++) мы увидим (см. листинг 7), что когда создается экземпляр d, то конструктор base::base будет вызывать функцию G, передавая ей в качестве указателя this указатель на base, но не на derived, что, собственно говоря, и требовалось доказать.

.text:00000005 public: __thiscall Base::Base(void) proc near
.text:00000005            ; CODE XREF: Derived::Derived(void)+Avp
.text:00000005
.text:00000005 var_4      = dword ptr -4
.text:00000005
.text:00000005            push    ebp
.text:00000006            mov    ebp, esp
.text:00000008            push    ecx
.text:00000009            mov    [ebp+var_4], ecx    ; this (base::base)
.text:0000000C            mov    eax, [ebp+var_4]
.text:0000000F            mov    dword ptr [eax], offset const Base::`vftable'
.text:00000015            mov    ecx, [ebp+var_4]
.text:00000018            push    ecx
.text:00000019            call    G(Base &)
.text:0000001E            add    esp, 4
.text:00000021            mov    eax, [ebp+var_4]
.text:00000024            mov    esp, ebp
.text:00000026            pop    ebp
.text:00000027            retn
.text:00000027 public: __thiscall Base::Base(void) endp

Листинг 9. Результат компиляции "трюкового" кода компилятором MS Visual C++ 6.0.