Мы все сталкивались с проблемами, возникающими при неправильной работе с указателями: выход за пределы массива и переполнение буфера, случайная запись в неизвестный кусок памяти, с последующим чтением этого «мусора» в другом месте, а в некоторых отдельных случаях и просто падение всей системы. Иногда это просто «дичь», господа! И нужно уметь обходится с этой «дичью» правильно – вовремя находить и исправлять подобные ошибки и проблемы. Именно этим занялись в «плюсовом» компиляторе Intel ещё несколько релизов тому назад. Кроме того, многие идеи пошли дальше и будут реализованы в «железе» через технологию Intel® Memory Protection Extensions. Давайте-ка посмотрим, как всё это работает в компиляторе.
«Как бы было хорошо, если бы была такая опция компилятора, которая позволяла бы находить ошибки с указателями сразу в коде, изменяла его и давала на выходе отлаженное рабочее приложение», – мечталось одному разработчику. На самом деле такого нет, и, вроде как, не планируется. Компилятор Intel дает только средство динамической проверки кода. Это означает, что нам, как обычно, нужно подключить одну из магических опций, собрать с ней код и запустить приложение на исполнение, получив при этом ошибку, в причинах которой будет легко разобраться. Вот и весь путь. В деталях же это выглядит так.
С помощью фичи Pointer Checker мы можем отлавливать работу с памятью через указатели во всем приложении. Для этого у каждого указателя определяется нижняя и верхняя допустимые границы, которые проверяются при работе с памятью и гарантируют корректную работу. Естественно, эта информация хранится в специальной табличке по какому-то адресу. В ней мы можем для любого указателя найти значения нижней (lower bound) и верхней (upper_bound) границы.
В простейшем случае, если мы выделили память для p через malloc(size), то в lower_bound(p) будет адрес (char *)p, а в upper_bound(p) — адрес (lower_bound(p) + size – 1). И в самом тривиальном примере это позволит обнаружить проблему:
char * buffer = (char*)malloc(5);
for (int i = 0; i <= 5; i++)
buffer[i] = 'A' + i;
В массиве только 5 элементов, и при попытке записи в адрес, превышающий допустимую верхнюю границу, мы получим ошибку времени выполнения, говорящую о выходе за границы.
Выглядеть будет примерно так:
CHKP: Bounds check error ptr=0X012062ED sz=1 lb=0X012062E8 ub=0X012062EC loc=0X0
0131149
Traceback:
wmain [0x131149]
in file C:ConsoleApplication1.cpp at line 12
__tmainCRTStartup [0x13F959]
in file f:ddvctoolscrtcrtw32dllstuffcrtexe.c at line 623
wmainCRTStartup [0x13FA9D]
in file f:ddvctoolscrtcrtw32dllstuffcrtexe.c at line 466
BaseThreadInitThunk [0x76D3919F]
RtlInitializeExceptionChain [0x77550BBB]
RtlInitializeExceptionChain [0x77550B91]
CHKP Total number of bounds violations: 1
Очевидно, что наш указатель оказался вне допустимого диапазона. Значение ptr равно 0x012062ED, а верхняя граница должна быть не больше ub, которая равна 0x012062EC. При этом мы получаем traceback и можем легко найти проблемное место. Всё это будет происходить при условии, что приложение мы собрали с ключом Qcheck-pointers (Windows), который из Visual Studio можно выставить в вкладке C/C++ -> Code Generation -> Check Pointers. Для случая с Линуксом используем ключ -check-pointer. Если вы не поленились и честно пошли выставлять его через интерфейс VS под Windows, то, вероятно, заметили что там есть разные режимы работы Pointer Checker’а:
- Check bounds for reads and writes (/Qcheck-pointers:rw)
- Check bounds for writes only (/Qcheck-pointers:write)
- Check bounds for reads and writes, using Intel® MPX (/Qcheck-pointers-mpx:rw)
- Check bounds for writes only, using Intel® MPX (/Qcheck-pointers-mpx:write)
Две последние опции пока ничего не дадут при реальном запуске приложения, потому что железа для его использования ещё нет в доступности. Собственно, обычная практика, когда функционал в софте появляется чуть раньше. Аналогичное происходит и с другими технологиями, скажем AVX.
Для нас интерес представляет возможность проверять указатели как при операциях чтения и записи, так и только при записи. Скажем, используя опцию Qcheck-pointers:write, мы не получим ошибок при выходе за установленные границы указателя buffer при операции чтении. Например в таком случае, при условии правильной инициализации массива:
for (int i = 0; i <= 5; i++)
printf("%c", buffer[i]);
Скомпилировав же с ключом Qcheck-pointers:rw, будем отлавливать все случаи, включая чтение. Кстати, при передачи указателя в функцию, информация о границах так же сохраняется.
Есть ещё одна интересная особенность: нужно уметь различать понятия работы с памятью и простой арифметики с указателями. Пример:
char *p = (char *)malloc(100);
p += 200;
p[-101] = 0;
p[0] = 0;
В первом выражении мы только сдвигаем указатель, а затем обращаемся к памяти, находящейся в валидной области – p[-101] в данном случае является p[99]. Поэтому всё проходит гладко. Ошибка выхода за границы случится только на последней строчке, потому что мы, фактически, пытаемся записать в p[200].
Существует специальный алгоритм для нахождения висящих (dangling) указателей для чего используется опция Qcheck-pointers-dangling (её нужно указывать вместе с Qcheck-pointers). Это те случаи, когда память уже очищена, а мы упорно пытаемся что-то сделать через указатель. Если продолжать наш пример с буфером, что-то из этого разряда:
free(buffer);
printf("%c", buffer[2]);
При этом без дополнительной установки Qcheck-pointers-dangling данный случай не будет рассматриваться как ошибка. Если же прописать Qcheck-pointers-dangling, то компилятор будет использовать специальную обертку для функций free и оператора delete. Она находит все указатели с очищенной памятью и выставляют значения нижней границы в 2, а верхней в 0. Таким образом, любая попытка работать с памятью через этот указатель приведет к ошибке. В рассмотренном примере ошибка будет выглядеть так (traceback информацию убрал для компактности):
CHKP: Bounds check error ptr=0X007F62EA sz=1 lb=0X00000002 ub=00000000 loc=0X00DB11D5
Кстати, если у нас есть своя реализация функция для работы с памятью, мы можем включить в неё функцию проверки висящих указателей с помощью вызова функции __chkp_invalidate_dangling, объявленной в chkp.h.
Пример функции, выполняющей очищение памяти, будет выглядеть так:
#include <chkp.h>
void my_free(void *ptr)
{
size_t size = my_get_size(ptr);
// do the free
__chkp_invalidate_dangling(ptr, size);
}
В заключение скажу, что возможностей у Pointer Checker’a очень много и всё это нужно пробовать ручками. Например, есть возможность выборочно компилировать один или несколько модулей с проверкой указателей, а другие — без. Кроме того, доступны многие API функции для более гибкой работы. Стоит отметить, что Pointer Checker поддерживается только под Windows и Linux, на Mac его нет.
Существует и обратная сторона медали – выполнение приложения существенно замедляется (минимум, в два раза, но, не более чем в 5). Естественно, размер кода так же увеличивается. Тем не менее, для целей отладки приложения функциональность очень интересная, а с её реализацией в «железе» все будет гораздо эффективнее.
Ну и напоследок небольшой вопрос. Как вы думаете, как всё это дело работает в многопоточных приложениях? Учитывая, что каждый раз при обращении к указателю мы читаем или записываем информацию о границах, тратя несколько инструкций, и тот факт, что разные потоки могут попытаться записать разную информацию для одного и того же указателя?
Автор: ivorobts