Статья №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!