Различия

Здесь показаны различия между двумя версиями данной страницы.

Ссылка на это сравнение

articles:c-tricks-14h [2017/09/05 02:55] (текущий)
Строка 1: Строка 1:
 +====== c-tricks-14h ======
 +<​sub>​{{c-tricks-14h.odt|Original file}}</​sub>​
 +
 +====== сишные трюки\\ (14h выпуск) ======
 +
 +крис касперски ака мыщъх, a.k.a. souriz, a.k.a. nezumi, no-email
 +
 +**о переполняющихся буферах написано много, о переполнении целочисленных/​вещественных переменных — чуть меньше,​ а ведь это одна из фундаментальных проблем языка си, доставляющая программистам массу неприятностей и порождающая целых ворох уязвимостей разной степени тяжести,​ особенно если программа пишется сразу для нескольких платформ. как быть, что делать?​ мыщъх делится своим личным боевым опыт (с учетом всех травм и ранений,​ понесенных в ходе сражений),​ надеясь,​ что читатели найдут его полезным,​ а я тем временем в госпитале с сестричкой…**
 +
 +===== трюк 1 — закон суров, но он закон! =====
 +
 +Фундаментальность проблемы переполнения целочисленных переменных имеет двойственную природу. Стандарт декларирует,​ что результат выражения (a + b) в общем случае неопределен (undefined) и зависит как от архитектурных особенностей процессора,​ так и от "​характера"​ компилятора. Положение усугубляется тем, что Си (в отличии от Паскаля,​ например) вообще ничего не говорит о разрядности типов данных больших чем байт. long int вполне может равняться int. И хотя начиная с ANSI C99 появились типы int32_t, int64_t, а некоторые компиляторы (в частности,​ MS VC) еще черт знает с какой версии поддерживают нестандартные типы _int32 и _int64, проблема определения разрядности переменных остается проблемой. Одним процессорам выгоднее обрабатывать 64-битные данные,​ другим — 32-битные и потому выбирать тип "на вырост",​ то есть с расчетом,​ что в него гарантированно влезут обозначенные значения — расточительно и не гуманно.
 +
 +К тому же, _гарантии_ что переполнение не произойдет,​ у нас нет. Обычно при переполнении либо наблюдается изменение знака числа (небольшое знаковое отрицательное превращается в больше беззнаковое),​ либо "​заворот"​ по модулю,​ физическим аналогом которого могут служить обычные механические часы. Хинт: 3 + 11 = 2, а вовсе не 14! Вот так неожиданность! И ищи потом на каком этапе вычислений данные превращаются в винегрет! А искать можно долго и ошибки возникают даже в полностью отлаженных программах,​ стоит только скормить им непредвиденную последовательность входных данных!
 +
 +LIA-1 (см. приложение "​H"​ к Стандарту ANSI C99) говорит,​ что в случае отсутствия "​заворота"​ при переполнении знаковых целочисленных переменных,​ компилятор должен генерировать сигнал (ну, или, в терминах Microsoft, выбрасывать исключение). Поскольку,​ знаковый бит на x86 процессорах расположен по старшему адресу,​ заворота не происходит и некоторые компиляторы учитывают это обстоятельство при генерации кода. В частности,​ GCC поддерживает специальный флаг "​-ftrapv"​. Посмотрим,​ как он работает?​
 +
 +foo(int a, int b)
 +
 +{
 +
 +return a+b;
 +
 +}
 +
 +Листинг 1 исходная функция,​ складывающая два знаковых числа типа int
 +
 +fooproc near
 +
 +pushebp; открываем кадр
 +
 +movebp, esp;​стека
 +
 +moveax, [ebp+arg_4];​ грузим аргумент b в EAX
 +
 +addeax, [ebp+arg_0];​ EAX := (a + b)
 +
 +popebp; закрываем кадр стека
 +
 +retn; возвращаем сумму (a+b) в EAX
 +
 +fooendp
 +
 +Листинг 2 компиляция компилятором GCC с ключами по умолчанию
 +
 +Очевидно,​ что результат работы данной функции непредсказуем,​ и, если сумма двух int'​ов не влезет в отведенную разрядность,​ нам вернется черт знает что. А вот теперь используем флаг -ftrapv:
 +
 +fooproc near
 +
 +pushebp; открываем
 +
 +movebp, esp;​кадр
 +
 +subesp, 18h;​стека
 +
 +moveax, [ebp+arg_4];​ грузим аргумент b в EAX
 +
 +mov[esp+18h+var_14],​ eax; передаем аргумент b функции __addvsi3
 +
 +moveax, [ebp+arg_0];​ грузим аргумент a в EAX
 +
 +mov [esp+18h+var_18],​ eax; передаем аргумент a Функции __addvsi3
 +
 +call__addvsi3;​ __addvsi3(a,​ b); // безопасное сложение
 +
 +leave; закрываем кадр стека
 +
 +retn; возвращаем сумму (a+b) в EAX
 +
 +fooendp
 +
 +
 +
 +__addvsi3proc near
 +
 +pushebp; открываем
 +
 +movebp, esp; кадр
 +
 +subesp, 8; стека
 +
 +mov[ebp+var_4],​ ebx; сохраняем EBX в лок. переменной
 +
 +moveax, [ebp+arg_4];​ грузим аргумент b в EAX
 +
 +call__i686_get_pc_thunk_bx;​грузим thunk в EBX
 +
 +addebx, 122Fh; -> GLOBAL_OFFSET_TABLE
 +
 +movecx, [ebp+arg_0];​ грузим аргумент a в ECX
 +
 +testeax, eax; определяем знак аргумента b
 +
 +leaedx, [eax+ecx]; EDX := a + b
 +
 +jsshort loc_8048410;​ прыгаем если знак
 +
 +; ───────────────────────────────────────────────────────────────────────────
 +
 +; работаем с беззнаковыми переменными
 +
 +cmpedx, ecx; if ((a + b) >= a)
 +
 +jgeshort loc_8048400;​ goto OK
 +
 +loc_80483F5:;​ если ((a + b) < a)…
 +
 +call_abort; то имело место переполнение
 +
 +leaesi, [esi+0]; и мы абортаемся
 +
 +loc_8048400:;​ нормальное продолжение программы
 +
 +movebx, [ebp+var_4];​ восстанавливаем EBX
 +
 +moveax, edx; перегоняем в EAX (a+b)
 +
 +movesp, ebp; закрываем
 +
 +popebp; кадр стека
 +
 +retn; возвращаем (a+b) в EAX
 +
 +; ───────────────────────────────────────────────────────────────────────────
 +
 +loc_8048410:;​ работаем со знаковыми ​
 +
 +cmpedx, ecx; if ((a+b) < a)
 +
 +jgshort loc_80483F5;​GOTO _abort
 +
 +jmpshort loc_8048400;​ -> нормальное продолжение
 +
 +__addvsi3endp
 +
 +Листинг 3 компиляция компилятором GCC с ключом -ftrapv
 +
 +Сложение с флагом -ftrapvбезопасно,​ но… как же оно тормозит!!! Кстати,​ на уровне оптимизации -O2 и выше, флаг -ftrapv игнорируется. Но даже без всякой оптимизации он не ловит переполнения при умножении и что самое печальное,​ поддерживается не всеми компиляторами.
 +
 +===== трюк 2 — пишем закон сами! =====
 +
 +На самом деле, для "​безопасного"​ сложения чисел у нас есть все необходимые ингредиенты. Причем,​ это будет работать с любым компилятором на любом уровне оптимизации и с достаточно приличной скоростью (уж во всяком случае побыстрее,​ чем __addvsi3 в реализации от GGC).
 +
 +Функция безопасного сложения двух переменных типа int в простейшем случае выглядит так:
 +
 +#include <​limits.h>//​ здесь содержатся лимиты всех типов
 +
 +int safe_add(int a, int b)
 +
 +{
 +
 +if(INT_MAX - b < a)return _abort(ERROR_CODE);​
 +
 +return a + b;
 +
 +}
 +
 +Листинг 4 функция безопасного сложения
 +
 +Дизассемблерный листинг не приводится за ненадобностью. Если компилятор заинлайнит safe_add, то мы имеем следующий оверхид:​ одно лишнее ветвление,​ одно лишнее сравнение и одно лишнее вычитание. Конечно,​ в особо критичных фрагментах (да еще и в глубоко вложенных циклах) этот оверхид непременно даст о себе знать, и тогда лучше отказаться от safe_add и пойти другим путем. Например,​ обосновать,​ что переполнения (в данном месте) не может произойти в принципе даже при обычном сложении.
 +
 +===== трюк 3 — отправляемся в плаванье =====
 +
 +Вещественные переменные,​ в отличии от целочисленных,​ работают чуть медленнее,​ хотя… это еще как сказать! С учетом того, что ALU и FPU блоки современных ЦП работают параллельно,​ то для достижения наивысшей производительности,​ целочисленные и вещественные переменные должны использоваться совместно (конкретная пропорция определяется типом и архитектурой процессора).
 +
 +Главное,​ что x86 (и некоторые другие ЦП) поддерживают генерацию исключений при переполнении вещественных переменных,​ хотя по умолчанию она выключена и включить ее, увы, средствами "​чистого"​ языка Си нельзя,​ но вот если прибегнуть к функциями API или нестандартным расширениям….
 +
 +Рассмотрим следующую программу:​
 +
 +#include <​float.h>​
 +
 +#include <​stdio.h>​
 +
 +main()
 +
 +{
 +
 +// объявляем вещественную переменную
 +
 +// (это может быть так же и float)
 +
 +double f = 666;
 +
 +
 +
 +// считываем значение управляющего слова
 +
 +// сопроцессора через MS-specific функцию
 +
 +int cw = _controlfp(0,​ 0);
 +
 +
 +
 +// задействуем исключения для след. ситуаций
 +
 +cw &​=~(EM_OVERFLOW|EM_UNDERFLOW|EM_INEXACT|EM_ZERODIVIDE|EM_DENORMAL);​
 +
 +
 +
 +// обновляем содержимое управляющего слова сопроцессора
 +
 +_controlfp( cw, MCW_EM );
 +
 +
 +
 +__try{// в блоке try мы будем делать исключения
 +
 +
 +
 +while(1)// в бесконечном цикле вычисляем f = f * f
 +
 +{// выводя его содержимое на экран
 +
 +printf("​%f\n",​ f=f * f);
 +
 +}
 +
 +}
 +
 +
 +
 +except(puts("​in filter"​),​ 1)// а тут мы ловим возникающие исключения!
 +
 +{
 +
 +puts("​in except"​);//​ для упрощения обработка исключений опущена
 +
 +}
 +
 +}
 +
 +Листинг 5 активация исключений при работе с вещественными переменными
 +
 +В зависимости от компилятора (и процессора) данный пример будет тормозить в большей или меньшей степени. В частности,​ на x86 вещественное деление _намного_ быстрее целочисленного. С другой стороны,​ компилятор MS VC выполняет вещественное сложение в разы медленнее,​ главным образом потому,​ что не умеет сохранять промежуточный результат вычислений в регистрах сопроцессора и постоянно загружает/​выгружает их в переменные,​ находящиеся в памяти. GCC такой ерундой не страдает и при переходе с целочисленных переменных на вещественные быстродействие не только не падает,​ но местами даже и возрастает.
 +
 +Плюс, вещественные переменные имеют замечательное значение "не число",​ которое очень удобно использовать в качестве индикатора ошибки. У целочисленных с этим — настоящая проблема. Одни функции возвращают ноль, другие минус один, в результате чего возникает путаница,​ а если и ноль, и минус один входят в диапазон допустимых значений,​ возвращаемых функцией,​ приходится не по детски извращаться,​ возвращая код ошибки в аргументе,​ переданном по указателю или же через исключения.
 +
 +А с вещественными переменными все просто! И удобно! И это удобство стоит небольшой платы за производительность!
 +
 +