terminal-s52

Объявление

Администрация форума не несет никакой ответственности за действия пользователей! Уважаемые пользователи всю информацию и софт вы используете на свой страх и риск! Приятного пребывания на форуме!

Информация о пользователе

Привет, Гость! Войдите или зарегистрируйтесь.


Вы здесь » terminal-s52 » Кодинг » Трюки от Криса Касперски!


Трюки от Криса Касперски!

Сообщений 1 страница 10 из 10

1

Статья №1
Как известно, денно и нощно Крыс сидит в своей норе и точит программное обеспечение. Поточит-поточит да и напишет статейку. Для чего же он это делает? А все для того, чтобы ты, :playful:  дорогой юзер терминала :playful: , не крутился как белка в колесе и не наступал на грабли, на которые мыщъх уже успел наступить.

unions vs нецензурный кастинг

Типизация, призванная оградить программиста от совершения ошибок, хорошо работает лишь на бумаге, а в реальной жизни порождает множество проблем (особенно при низкоуровневом разборе байтов), решаемых с помощью явного преобразования типов или, другим словами, «кастинга» (от английского «casting»), например, так:

int *p; char x;

x = *(((char*)p)+3); // получить байт, лежащий по смещению 3 от ячейки *p

Типизация была серьезно ужесточена в приплюснутом Си, вследствие чего количество операций явного преобразования резко возросло, захламляя листинг и культивируя порочный стиль программирования.

Рассмотрим следующую ситуацию:

Жесткая типизация приплюснутого Си трактует попытку передачи void* вместо char* как ошибку

f00(char *x); // функция, ожидающая указателя на char

void* bar(); // функция, возвращающая обобщенный указатель void

f00(bar()); // ошибка! Указатель на char не равнозначен указателю void*

Здесь функция f00 принимает указатель на char, а функция bar возвращает обобщенный указатель void*, который мы должны передать функции f00, но… мы не можем этого сделать!

Компилятор, сообщив об ошибке приведения типов, остановит трансляцию. Что здесь плохого? А то, что программиста вырабатывается устойчивый рефлекс преобразовывать типы всякий раз, когда их не может проглотить компилятор, совершенно не обращая внимания на их «совместимость», в результате чего константы сплошь и рядом преобразуются в указатели, а указатели — в константы со всеми вытекающими отсюда последствиями. Но по-другому программировать просто не получается! Различные функции различных библиотек по-разному объявляют физически идентичные типы переменных, так что от преобразования никуда не уйти, а ограничиться одной конкретной библиотекой все равно не получится. Платформа .NET выглядит обнадеживающей, но… похожая идея (объять необъятное) уже предпринималась не раз и не два и всякий раз заканчивалась если не провалом, то разводом и девичьей фамилией. Взять хотя бы MFC… и попытаться прикрутить ее к чему-нибудь еще, например, к API-функциям операционной системы. Преобразований там будет…

Но частые преобразования очень напрягают, особенно если их приходится выполнять над одним и тем же набором переменных. В этом случае можно (и нужно) использовать объединения, объявляемые ключевым словом «union» и позволяющие «легализовать» операции между разнотипными переменными.
С использованием объединений наш код будет выглядеть так:
Использование объединений в Си для избавления от явного преобразования типов

union pint2char /* декларация объединения */

{

int *pi; // указатель на int

char *pb; // указатель на char

} ppp;

int *p; char x; // объявление остальных переменных

ppp.pi = p; x = *(ppp.pb+3); // элегантный уход от кастинга

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

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

Использование анонимных объединений в приплюснутом Си избавляет нас от кастинга, но делает логику работы кода менее очевидной

union /* декларация анонимного объединения */

{

void *VOID; // обобщенный указатель void*

char *CHAR; // указатель на char

};

VOID = bar(); f00(CHAR); // уход от кастинга

Анонимные объединения элегантно избавляют нас от кастинга, но, в то же самое время, затрудняют чтение листинга, поскольку из конструкции «VOID = bar(); f00(CHAR);» совершенно не очевидно, что функции f00 передается значение, возращенное bar. Не видя объединения, можно подумать, что VOID и CHAR - это две разные переменные, когда, на самом деле, это одна физическая ячейка памяти.

В общем, получается замкнутый круг, выхода из которого нет…

Сравнение структур

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

Чем мотивирован запрет на побайтовое сравнение структур? А тем, что компиляторы по умолчанию выравнивают элементы структуры по кратным адресам, обеспечивая минимальное время доступа к данным. Величина выравнивания зависит от конкретной платформы, и если она отлична от единицы (как это обычно и бывает), между соседними элементами могут образовываться «дыры», содержимое которых не определено. Вот эти самые «дыры» и делают побайтовое сравнение ненадежным.

На самом деле, сравнивать структуры все-таки можно. Имеется как минимум два пути решения этой проблемы. Во-первых, выравнивание можно отключить соответствующей прагмой компилятора или ключом командной строки. Тогда «дыры» исчезнут, но… вместе с ними исчезнет и скорость (во всяком случае, потенциально). Падение производительности иногда может быть очень значительным (а некоторые процессоры при обращении к невыравненным данным и вовсе генерируют исключение), и, хотя правильной группировкой членов структуры его можно избежать, это не лучшее решение.

Исследование «дыр» (и логики компиляции) показывает, что их содержимое легко сделать определенным. Достаточно перед объявлением структуры (или сразу же после объявления) проинициализировать принадлежащую ей область памяти, забив ее нулями, и… это все! Компилятор никогда не изменяет значение «дыр» между элементами структуры, и даже если структура передается по значению, она копируется вся целиком, вместе со всеми «дырами», которые только у нее есть. Следовательно, побайтовое сравнение структур абсолютно надежно. Главное, не забывать об инициализации «дыр», которая в общем случае делается так:

«Обнуление» области памяти, занятой структурой, дает зеленый свет операции побайтового сравнения

struct my_struct /* декларация произвольной структуры */

{

int a;

char b;

int c;

};

struct my_struct XX; // объявление структуры XX («дыры» заполнены мусором)

struct my_struct XY; // объявление структуры XY («дыры» заполнены мусором)

memset(&XX, 0, sizeof(XX)); // инициализируем область памяти структуры XX

memset(&XY, 0, sizeof(XY)); // инициализируем область памяти структуры XY

// что-то делаем со структурами

if (!memcmp(&XX, &XY, sizeof(XX))

/* структуры идентичны */

else

/* структуры _не_ идентичны */

strncpy vs strcpy

В борьбе с переполняющимися буферами программисты перелопачивают тонны исходного кода на погонный метр, заменяя все потенциально опасные функции их безопасными аналогами с суффиксом n, позволяющим задать предельный размер обрабатываемой строки или блока памяти.

Часто подобная замена делается чисто механически, без учета специфики n-функций, и не только не устраняет ошибки, но даже увеличивает их число. Вероятно, самым популярным ляпом является смена strcpy на strncpy.
Рассмотрим код вида:

Потенциально опасный код, подверженный переполнению

f00(char *s)

{

char buf[BUF_SIZE];

strcpy(buf,s);

}

Если длина строки s превысит размер буфера buf, произойдет переполнение, результатом которого зачастую становится полная капитуляция компьютера перед злоумышленником, чего допускать ни в коем случае нельзя, и в благородном порыве гражданского долга многие переписывают потенциально опасный код так:

Исправленный, но по-прежнему потенциально опасный вариант того же самого кода

f00(char *s)

{

char buf[BUF_SIZE];

strncpy(buf,s, BUF_SIZE);

}

Или так:
Еще один потенциально опасный вариант

f00(char *s)

{

char buf[BUF_SIZE];

strncpy(buf,s, BUF_SIZE-1);

}

Хе-хе. Если размер строки s превысит значение BUF_SIZE (или BUF_SIZE-1), функция strncpy прервет копирование, забыв поставить завершающий ноль. Причем об этом будет очень трудно узнать, поскольку сообщение об ошибке при этом не возвращается, а попытка определить фактическую длину скопированной строки через strlen(buf) ни к чему хорошему не приводит, поскольку в отсутствии завершающего нуля в лучшем случае мы получаем неверный размер, в худшем — исключение.

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

Не подверженный переполнению, но по-прежнему неправильно работающий код

f00(char *s)

{

char buf[BUF_SIZE];

buf[BUF_SIZE-1] = 0;

strncpy(buf,s, BUF_SIZE-1);

}

Такой код вполне безопасен в плане переполнения, однако, порочен и ненадежен, поскольку маскирует факт обрезания строки, что приводит к непредсказуемой работе программы. Вот только один пример. Допустим, в переменной s передается путь к каталогу для удаления его содержимого. Допустим так же, что, в силу каких-то обстоятельств, длина пути превысит BUF_SIZE и он окажется усечен. Если усечение произойдет на границе «\», то удаленным окажется совсем другой каталог, причем более высокого уровня!

Самый простой и единственно правильный вариант выглядит так, как показано в листинге, приведенном ниже. А функция strncpy, кстати говоря, изначально задумывалась для копирования неASCIIZ-строк, то есть строк, не содержащих символа завершающего нуля, и это совсем не аналог strcpy! Эти две функции не взаимозаменяемы!

Безопасный и правильно работающий вариант

f00(char *s)

{

char buf[BUF_SIZE];

if (strlen(s)>=BUF_SIZE) return ERROR; else strcpy(buf,s);

}
The End!

0

2

Статья №2
Программирование — это своего рода дзен, это поединок кода с мыслью. А поединок - это уже кун-фу. А кун-фу - это когда сначала ты не знаешь, что нельзя делать то-то; потом знаешь, что нельзя делать то-то; позже понимаешь, что иногда-таки можно делать то-то; ну а далее ты осознаешь, что, помимо того-то, существует еще 69 способов добиться желаемого и все они практически равноправны. Но когда тебя спрашивают: «Как мне добиться желаемого?», ты быстро перебираешь в уме эти 69 способов, прикидываешь то общее, что в них есть, вздыхаешь и говоришь: «Вообще-то, главное – гармония». А на вопрос обиженных учеников: «А как ее добиться?», ты отвечаешь: «Никогда не делайте то-то».

Проверка выделения памяти — ошибка или гениальная задумка

Та же истина, только в чуть-чуть более длинной интерпретации: есть несколько шагов обучения воинскому рукопашному искусству:

   1. Новичка обучают небольшому количеству приемов. В каждой ситуацию ему говорят: делай так-то. И новичок оттачивает эти приемы до филигранной точности.
   2. Ученику показывают, как известные ему приемы можно объединять, переходить от одной технике к другой и получать новые приемы.
   3. Мастер о следовании конкретным приемам не заботится. Он в каждый момент анализирует ситуацию, видит множество путей ее решения (и все они правильные) и сосредотачивает все свои усилия на достижении выбранной цели.
   4. Новичок, глядя на мастера, не может взять в толк, почему тот не использует ни один из известных приемов.

Применительно к программированию это означает, что грань между ошибкой и задумкой настолько тонка, что порой совсем не заметна. Возьмем классическую ситуацию с проверкой успешности выделения памяти. Можно ли считать следующий код правильным (отсутствие проверки на валидность указателя *s не принимается в расчет)?

zen(char *s)

{

char *p = malloc(strlen(s)+1));

strcpy(p,s);

free(p);

return 0;

}

«Да здесь же грубейшая ошибка! — скажет начинающий. — Где гарантии, что malloc выделит память?!» И по-своему он будет прав, ведь такой гарантии у нас нет, и более опытные товарищи явно посоветуют воткнуть «if». И они тоже по-своему будут правы. Но только изначальный вариант окажется самым оптимальным среди всех возможных решений.

Программирование — это, прежде всего, учет рисков. Есть риск, что память не будет выделена, и эту ситуацию надо предусмотреть и обработать заранее. Но как мы ее можем обработать? И в каких ситуациях malloc может не выделить память? Чем нам реально поможет дополнительный «if»?! Если памяти нет, то нет никаких гарантий, что удастся сделать хоть что-то, даже вывести примитивный диалог, не говоря уже о том, чтобы корректно завершить работу, сохранив все данные.

Самое главное, что операционная система совместно с процессором отслеживает попытки обращения к нулевому указателю (а точнее, к первым 64 Кб адресного пространства), возбуждая исключение, которое мы можем поймать и обработать. При отсутствии обработчика на экране появляется знаменитый диалог с сообщением о критической ошибке. Но это лучше, чем «if (!p) return ERROR;», поскольку если вызывающая функция забудет о проверке на ERROR, программа продолжит свою работу, но вряд ли эта работа будет стабильной. Последуют глюки или падения в весьма отдаленных от функции zen местах, и, даже имея на руках дамп памяти (или отчет «Доктора Ватсона»), можно угробить кучу времени на выяснение истинной причины аварии.

Это вовсе не призыв к отказу от проверок на корректность выделения памяти. Это просто констатация факта, что если память закончилась, то ситуация опаньки и проверка ее не исправляет, а только усугубляет. Если мы действительно хотим принять такой фактор риска во внимание, необходимо предусмотреть обработку ситуации (выделение памяти из стека или секции данных вместо кучи, резервирование памяти как НЗ на ранних стадиях запуска программы, освобождение ненужной памяти и т.д.). Но такой обработчик являет собой инженерное сооружение сложное, но малоэффективное в случаях с «утеканием» памяти в соседней программе. Да, в своих функциях, мы можем использовать и стек вместо кучи, и зарезервированную память, но системным и библиотечным процедурам этого не объяснишь, и, даже освободив все ненужное, мы не застрахованы, что соседняя программа его не съест.

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

Еще один миф — проверка корректности указателей

Должна ли функция проверять корректность переданных ей аргументов? Кое-кто скажет: «Должна». Это зависит от спецификаций. В некоторых случаях все проверки можно переложить на материнскую функцию, в некоторых  нет. В частности, все ядерные native-API-функции и драйверы крайне осторожно относятся к передаче аргументов из прикладного адресного пространства, совершая множество телодвижений. Иначе и быть не может! В противном случае, передав некорректный указатель, пользователь мог бы нанести ядру серьезные увечья, что недопустимо!

А вот в рамках пользовательского пространства проверка аргументов материнской функцией политически более корректна, поскольку возможности дочерней функции в концептуальном плане весьма невелики. Если материнская функция при определенных обстоятельствах может передать невалидный указатель, то с таким же успехом она может проигнорировать ошибочный код завершения дочерней функции! Причем валидный и ненулевой указатель - это совсем не одно и тоже! Да, мы можем легко выявить нулевой указатель, но только толку с того… Указатель может и не равняться нулю, но указывать на недоступную область памяти. Обычно это происходит, когда нулевое значение, возращенное malloc, складывается с некоторым индексом и передается дочерней функции.

Если индекс меньше 10000h, то операционная система отловит такую ситуацию и выбросит исключение, а если нет? Вообще-то, существует целый легион API-функций типа IsBadReadPtr, IsBadWritePtr, IsBadStringPtr, позволяющих проверить, если у нас права доступа к данной ячейке (ячейкам) памяти или нет. Некоторые программисты их старательно используют и пихают во все функции, забывая о том, что, во-первых, исключение останавливает программу, явно сигнализируя об ошибке, а обработка ошибки в стиле «if (IsBadStringPtr(s)) return ERROR» ее подавляет, постулируя, что материнская функция знает, что делать; во-вторых,  указатель может принадлежать чужой области памяти, наличие прав доступа к которой ничуть не смягчает последствия их реализации.

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

Освобождать или нет?

Каждому malloc должен соответствовать свой free. Это правило номер один, которое каждый новичок должен знать назубок и несоблюдение которого приводит к утечкам памяти. Однако освобождать память бывает не всегда удобно, а порой даже очень затруднительно. А что если… не освобождать! При всей внешней бредовости это весьма здравая идея. Действительно, частые выделения/освобождения памяти - это тормоза и рост фрагментации кучи. Лучше выделять память с запасом, используя ненужные блоки повторно без их освобождения. Это раз.

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

Заключение

Прежде чем воспользоваться приведенными здесь советами, перечитай азы кун-фу еще раз. Чтобы отступать от правил, сначала нужно научиться их соблюдать! И каждое отступление должно иметь под собой твердое основание и мотивацию. Отмазка в стиле: «Я не освобождаю память, не проверяю валидности указателей, потому что Настоящие Мастера этого не выполняют», совершенно не катит, потому что Мастера просчитывают те ситуации, возможность которых не ведома новичку. Но даже у Мастеров доминирует мотивация: «Рискнем, авось пронесет!».
The End!

0

3

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

Дерево состоит из узлов, каждый из которых в языках Си/Си++ традиционно определяется так:

struct my_tree

{

struct my_tree *left_nest; // ссылка на левый узел

struct my_tree *right_nest; // ссылка на правый узел

int leaf; // элемент дерева

}

Память под узлы выделяется либо функцией malloc (в языке Си), либо оператором new (в Си++). Это делают все, или практически все, совершенно не задумываясь о тех накладных расходах, которыми их облагает менеджер кучи. Выделение крошечных блоков памяти совершенно неэффективно! Кроме того, зачастую соседние узлы оказываются в различных физических страницах памяти, в результате чего операции с деревом существенно замедляются, производительность падает в разы, а при хронической нехватке оперативной памяти винчестер трещит как бешенный!

К тому же такое дерево существует только в памяти и не может быть сохранено на диск в двоичном виде. То есть сохранить-то его можно, вот только при последующем считывании с диска ранее выделенные указатели будут указывать в космос со всеми вытекающими отсюда последствиями, и перед сохранением дерева все указатели в обязательном порядке должны быть преобразованы в индексы. А при считывании дерева его приходится реконструировать вновь, что совсем не добавляет производительности.

Выход — хранить дерево в массиве, используя индексы вместо указателей. В результате мы получим следующее:
Структура двоичного дерева, подготовленная к хранению в массиве

struct my_good_tree

{

unsigned int left_nest; // ссылка на левый узел

unsigned int right_nest; // ссылка на правый узел

int leaf; // элемент дерева

};

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

Единственный минус этого решения заключается в том, что память под древесный массив необходимо выделять заранее, и если этой памяти вдруг окажется недостаточно, придется заново выделить блок, что влечет за собой ощутимые накладные расходы. С другой стороны, операционные системы семейства Windows/UNIX позволяют не выделять, а лишь резервировать память в адресном пространстве, поэтому размер массива можно (и нужно!) брать с большим запасом.
Обход двоичного дерева

Операция обхода двоичного дерева (то есть прохождения по каждому из его узлов) не самая тривиальная задача, решение которой выливается в десятки строк кода, а их еще отладить надо! Рекурсивные алгоритмы тормозят и требуют очень много стековой памяти, не рекурсивные — намного более сложные в реализации.

Но стоп! Если дерево хранится в массиве, то его обход осуществляется простым перебором элементов массива один за другим, свободно укладывается всего в две (!) строки на Си (за исключением операций объявления и инициализации) и работает с ошеломляющей скоростью, особенно на процессорах, использующих предвыборку. К тому же он легко масштабируется, что на HT- и многоядерных процессорах совсем немаловажно:
Алгоритм обхода двоичного дерева, хранящегося в массиве

// объявление

int a; struct my_good_tree tree_array[MAX_TREE_SIZE];

// инициализация

memset(tree_array, 0xDEADBEEF, sizeof(tree_array));
Удаление узлов двоичного дерева

for(a = 0;a < MAX_TREE_SIZE; a++)

if (tree_array[a].leaf!=0xDEADBEEF)printf("x\n",tree_array[a].leaf);else break;

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

А что если… не удалять элементы, а только помечать их как удаленные? Для этого в структуру дерева будет достаточно добавить всего лишь одно поле - «deleted». Что это дает? Во-первых, перестраивать структуру дерева при удалении более не придется. Во-вторых, при операциях поиска существует вероятность натолкнуться на удаленный элемент раньше, чем достичь конца дерева, следовательно, средняя скорость поиска несколько возрастает. В-третьих, повторное добавление ранее удаленного элемента решается простым сбросом флага «deleted».

Естественно, при накоплении большого количества удаленных элементов, эффективность использования двоичного дерева будет неуклонно уменьшаться, но эту проблему легко решить перестройкой дерева, то есть реальным удалением элементов, помеченных как удаленные! Кстати, неплохая идея — завести счетчик удалений/добавлений каждого из элементов и при перестройке дерева удалять только непопулярные элементы.
Балансировка или скремблирование?

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

Выход? Любой преподаватель скажет: использовать сбалансированные деревья, которые ты, наверняка, изучал в университете. Готовых библиотек куча, вот только реализовать сбалансированное дерево намного сложнее нормального, да и всех проблем оно не решает. На самом же деле…

Эффективное использование обычных деревьев возможно в случае гарантированного поступления на их вход случайных данных, чего легко добиться скремблированием, то есть наложением на входной поток псевдослучайной последовательности данных, сгенерированной, например, функций rand(). Естественно, при извлечении элементов из дерева операцию скремблирования необходимо развернуть на 180%. И хотя существует возможность, что даже после скремблирования поступающие на вход дерева данные сохранят некоторую упорядоченность, отказываться от этой идеи, не обкурив ее, не стоит!
Случайные пермутации дерева

Если дерево хранится в виде массива и если мы видим, что оно приобретает несбалансированную структуру, перекашиваясь либо в одну, либо в другую сторону, мы можем просто, да-да, просто переставить элементы дерева в случайном порядке. Таким образом, задача балансировки дерева сводится к алгоритму тасовки колоды карт, которых придумано очень много. Конечно, операции переупорядочивания снижают производительность, проигрывая сбалансированным деревьям, но… сбалансированные деревья оправдывают себя только при обработке очень больших объемов информации, в противном случае обычное двоичное дерево, хранимое в массиве и тасуемое время от времени, вырывается вперед!
Гибрид дерева и социалистической очереди

Другим способом избежания дисбаланса двоичного дерева является организация входной очереди по типу «цепочки задержки». Рассмотрим это на следующем примере.

Допустим, к нашему дереву последовательно добавляются числа 1, 2, 3, 4, 5, 6… Любой, кто хоть раз имел дело с деревьями, сразу же скажет: поскольку 1<2<3<5<6, то все эти числа попадут на одну ветвь, а другая ветвь окажется совсем пустой.

А теперь представим, что на подступах к дереву стоит «демон» и складывает поступающие числа в некоторый контейнер, а затем извлекает их оттуда и переупорядочивает в наиболее выгодном для дерева порядке. В данном случае это «3, 4, 2, 5, 1, 6», то есть для всей последовательности должно выполняться условие Xn<Xn+1>Xn+2<Xn+3… Это легко обеспечить сортировкой элементов с их последующей выборкой. Поскольку необязательно организовывать длинную очередь, то временем сортировки можно пренебречь. Естественно, при операции поиска элементов в дереве нельзя забывать об очереди ;-).
The End!

0

4

Статья №4
Сегодняшний выпуск «Трюков» всецело посвящен хитроумным приемам программирования, затрудняющим дизассемблирование и отладку откомпилированной программы и, следовательно, увеличивающим ее сопротивляемость взлому, причем все это — без всякого ассемблера и других шаманских ритуалов!

Сладкая парочка setjump и longjump

Трассировка программы без захода в функции (step-over) — основной способ хакерской навигации. На его пути его реализации разработчики защитных механизмов стремятся расположить всякие подводные рифы и другие неожиданные ловушки, типа функций, никогда не возвращающих управление в точку возврата, что приводит к потере контроля над отлаживаемой программой и сильно напрягает хакера, заставляя его входить в каждую функцию, а также во все вызываемые ею функции. При большом уровне вложенности взлом растягивается на многие часы, дни, недели, месяцы, годы…

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

Обход точки возврата через longjmp

#include <stdio.h>

#include <setjmp.h>

#include <stdlib.h>

jmp_buf stack_state;

A(){printf("func A()\n");longjmp(stack_state, -1 );}

B(){printf("func B()\n");longjmp(stack_state, -2 );}

C(){printf("func C()\n");longjmp(stack_state, -3 );}

main()

{

int jmpret;

// __asm{int 03}; // для отладки

// запоминаем состояние стека

jmpret = setjmp(stack_state);

// выполняем C(), из которой мы возвращаемся в точку jmpret

if (jmpret==-3) return 0;

// выполняем C(), из которой мы возвращаемся в точку jmpret

if (jmpret==-2) C();

// выполняем B(), из которой мы возвращаемся в точку jmpret

if (jmpret==-1) B();

// выполняем A(), из которой мы возвращаемся в точку jmpret

if (jmpret==00) A();

// эта функция никогда не получает управление

printf("good bye, world!\n");

}

Откомпилируем программу и убедимся, что она последовательно вызывает функции A(), B(), C(), после чего раскомментируем строку «_asm{int 03}», откомпилируем еще раз и запустим полученный exe-файл под отладчиком OllyDbg (или любым другим), нажав <F9> (run) для достижения строки «int 03h». Начинаем трассировать программу по <F8> (step over) и… Не доходя до строки «printf("good bye, world!\n");» и не успев выполнить функцию A(), отладчик неожиданно теряет контроль за подопытной программой, и она, вырвавшись из-под трассировки, благополучно завершается по return 0, что OllyDbg и констатирует. Сказанное относится не только к OllyDbg, но также к Soft-Ice и всем остальным отладчикам.

К сожалению, в дизассемблере типа IDA Pro ловушка становится слишком очевидной, и, установив точку останова на функцию setjmp, хакер без труда сможет отладить защищенную программу, разобрав защитный механизм на составные части и выкинув из него все лишние детали.

Подмена адреса возврата

При трассировке step-over отладчики устанавливают программную (реже аппаратную) точку возврата за концом команды CALL func_A, куда func_A возвращает управление посредством оператора return, стягивающего со стека адрес возврата, положенный туда процессором перед вызовом func_A. Таким образом, чтобы вырваться из-под трассировки, нам достаточно заменить подлинный адрес возврата адресом какой-нибудь другой функции (назовем ее функцией func_B), куда и будет передано управление.

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

Таким образом, нам надо просто получить адрес самого левого аргумента (что можно сделать оператором «&»). Преобразовав его к указателю на машинное слово (на x86 составляющее 32 бита и совпадающее по размеру с указателем на int), уменьшить его на единицу и… записать по этому адресу указатель на функцию func_B, куда и будет передано управление по завершении func_A. Следует помнить, что при выходе из функции func_B управление будет передано… обратно на саму функцию func_B! Почему? Да потому, что она вызвана «нечестным» способом и в стек не занесен адрес возврата. Тем не менее, func_B может спокойно вызывать остальные функции «честным» путем, ничем не рискуя.
Законченный пример приведен ниже:

Демонстрация вызова функции с подменой адреса возврата

// функция B(), которой функция A()

// скрытно передает управление

B(){printf("func B();\n");exit(0);}

// явно объявляем функцию как _cdecl,

// чтобы оптимизатор «случайно» не реализовал ее как fastcall

_cdecl A(int x)

{

// подмена адреса возврата

*((((int*)&x)-1))=x;

printf("func A();\n");

}

main()

{

// __asm{int 03}; // для отладки

// функция A() подменяет свой адрес возврата на B()

A((int) B);

// эта функция никогда не получает управление

printf("good bye, world!\n");

}

Компилируем программу, убеждаемся, что она работает, затем раскомментируем строку «_asm{int 03}», перекомпилируем и запускаем под отладчиком Microsoft Visual Studio Debugger (или любым другим). Нажимаем <F5> (run) и затем несколько раз <F10> (step over). Отладчик входит в функцию A(), но обратно уже не возвращается, поскольку отлаживаемая программа вырывается из лап трассировщика!

Маскировка указателей

Описанный выше прием эффективен в борьбе против отладчиков, но бессилен перед дизассемблерами, поскольку при первом же взгляде на вызов функции func_A становится заметно, что ей в качестве аргумента передается адрес функции func_B. «Это же явно неспроста», — бормочет себе под нос хакер, устанавливая точку останова на func_B, о которую спотыкается защитный механизм при попытке освободиться от гнета отладчика.

Так выглядит откомпилированный код в дизассемблере

.text:0040103F int 3 ; Trap to Debugger

.text:00401040 push offset func_B

.text:00401045 call func_A

.text:0040104A add esp, 4

.text:0040104D push offset aGoodByeWorld ; "good bye, world!\n"

.text:00401052 call _printf

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

Самое простое, что можно сделать, — перед передачей указателя на func_B наложить на него «магическое слово» операцией XOR, а перед подменой адреса возврата наложить XOR еще раз, получая исходный указатель:

Доработанный вариант, маскирующий указатель на func_B

#define MAGIC_WORLD 0x666999

*((((int*)&x)-1))=x ^ MAGIC_WORLD;

A(((int) B) ^ MAGIC_WORLD);

Компилируем программу, не забыв задействовать оптимизацию, чтобы компилятор зашифровал указатель еще на стадии компиляции; в Microsoft Visual C++ это достигается путем указания ключа ‘/Ox’, в других компиляторах это может быть ключ ‘-O2’ или что-то другое, описанное в справочном руководстве.

Доработанный код в дизассемблере

text:00401040 _main proc near

text:00401040 push 267999h

text:00401045 call sub_401020

text:0040104A push offset aGoodByeWorld ; "good bye, world!\n"

text:0040104F call _printf

text:00401054 add esp, 8

text:00401057 retn

text:00401057 _main endp

Теперь указатель на функцию func_B превратился в безликую константу 267999h, в которой даже самые проницательные хакеры навряд ли смогут распознать ее истинную сущность! Кстати говоря, описанный трюк не только полезен в контексте подмены адреса возврата, но применим ко всем видам указателей — как на функции, так и на данные, в том числе и текстовые строки, перекрестные ссылки к которым автоматически генерируются IDA Pro и другими дизассемблерами. А по перекрестным ссылкам найти код, выводящий сообщение о неверном ключе регистрации или истечении демонстрационного строка использования, — минутное дело! Если, конечно, указатели не будут зашифрованы магическим словом!
The End!

0

5

Статья №5
Сегодняшний выпуск посвящен проблемам удаленной диагностики ошибок. Это когда у пользователя падает программа, а воспроизвести ситуацию на месте у нас не получается. Удаленную отладку (по модему и/или интернету) не предлагать, поскольку далеко не всякий пользователь на это согласится. Все, что нам остается, — внедрить в программу дополнительный проверочный код.
Совет 1: никогда не удаляй проверки из release

Большинство программистов, напичкивающих отладочную версию программы всевозможными проверками корректности всех значений, словно лемминги, подчиняющихся законам всеобщей традиции, удаляют их из финального релиза. А зачем?! Отладочную информацию (генерируемую компилятором) удалять, естественно, нужно, поскольку она не только в разы увеличивает размер исполняемого файла, но и облегчает его взлом, а также в большинстве случаев вырубает многие опции компиляции. Удаление избыточных проверок практически не сказывается на размере и слабо влияет на производительность (за исключением, быть может, многочисленных проверок в глубоко вложенных циклах). Так зачем же их удалять?! И каким образом выполнять диагностику, если на машине конечного пользователя программа внезапно откажет в работе?! Если программист предполагал, что ошибка может проявиться в отладочной версии, и добавил специальную проверку, то почему он считает, что она заведомо не появится в релизе? Где гарантия, что в процессе отладки были протестированы все возможные состояния программы? Где гарантия, что мы не имеем дело с «наведенной» ошибкой, зависящей от других частей программы, на первый взгляд, не имеющей к ней никакого отношения?

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

Совет 2: активно используй самодиагностику

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

На стадии отладки польза самодиагностики очевидна, но вот зачем она в финальной версии?! А затем, что мы не можем доверять ни аппаратуре, ни системным библиотекам, ни самой оси, установленной у пользователя. Личный пример из жизни — машинные команды PUSH reg16 в 32-битном режиме у Intel и AMD реализованы неодинаково. Обе они забрасывают на вершину стека двойное слово (как и положено по спецификации), но одна очищает старшие разряды, а другая оставляет их без изменений (со всем мусором, что в них есть). В моей программе была досадная ошибка, при определенных обстоятельствах приводящая к потере нуля в конце ASCIIZ-строки. Но поскольку за ней следовало двойное слово, заброшенное на стек командой PUSH reg16, и я отлаживал программу на процессоре, очищающем старший разряд, то все работало более или менее нормально (два байта мусора, появляющихся в конце строки, никому не мешали). Но вот при запуске на другом процессоре, где завершающего нуля не оказывалось, возникала критическая ситуация, завершающаяся исключением.

Или вот: незначительные различия в реализации «плавающих» команд на различных процессорах могут привести к странному поведению программы, которое будет невозможно воспроизвести на любом другом процессоре!

Про разгон, дефекты памяти и т.д. я вообще молчу! Никогда нельзя быть уверенным в том, что после выполнения «a=6; a=a+3;» в переменной а окажется именно 9, а не 83737382. И виноват тут может быть не только процессор, но и «удар по памяти», когда совершенно посторонняя функция, обратившись по неинициализированному указателю, запишет что-то в чужую область данных.

Естественно, самотестирование занимает некоторое время, и для достижения максимальной производительности я использую его в том случае, если предыдущий запуск программы завершился в аварийном режиме. Кроме того, на всякий непредвиденный случай присутствует недокументированный флаг, форсирующий самотестирование, даже если предыдущий запуск был завершен нормально.

Функции самотестирования не раз выручали меня и помогли мне сэкономить колоссальное количество времени, поскольку ряд ошибок был связан с особенностями конкретного оборудования, то есть причина крылась вне исходного кода программы.

Совет 3: секреты отладочной печати

Отладочная печать — великолепное изобретение, появившееся еще в те времена, когда интерактивных отладчиков не существовало и в помине, а отлаживать было надо. Большинство программистов использует тривиальную запись в текстовый log-файл или API-функцию OutputDebugString. Первый метод, естественно, лучше, поскольку он не требует наличия отладчика или специальной утилиты для перехвата отладочной печати, которую конечному пользователю нужно устанавливать на свой компьютер. Мы же ведь не собираемся исключать отладочную печать из финальной версии, верно? Естественно, не собираемся! Достаточно добавить специальный ключ командой строки, секретную комбинацию клавиш или вполне честную опцию в настройках программы. Лог лучше всего вести в текстовой форме. Так пользователю будет проще пересылать его нам по почте, и он сможет убедиться, что там нет ничего такого, чего бы он не хотел разглашать.

Вот только… при возникновении критической ошибки система завершает работу приложения еще до того, как будут сброшены дисковые буферы. Даже использование функции fflush ничего не решает (а вот скорость программы замедляет весьма существенно). Как же быть?! Да очень просто: создать в shared-memory кольцевой буфер заданного размера и весь отладочный вывод направлять туда, читая его с помощью дочернего процесса. Тогда, при аварийном завершении материнского процесса, shared-memory не будет освобождена системой и дочерний процесс успеет принять последнее отладочное сообщение, отправленное упавшей программой. К тому же этот метод работает намного быстрее прямой записи на диск.

А почему буфер должен быть именно кольцевым?! В общем, это не требование, а так, простое пожелание. Обычно нас интересует не весь отладочный вывод целиком, а события, непосредственно предшествующие падению. Но при интенсивном отладочном выводе полный размер лога может достигать десятков мегабайт, большая часть из которых не несет никакой полезной нагрузки, так что лучше заранее исключить ее, замкнув буфер в кольцо.

Совет 4: автоматический трассировщик - это просто

В самых ответственных случаях программу, поставляемую заказчику, имеет смысл снабдить простейшим автоматическим трассировщиком, на создание которого уйдет не больше одного вечера. Просто взводим флаг трассировки (TF) и отлавливаем отладочные исключения штатными средствами операционной системы (через SEH), записывая: а) адрес машинной команды; б) содержимое регистров; в) адрес ячейки памяти, к которой она обращается.

Когда трассировка из прикладного уровня дойдет до ядра, процессор самостоятельно опустит флаг трассировки на время прохождения нулевого кольца и потом поднимет его при возвращении на прикладной уровень, так что предусматривать специальную обработку для исключения системных вызов из списка трассируемых функций не надо.

Естественно, трассировка на несколько порядков (!) замедляет скорость работы программы и потому должна включаться специальной комбинацией клавиш, которую пользователь нажимает в тот момент, когда программа приближается к месту сбоя на максимально близкое расстояние. А для этого пользователю придется воспроизвести ситуацию, при которой возникает ошибка. Если же ему это сделать не удастся, что ж! Включаем трассировщик при старте программы специальным ключом командой строки и пишет результат трассировки в кольцевой буфер, который внимательно изучаем.

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

Понятное дело, программа, защищенная протекторами, содержащими антиотладочные приемы, с автоматическим трассировщиком работать не будет. Так что придется отказаться или от протекторов, или от трассировки, либо же надо писать «умный» трассировщик, обходящий анти-отладочные приемы, но это уже серьезная задача, решение которой может затянуться не на одну неделю.
The End!

0

6

Статья №6
Совет 1: вычисления rand() на стадии компиляции

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

Некоторые программисты поступают так: подключают включаемый файл x-file.h директивой #include, создают простую утилиту на Си, генерирующую x-file.h следующего содержания: #define X_RAND 0xXXXXXXXX, где 0xXXXXXXXX — случайное число, возвращаемое функцией rand(), а в makefile-файл вставляют команды компиляции этой вспомогательной утилиты, ее линковку и запуск. После создания x-file.h можно собирать файл проекта. Достоинство этого трюка в его переносимости, а недостаток в излишней громоздкости. Сгенерировать уникальное для каждого билда число можно и проще!

Компиляторы, придерживающиеся ANSI Си, имеют в своем «словарном запасе» макросы __DATE__ и __TIME__, возвращающие дату и время компиляции файла соответственно. Оба значения представлены в строковом формате, от которого приходится избавляться путем вычисления хеш-суммы по CRC32 или любому другому алгоритму. Для достижения большей случайности полученное число можно передать функции srand() с последующим вызовом rand().

Также можно использовать макрос __TIMESTAMP__, возвращающий штамп даты/времени последней модификации компилируемого файла в виде 32-битного целого, что избавляет нас от необходимости вычисления CRC32. Однако если файл, содержащий __TIMESTAMP__, не будет изменен, мы получим то же самое число, что и в предыдущем билде. В некоторых случаях это неприемлемо, в некоторых, напротив, даже очень желательно (то есть сгенерированное число изменяется только в случае изменения файла).

Использование макроса __TIMESTAMP__ для генерации случайного числа, уникального для каждого билда

int x_rand;

main()

{

srand(__TIMESTAMP__);

x_rand = rand();

}

Совет 2: строковые литералы и тип char

Рассмотрим следующий код, вполне типичный для начинающих. Что в нем неправильно?

Пример неправильного использования char*, неявно использующий некоторые особенности поведения компилятора

foo(char *s)

{

if (*s < 'я') return 'ты';

}

Опытным программистам известно, что стандарт ANSI Cи позволяет компиляторам самостоятельно решать, должен ли тип char быть знаковым или нет. Поэтому, если число укладывается в [0, 127], мы вправе использовать char — программа будет работать независимо от наличия знака. В противном случае следует явно специфицировать тип, указывая перед char, каким ему быть: signed или unsigned.

Компилятор Microsoft Visual C++ по умолчанию всегда выбирает unsigned char, поэтому данная программа будет работать правильно. Однако стоит откомпилировать ее с помощью Borland C++, как все изменится и мы получим совершенно неожиданный результат. Компилятор по умолчанию устанавливает char в signed, в результате чего строковой литерал 'я' превращается в число -17 и условие «(*s < 'я')» окажется в косяках, что наглядно подтверждает дизассемблерный листинг, приведенный ниже:
Результат работы компилятора Borland C++, переменная char *s трактуется как signed char*

_TEXT:00000000 _foo proc near ; CODE XREF: _main+4?p

_TEXT:00000000

_TEXT:00000000 arg_0 = dword ptr 8

_TEXT:00000000

_TEXT:00000000 push ebp

_TEXT:00000001 mov ebp, esp

_TEXT:00000003 mov eax, [ebp+arg_0]

_TEXT:00000006 cmp byte ptr [eax], -17 ; 'я'

_TEXT:00000009 jge short loc_20 ; знаковое сравнение!

_TEXT:0000000B mov eax, 0EBE2h

_TEXT:00000010

_TEXT:00000010 loc_20: ; CODE XREF: _foo+9?j

_TEXT:00000010 pop ebp

_TEXT:00000011 retn

_TEXT:00000011 _foo endp

Самое большое коварство этой проблемы заключается в том, что она не проявляется в английских программах, поскольку символы английского алфавита сосредоточены в первой половине ASCII-таблицы и потому знака «минус» в них просто не возникает! А вот после русификации он неожиданно вылезает в самых разных местах, разваливая программу и порождая трудноуловимые баги.

Совет 3: выход из нескольких циклов сразу

Начинающие программисты постоянно задают мне один и тот же вопрос: как выйти из двух и более циклов сразу? Средствами структурного программирования никак не получается. То есть получается, конечно, но приходится использовать флаги, проверяемые в каждом цикле, что не только громоздко, ненадежно, ненаглядно, но еще и непроизводительно. Современные процессоры не любят ветвлений, и каждая лишняя проверка сжирает кучу тактов, особенно на нерегулярных переходах, которые невозможно предсказать.

Выход состоит в использовании горячо критикуемого goto, который обвиняют в неструктурности и вообще в «идеологической неправильности». Действительно, при злоупотреблении goto программа превращается в спагетти и ее становится совершенно невозможно отлаживать, поскольку непонятно, как мы вообще попали в этот блок кода и какая зараза совершила сюда переход. Но сравни два следующих фрагмента кода:
Выход из трех циклов с использованием оператора goto

for(…)

{

for (…)

{

for(…)

{

if (…) goto to_exit;

}

}

} to_exit:

Выход из трех циклов без использования оператора goto

int to_exit = 0;

for(…)

{

for(…)

{

for(…)

{

if (…)

{

to_exit = 1;

break;

}

}

if (to_exit) break;

}

if (to_exit) break;

}

Не кажется ли тебе, что этот код намного более нагляден и в нем гораздо труднее совершить ошибку, чем в «идеологически правильном» варианте? Увы! В некоторых случаях, использование goto строго запрещено принятыми корпоративными правилами кодирования, против которых не попрешь. Вот такая, значит, бюрократия.

Совет 4: переносимые ассемблерные вставки

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

Проблема в том, что способ оформления ассемблерный вставок не стандартизован и каждый компилятор делает это по-своему. К тому же даже в рамках x86-процессоров существует как минимум два ассемблерных синтаксиса: Intel, поддерживаемый Windows-компиляторами, и AT&T, поддерживаемый, например, GCC.

Одно из решений состоит в переводе ассемблерной вставки в машинный код (что очень удобно делать в hiew'е) с последующим размещением ее в локальном массиве, указатель на который преобразуется в указатель на функцию, запускаемую на выполнение с передачей аргументов через стек по тому или иному соглашению.

Практический пример использования такого трюка приведен ниже:
Пример вставки машинного кода в Си-программу

int (*foo)();

bar()

{

// объявляем массив и заполняем его машинным кодом

char shell[]="\x0F\x31\xC3"; // RDTSC + RETN

// преобразуем указатель на массив в указатель на функцию foo

foo = (int(*)())shell;

// вызываем функцию foo, возвращая результат ее выполнения

// для простоты результат усекается до 32 бит, передаваемых в регистр EAX

// старшие 32 бита, помещаемые командой RDTSC в регистр EDX, мы отбрасываем

return foo();

}

main()

{

int a;

a = bar();

}

Единственный существенный недостаток этого метода в том, что на осях с неисполняемым стеком он не работает, и приходится вызывать системно-зависимые функции для установки соответствующих атрибутов доступа к памяти: VirtualProtect() на Windows и mprotect() на UNIX. Вокруг них приходится делать свои «обертки» (они же «врапперы» от английского wrapper), вызываемые перед передачей управления на функции foo(). Но и в этом случае у нас нет никаких гарантий, что ось позволит выполнить код. В частности, некоторые UNIX-подобные системы на процессорах, не поддерживающих биты NX/XD (атрибуты исполнения кода на уровне страниц), размещают стек в области памяти, управляемой селектором, устанавливающим права доступа только на чтение/запись (без возможности исполнения) и потому игнорирующим вызов mprotect(PROT_EXEC).
The End!

0

7

Статья №7
Сегодня мы поговорим о статических/динамических массивах нулевой длины. «А разве бывают такие?» — спросит недоверчивый читатель. Не только бывают, но и утверждены Стандартом, а также активно используются всеми, кто о них знает.

Трюк первый: статические массивы на стеке

Зачем может понадобиться создавать статический массив нулевой длины? Ведь выражение типа «char c[0]» не имеет смысла! Однако… в некоторых ситуациях оно бывает очень даже полезно. Допустим, мы имеем определение DATA_LEN с допустимыми значениями от 0 (no data) до… XXL. Тогда конструкция «char c[DATA_LEN]» при DATA_LEN == 0 приведет к ошибке компиляции, даже если мы не собираемся обращаться к массиву «c» по ходу исполнения программы. А усложнять алгоритм, добавляя лишние ветвления и загромождая листинг командами препроцессора, не хочется.

Вся хитрость в том, что если обернуть статический массив структурой, то компилятор проглотит ее, не задумываясь! Как раз то, что нам нужно! Рассмотрим следующий код:

Статический массив нулевой длины на стеке

#define DATA_LEN 0 // нет данных

struct ZERO // структура со статическим массивом нулевой длины

{

char c[DATA_LEN]; // массив нулевой длины

};

main()

{

// объявляем структуру с массивом нулевой длины

struct ZERO zero;

// печатаем размер структуры ZERO и ее экземпляра zero

printf("%x %x\n", sizeof(struct ZERO), sizeof(zero));

// присваиваем значение первой ячейке массива нулевой длины!!!

*zero.c = 0x69;

// выводим это значение на экран

printf("0%Xh\n", *zero.c);

}

Этот код компилируется всеми компиляторами без исключения, причем работает он правильно (хотя и не обязан этого делать). В частности, при компиляции Си-программы, Microsoft Visual C++ утверждает, что размер структуры ZERO равен 4 байтам, но если изменить расширение файла с «.c» на «.cpp», мы получим… 1 байт.

GCC во всех случаях дает нам 0 байт, что логично, но неправильно, поскольку все 32-битные компиляторы реально резервируют как минимум 4 байта под локальные переменные любых видов, поскольку это необходимо для выравнивания стека (и дизассемблерные листинги наглядно подтверждают это!).

Следовательно, мы можем не только создавать статические массивы нулевой длины, но еще и (пускай не без предосторожностей) использовать их. Например, в качестве вступительных тестов для новичков. Шутка! Но некоторая доля истины в ней есть. Обычно программисты, не желающие, чтобы их отстраняли от проекта, добавляют в исходный код немного «черной магии». Программа работает вопреки здравому смыслу и совершенно непостижимо для окружающих.

А вот если переместить структуру ZERO в статическую область памяти (секцию данных), то место, резервируемое под массив нулевой длины, сразу же уменьшится до 1 байта, а поскольку выравнивать переменные в статической памяти нет никакой необходимости, то в нашем распоряжении останется по меньшей мере 1 байт, который можно задействовать под производственные нужды.

Трюк второй: динамические массивы на куче

Функции семейства malloc() обязаны корректно обрабатывать нулевой аргумент, возвращая валидный указатель на блок памяти нулевой длины. Вот что говорит MSDN по этому поводу: «If size is 0, malloc allocates a zero-length item in the heap and returns a valid pointer to that item» (Если размер [выделяемой памяти] равен нулю, функция malloc выделяет блок памяти нулевой длины в куче и возвращает указатель на него). То есть создавать массив нулевой длины на куче мы можем без всяких извращений со структурами. Вот только обращаться к созданному массиву (по стандарту) не можем никак. Стандарт допускает только проверку указателя на ноль, сравнение двух указателей, освобождение памяти, ну и, естественно, реаллокацию. Однако стандарт предполагает, а компилятор располагает.

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

Измерительный прибор, определяющий реальный размер массивов нулевой длины

#define DATA_LEN 0 // нет данных

main()

{

// создаем 3 массива нулевой длины

char *p1=malloc(DATA_LEN);

char *p2=malloc(DATA_LEN);

char *p3=malloc(DATA_LEN);

// создаем 3 массива длиной в 1 байт

char *p4=malloc(1);

char *p5=malloc(1);

char *p6=malloc(1);

// выводит указатель на созданные блоки на экран

printf("0%Xh\n0%Xh\n0%Xh\n\n0%Xh\n0%Xh\n0%Xh\n", p1, p2, p3, p4, p5, p6);

}

Откомпилировав программу с помощью Microsoft Visual C++ и запустив ее на выполнение, мы получим следующий результат:

Реально выделяемый размер динамических массивов

0300500h ; \

03004F0h ; +- указатели на блоки нулевой длины

03004E0h ; /

03004D0h ; \

03004C0h ; +- указатель на блоки длиной в 1 байт

03004B0h ; /

Как видно, адреса выделяемых блоков планомерно уменьшаются на 10h байт, следовательно, каждый блок (состоящий из массива и служебных данных) занимает намного больше, чем ничего. Более того, вызов malloc(0) эквивалентен malloc(1). Определить размер актуальных данных динамического массива несложно. Достаточно увеличивать аргумент, передаваемый malloc до тех пор, пока разница между соседними указателями скачкообразно не увеличится на некоторую величину.

Эксперимент показывает, что минимальный размер выделяемого блока для Microsoft Visual C++ и 32-битных версий GCC составляет 10h байт, то есть malloc(0) работает точно так же, как и malloc(0xF). Естественно, никаких гарантий, что остальные компиляторы поведут себя аналогичным образом, у нас нет и никогда не будет, поэтому вылезать за границы отведенного блока в любом случае не стоит.

С другой стороны, выделив большое количество динамических массивов нулевого размера, не следует надеяться, что они не занимают драгоценной памяти и потому их можно не освобождать. Освобождать их нужно, иначе память будет утекать со страшной скоростью!

Трюк третий: оператор new

Практически все известные мне компиляторы Си++ реализуют оператор new на основе malloc, поэтому все сказанное о malloc(0) справедливо и для new(0). Однако… кое-какие различия все-таки наблюдаются, и мне бы хотелось обратить на них внимание читателя.

Прежде всего откроем Стандарт (смотри «C++ Programming Language, Second Edition», секция 5.3.3), где Дохлый Страус прямо так и пишет: «This implies that an operator new() can be called with the argument zero. In this case, a pointer to an object is returned. Repeated such calls return pointers to distinct objects» («Отсюда следует, что оператор new() может вызываться с нулевым аргументом и возвращать валидный указатель на объект. Последовательный вызов new(0) возвращает указатели на различные объекты»).

Дальше по тексту объясняется, что мы можем получить указатель на нулевой объект, сравнивать его с любым другим указателем, но вот обращение к объекту нулевой длины Стандартом… ну не то чтобы запрещается, а отдается на откуп конкретным реализациям. Изучение исходных кодов RTL-библиотек различных компиляторов показывает, что new(0) в общем случае эквивалентно new(1) независимо от типа объекта.

Вот, например, фрагмент кода из GCC:

Фрагмент кода из компилятора GCC, реализующий оператор new

void* operator new(size_t size) // реализация оператора new

{

// если size равно нулю, принудительно устанавливаем размер в единицу

if( size == 0 ) size = 1;

// продолжение функции

}

Оператор new, в свою очередь, опирается на RTL-библиотеку, общую как для Си, так и для Си++, а потому оператор new(1) в большинстве случаев эквивалентен new(0xF), что наглядно подтверждает следующая программа:

Демонстрация создания объекта размером в 1 байт с помощью new char[0]

main()

{

// создаем символьный массив нулевой длины

// (Стандартом это допускается)

char *c = new char[0];

// получаем указатель на созданный объект нулевой длины

// (Стандартом это допускается)

char *p = &c[0];

// записываем в объект нулевой длины число 0x69

// (а вот этого Стандарт уже не допускает!!!)

*c=0x69;

// проверяем успешность записи числа, выводя его на экран

printf("0%Xh\n",*c);

}

Чтобы не быть голословным, мыщъх приводит дизассемблерный фрагмент вышеупомянутой программы, откомпилированной Microsoft Visual C++ (__heap_alloc – служебная функция, на которую опирается оператор new):

__heap_alloc proc near

arg_0 = dword ptr 8

push esi

mov esi, [esp+arg_0] ; размер выделяемой памяти

cmp esi, dword_406630 ; выделяем больше 1016 байт

ja short loc_401B87 ; если да, то прыжок

push esi ; обрабатываем ситуацию

call ___sbh_alloc_block ; с выделением более 1016 байт

test eax, eax ; памяти

pop ecx

jnz short loc_401BA3 ; прыжок, если памяти нет

loc_401B87:

test esi, esi ; выделяем ноль байт?!

jnz short loc_401B8E ; если не ноль - прыгаем

push 1 ; если ноль - увеличиваем

pop esi ; аргумент на единицу

loc_401B8E:

add esi, 0Fh ; округляем размер блока

and esi, 0FFFFFFF0h ; на 10h в большую сторону

push esi ; dwBytes

push 0 ; dwFlags

push hHeap ; hHeap

call ds:HeapAlloc ; выделяем блок памяти

loc_401BA3:

pop esi

retn

__heap_alloc endp

The End!

0

8

Статья №8
Cовет 1: рекурсия вместо циклов

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

for(i = 0; i < n; i++) result *= n;

А теперь попробуем извратиться, заменив цикл рекурсивным вызовом функции, в результате чего у нас получится следующий код:

foo(int n, int result)

{

if(n) return foo(n - 1, result * n);

return result;

}

На первый взгляд кажется, что рекурсивный вариант будет ужасно тормозить, поскольку накладные расходы на передачу аргументов и вызов функции чрезвычайно велики, к тому же возникает прямая угроза исчерпания стека при большом количестве итераций. На самом деле компиляторы уже давно научились избавляться от хвостовой рекурсии, трансформируя ее в цикл, что подтверждается дизассемблерным листингом, приведенным ниже (примечание: хвостовой рекурсией — tail recursion — называется такой тип рекурсии, при котором вызов рекурсивной функции следует непосредственно за оператором return):

Фрагмент дизассемблерного листинга, демонстрирующий, как компилятор Microsoft Visual C++ 6.0 сумел избавиться от рекурсии

.text:0000006C loc_6C:

.text:0000006C mov edx, ecx

.text:0000006E imul eax, edx

.text:00000071 dec ecx

.text:00000072 jnz short loc_6C

Сгенерированный компилятором код оптимизированного цикла полностью идентичен своему неоптимизированному собрату. И в чем же тогда состоит обещанный выигрыш?! В данном случае ни в чем, однако некоторые алгоритмы в рекурсивной форме имеют более естественный и наглядный вид. Следовательно, их отладка упрощается, а вероятность совершения ошибок уменьшается. Многие руководства по оптимизации настоятельно рекомендуют избавляться от ресурсоемкой рекурсии еще на стадии кодирования, однако в случае хвостовой рекурсии компилятор все сделает за нас сам!

Однако следует помнить, что этот трюк не распространяется на остальные виды рекурсии и с оптимизацией рекурсивного вычисления чисел Фибоначчи компилятор уже не справляется:

fib(int n)

{

if (n < 2) return 1;

return fib(n - 1) + fib(n - 2);

}

В дизассемблерном листинге мы отчетливо видим два вызова функции fib, которые приводят к огромному падению производительности, совершенно не компенсируемому улучшением читабельности и наглядности алгоритма.

Фрагмент дизассемблерного листинга с нехвостовой рекурсией

.text:00000030 fib proc near

.text:00000030

.text:00000041

.text:00000041 loc_41:

.text:00000041 lea eax, [esi-2]

.text:00000044 push edi

.text:00000045 push eax

.text:00000046 call _fib

.text:0000004B dec esi

.text:0000004C mov edi, eax

.text:0000004E push esi

.text:0000004F call _fib

.text:00000054 add esp, 8

.text:00000057 add eax, edi

.text:00000059 pop edi

.text:0000005A pop esi

.text:0000005B retn

.text:0000005B fib endp

Совет 2: сокрытие ветвления в логических операторах

Язык Си (как и остальные языки высокого уровня) всегда оптимизирует выполнение логических операторов (даже если все опции оптимизации выключены). Если выражение foo не равно нулю, то вычисление выражения bar в конструкции (foo || bar) никогда не выполняется. Соответственно, если foo равно нулю, то в конструкции (foo && bar) вычислять bar нет никакой необходимости, более того, если бы такое вычисление выполнялось, то привычная конструкция (m && n/m) привела бы к развалу программы при m, равном нулю.

Отсюда ветвление вида «if (foo==0) bar;» можно заменить аналогичным ему выражением (foo || bar):

Классический вариант с ветвлением

if (foo==0) my_func(x,y,z);

Оптимизированный вариант

foo || my_func(x,y,z);

И хотя ветвления на самом деле никуда не делись (компилятор послушно вставит их в код в том же самом количестве, что и раньше), этот трюк можно использовать, например, чтобы прищемить членов жури на олимпиадных и конкурсных задачах в стиле «отсортировать m чисел, используя не более k сравнений».

Другое (более существенное) преимущество — выражение, в отличие от явных ветвлений, может использоваться где угодно, существенно упрощая программирование и повышая наглядность листинга:

Неоптимизированный вариант

for (;;)

{

some_func(i,j,k);

if (foo==0) my_func(x,y,z);

}

Если заменить ветвления логикой, весь цикл укладывается в одну строку без всяких фигурных скобок:

for (;;) some_func(i,j,k), (foo || my_func(x,y,z));

И хотя некоторые могут заявить, что неоптимизированный вариант более нагляден, это не так. Наглядность на 90% вопрос привычки, однако скорость чтения (и восприятия) листинга обратно пропорциональна его размеру, и потому компактный стиль программирования более предпочтителен.

Совет 3: опасайся операторов «--» и «++»

За постфиксными операторами «--» и «++» закрепилась дурная слава небезопасных и приводящих к неопределенному поведению (по-английски undefined behavior, или сокращенно ub), ссылками на которое пестрит текст Стандарта и многочисленных руководств по Си/Си++.

Практически все знают, что результат вычисления функции foo(x++, x++) зависит не только от значения переменной x, но и особенностей используемого транслятора, ведь порядок вычисления аргументов отдан на откуп реализаторам и компиляторы могут вычислять их в произвольном порядке. Отсюда и ub.

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

Вопрос на засыпку. Является ли конструкция foo(++i, ++j) (не)безопасной и почему? На первый взгляд, здесь все законно и никакого ub не возникает. В случае если foo является функцией, это действительно так, ну а если это макрос вида «#define max(i,j) ((i) < (j) ? (i) : (j))», на выходе препроцессора мы получим следующий код:

((++i) < (++j) ? (++j) : (++i))

Теперь уже и слепой увидит, что переменные инкрементируются дважды, и мы получаем довольно неожиданный результат. Но ведь не будешь же каждый листинг прогонять через препроцессор! Более того, определения, бывшие ранее макросами, в следующей версии сторонней библиотеки могут превратиться в функции, равно как и наоборот!

Поэтому вместо конструкции foo(++i, ++j) настоятельно рекомендуется использовать (foo( (i+1), (j+1) ), i++, j++), которая хоть и проигрывает в компактности/производительности, зато абсолютно безопасна. Кстати, обрати внимание на два обстоятельства.

Первое - переменные разделяются не точкой с запятой, а просто запятой, что делает эту запись единым выражением. Если бы мы использовали точку с запятой, то при изменении «for(;;) foo(++i, ++j)» на «for(;;) foo( (i+1); (j+1) ); i++; j++;» пришлось бы использовать фигурные скобки, о которых легко забыть, особенно если for находится на одной строке, а foo – на другой. К тому же в выражениях if/else «внеплановые» фигурные скобки порождают неприятные побочные эффекты, а зачем они нам? Правда… операторы, разделенные запятой, значительно хуже поддаются оптимизации, но это уже издержки, на которые приходится закрывать глаза ради безопасности и универсальности.

Второе обстоятельство — круглые скобки вокруг (i+1) и (j+1) формально не обязательны, но! Если foo – макрос, разработчик которого забыл заключить аргументы в скобки, то при его обработке препроцессором мы можем огрести по полной программе, получив совсем не тот результат, который ожидали. Опять-таки с формальной точки зрения ответственность лежит на разработчике макроса, но в реальной жизни мы вынуждены предугадывать возможные ошибки своих коллег, предпринимая превентивные меры по их устранению, особенно при аудите кода. Всякая замена потенциально опасных конструкций безопасными должна быть полностью эквивалентной в смысле побочных эффектов, в противном случае последствия такого аудита могут оказаться фатальными.
The End!
P.S.:В статье нет ни одного смайла!Эт все код!

0

9

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

Трюк 1: обход префикса «_»

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

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

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

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

Избавляемся от префиксов «_» через макросы

#define _f() x_f()

x_f();

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

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

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

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

struct string

{

int length; // длина строки

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

};

Элемент 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);

...

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

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

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

foo()

{

char a;

char b;

char c;

char d[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

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

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

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

#pragma pack( 1 )

struct bar

{

char a;

char b;

char c;

char d[5];

};

foo()

{

struct bar baz;

}

Смотрим на откомпилированный код, дизассемблированный все той же 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

Вот оно! Вот они, наши 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;

}

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

.text:00000005 public: __thiscall Base::Base(void) proc near

.text:00000005 ; CODE XREF: Derived::Derived(void)+A?p

.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
The End!

0

10

Статья №10
Сегодняшний выпуск трюков посвящен двум любопытным, но малоизвестным возможностям языка Си — «триграфам» (trigraph) и «диграфам» (digraph), в основном встречающимся в соревнованиях по непонятному программированию, однако в некоторых (достаточно редких) случаях разваливающим программу, написанную без учета их существования.
Совет 1: триграфы: жизнь без скобок

Ди- и триграфы широко используются в натуральных языках для обозначения «чужеродных» символов, отсутствующих в «своем» алфавите. Последовательность из двух (реже трех) «своих» символов кодирует один «чужой». Просто, как и все гениальное! Например, хангыль (корейское фонематическое письмо) состоит из блоков типа чамо, кодирующих отдельные слоги или даже целые слова. Всего существует 51 чамо, 24 из которых эквивалентны буквам обычного алфавита, а оставшиеся 27 представляют собой комбинации из двух или трех букв (то есть диграфы и триграфы соответственно).

Язык Си использует 9 символов, не входящих в наборы ISO 646 и EBCDIC, до сих пор используемые в некоторых терминалах. В результате квадратные и фигурные скобки невозможно ни набрать с клавиатуры, ни отобразить на экране такого терминала, а потому, начиная с самых первых редакций Стандарта, в язык ввели поддержку триграфов, скрестив символы базового набора ISO 646 с двумя знаками вопроса (смотри таблицу 1).

Программа, написанная с использованием триграфов, может выглядеть, например, так:
Исходный текст программы, написанной с использованием триграфов

??=include <stdio.h> /* # */

int main(void)

??< /* { */

char n??(5??); /* [ и ] */

n??(4??) = '0' - (??-0 ??' 1 ??! 2); /* ~, ^ и | */

printf("%c??/n", n??(4??)); /* ??/ = \ */

printf("??=??=??="); /* ### */

??>

Попробуй подсунуть эту абракадабру коллегам и спроси, что она делает. Кстати, обрати внимание на предпоследнюю строчку, здесь тригафы используются внутри строковых констант и, вместо ожидаемых «??=??=??=», функция printf напечатает три символа решетки! А ведь, не зная о существовании триграфов, о такую комбинацию можно спотыкнуться чисто случайно, долго ломая голову, почему программа работает не так, как это задумывалось.

Триграфы поддерживают практически все современные компиляторы, однако если Microsoft Visual C++ задействует триграфы по умолчанию, то Borland C++ для увеличения скорости трансляции использует внешний препроцессор, реализованный в файле trigraph.exe, входящий в штатный комплект поставки компилятора и вызываемый программистом самостоятельно. GCC поддерживает триграфы, но по умолчанию не обрабатывает их внутри строковых констант и делает это только при явном указании ключа ‘-trigraphs’.

    * Подробнее о триграфах можно почитать в Стандарте на Си, в любом хорошем учебнике или Википедии — http://en.wikipedia.org/wiki/C_trigraph.

Совет 2: диграфы: элегантный мир

Недостаточная выразительность и отвратительная читабельность триграфов привели к тому, что в последней редакции Стандарта ANSI C99 появилась достойная альтернатива в виде диграфов. Они используют всего два символа вместо трех, комбинируя угловые скобки со знаком процента или двоеточия, интерпретируемых как квадратные и фигурные скобки соответственно. Согласитесь, что так нагляднее (причем намного) и гораздо естественнее!

Решетка («#») кодируется двоеточием, следующим за знаком процента . Остальные же символы – «\», «^», «|» и «~» - не получили адекватной репрезентации и по-прежнему должны кодироваться через триграфы, что, впрочем, не создает большой проблемы в силу их невысокой распространенности (по сравнению с фигурными и угловыми скобками).

Конечно, тут можно поспорить, попинать разработчиков Стандарта и вообще развести флейм на шестьсот квадратных миль, но… против Священного Писания (Стандарта в смысле) не попрешь, несмотря на то что в настоящее время не имеется ни одного компилятора, в полной мере поддерживающего С99.

Пример программы, написанной с использованием диграфов (точнее, смеси диграфов и триграфов), приведен ниже:
Исходный текст программы, написанной с использованием ди- и триграфов

%:include <stdio.h> /* # */

int main(void)

<% /* { */

char n<:5:>; /* [ и ] */

n<:4:> = '0' - (??-0 ??' 1 ??! 2); /* ~, ^ и | */

printf("%c??/n", n<:4:>); /* ??/ = \ */

printf("%:%:%:");

??>

Попытка ее трансляции компиляторами Microsoft Visual C++ и Borland C++ вызывает сообщение об ошибке и проваливается. Ничего не поделаешь, эти компиляторы диграфов не переваривают! Последние версии GCC выполняют постановку диграфов везде, за исключением строковых констант. Причем попытка использования ключа ‘-digraphs’ не решает проблемы, поскольку этот ключ предназначен для вывода внутренней отладочной информации, генерируемой компилятором в ходе трансляции программы и интересной главным образом его разработчикам.

В Сети встречается достаточно много исходных текстов программ, написанных под UNIX (а UNIX — это синоним GCC) с использованием диграфов. Возникает резонный вопрос: и как же это чудо прогресса портировать на Windows? Можно, конечно, посоветовать версию GCC для win32, но это будет плохой совет. Особенно если из программы требуется вырезать всего один кусок в надежде вставить его в готовый проект на Microsoft Visual C++, который, между прочим, компилятор GCC, скорее всего, не захочет транслировать, в особенности если программист активно использовал нестандартные расширения от Microsoft (а поскольку мало кто изучает Си по Стандарту, практически все мы используем те или иные расширения, зачастую даже не подозревая об их нестандартности).

В действительности решение состоит в создании простейшего внешнего препроцессора, выполняющего «сквозной» поиск диграфов и заменяющего их соответствующими им символами. Если препроцессор писать лень, эту операцию можно осуществить в любом текстовом редакторе через Replace.
Совет 3: диграфы и шаблоны

В отношении Си диграфы лексически нейтральны. Они не нарушают целостности языка и гарантируют отсутствие коллизий: ни при каких обстоятельствах никакой диграф не может пересекаться ни с одной родной конструкцией языка, что совсем неудивительно, поскольку именно для Си диграфы и разрабатывались.

А вот язык Си++, претендующий на обратную совместимость с Си, при работе с диграфами наталкивается на серьезные проблемы, приобретая неоднозначность интерпретации: одна и та же конструкция может быть прочитана двояко в зависимости от того, выполнять ли в ней подстановку диграфов или нет.

Рассмотрим ситуацию на следующем примере. Допустим, в глобальном пространстве имен (global namespace) мы имеем класс, именуемый (для определенности) X. Допустим также, что мы хотим передавать класс X какому-нибудь другому классу в качестве аргумента (например, классу std::vector), причем передавать не абы как, а непременно в виде шаблона (template), ведь шаблоны - это, во-первых, очень модно, а во-вторых, жутко (не)удобно! Но как бы там ни было (о вкусах не спорят), задача поставлена, и ее надо решать.

Очевидное решение — написать «std::vector<::X>», и на некоторых компиляторах это будет работать. Как ты уже, наверное, понял, этими компиляторами окажутся Microsoft Visual C++, Borland C++, ранние версии GCC, то есть все те, кто не поддерживает диграфов и потому трактует конструкцию «std::vector<::X>» однозначно.

Проблемы возникают при попытке скормить эту штуку свежим версиям GCC (или любому другому компилятору с поддержкой диграфов). Комбинация «<:» заменяется «[», в результате чего вся конструкция превращается в «std::vector[:X>», выдавая ошибку транслятора и вызывая естественное недоумение программиста: «Что здесь не так?! Ведь еще вчера компилировалось!»

Одно из возможных решений состоит в разделении «<» и «::X>» символом пробела. Конструкция принимает вид «std::vector< ::X>», и конфликтов с диграфами больше не возникает.

Анализ исходных текстов, выловленных на бескрайних просторах Сети, показывает, что очень многие программы Си++ страдают подобными конфликтами и отказываются компилироваться свежими версиями GCC. Забавно, но некоторые разработчики прямо указывают требуемую версию транслятора в FAQ, предостерегая от использования более новых («багистных») версий. В действительности это не баг. Это фича! И теперь ты знаешь, как с ней обращаться.
The End!

0


Вы здесь » terminal-s52 » Кодинг » Трюки от Криса Касперски!