![](https://habrastorage.org/getpro/habr/upload_files/caa/c65/b26/caac65b26a02fd3847a497bb4f1a7ed6.png)
Неопределённое поведение (undefined behavior, UB) в языке программирования C — постоянный источник жарких споров между программистами. С одной стороны, UB может быть важным для оптимизаций компилятора. С другой стороны, оно упрощает появление багов, которые приводят к проблемам безопасности.
Хорошая новость: N3322 был принят для C2y, что позволит устранить неопределённое поведение из этого конкретного участка языка C и сделать всё показанное ниже чётко определённым:
memcpy(NULL, NULL, 0);
memcmp(NULL, NULL, 0);
(int *)NULL + 0;
(int *)NULL - 0;
(int *)NULL - (int *)NULL;
Это справедливо только тогда, когда нулевой указатель сочетается с операцией «нулевой длины». Следующий пример по-прежнему будет неопределённым поведением:
memcpy(NULL, NULL, 4);
(int *)NULL + 4;
Ожидается, что устранение этого неопределённого поведения не скажется на производительности отрицательно, скорее, совсем наоборот.
Мотивация
Показанные выше примеры довольно дурацкие, потому что в них жёстко прописана константа NULL
/nullptr
. Однако можно легко столкнуться с ситуацией, когда указатель лишь иногда бывает null. Например, рассмотрим типичное описание строки известной длины:
struct str {
char *data;
size_t len;
};
Пустая строка обычно описывается как (struct str) { .data = NULL, .len = 0 }
, с указателем data
, равным NULL
. Теперь рассмотрим функцию, проверяющую две строки на равенство:
bool str_eq(const struct str *str1, const struct str *str2) {
return str1->len == str2->len &&
memcmp(str1->data, str2->data, str1->len) == 0;
}
На первый взгляд такая реализация выглядит вполне разумной. Однако если обе части входных данных оказываются пустыми строками, она демонстрирует неопределённое поведение. В этом случае мы вызовем memcmp(NULL, NULL, 0)
, что согласно стандарту C является неопределённым поведением.
Подобные UB добавляют угрозу того, что компилятор оптимизирует последующие проверки нулевых указателей, удалив их. Например, GCC с радостью удалит ветвь dest == NULL
в показанном ниже коде, а Clang намеренно не выполняет эту оптимизацию:
int test(char *dest, const char *src, size_t len) {
memcpy(dest, src, len);
if (dest == NULL) {
// Эта ветвь будет удалена GCC из-за неопределённого поведения.
}
}
Правильно будет написать функцию str_eq
следующим образом:
bool str_eq(const struct str *str1, const struct str *str2) {
return str1->len == str2->len &&
(str1->len == 0 ||
memcmp(str1->data, str2->data, str1->len) == 0);
}
Новый код корректен, но он хуже во всех других отношениях:
Он увеличивает размер кода, поскольку требует дополнительной проверки в каждом встраиваемом месте вызова.
Он снижает производительность из-за избыточной проверки того, что всё равно должна обрабатывать
memcmp
.Он повышает сложность кода.
В то же время, библиотека C никоим образом не может воспользоваться этим неопределённым поведением для создания более эффективной реализации. От такого UB проигрывают все, и его нужно устранить из языка.
Арифметика нулевых указателей
В исходном предложении был сделан упор на устранение UB для вызовов библиотечных функций в памяти, но ещё на ранних этапах ревьюер указал, что этого будет недостаточно. В конечном итоге, нам также нужно учитывать то, как реализованы эти библиотечные функции.
Например, рассмотрим типичную реализацию функции в стиле memcpy
:
void copy(char *dst, const char *src, size_t n) {
for (const char *end = src + n; src < end; src++) {
*dst++ = *src;
}
}
Эта функция демонстрирует неопределённое поведение при вызове copy(NULL, NULL, 0)
, потому что NULL + 0
— это неопределённое поведение в C.
Чтобы избежать этого и сделать язык в целом более согласованным, нам нужно определить NULL + 0
как возвращающее NULL
, а NULL - NULL
как возвращающее 0. Кроме того, это согласует C с семантикой C++, в котором это поведение уже чётко определено.
Возражения
При обсуждении этого предложения на двух совещаниях WG14 с неожиданной стороны поступили возражения.
Самой спорной частью предложения стало определение NULL - NULL
как возвращающего 0. Причина заключается в том, что когда дело касается адресных пространств (которые не являются частью стандартного C, но могут быть реализованы как расширение), могут существовать множественные представления нулевого указателя. Обеспечение гарантий того, что при вычитании двух «разных» null всё равно получится ноль, может потребовать генерации дополнительного кода, что нарушает условие отсутствия затрат ресурсов на это изменение.
Однако наиболее резкие возражения возникли у людей, занимающихся статическим анализом: если сделать нулевые указатели чётко определёнными для нулевой длины, то статические анализаторы больше не смогут безусловно сообщать о том, что функциям наподобие memcpy
передаётся NULL
, теперь им придётся учитывать и длину. Если в будущем будет добавлен квалификатор _Optional
, то аргументы memcpy
должны будут квалифицироваться им. Разработчики GCC рассматривают возможность добавления нового атрибута nonnull_if_nonzero
для описания нового начального условия.
Несмотря на достаточно негативную дискуссию, я был удивлён тем, насколько положительно было воспринято изменение при голосовании; более того, было рекомендовано реализовать изменение ретроактивно в старых версиях стандарта. Это означает, что после того, как компиляторы и библиотеки C внедрят это изменение, оно должно будет применяться даже без указания флага -std=c2y
.
Встроенные функции компиляторов
Я работаю над промежуточным слоем тулчейна компилятора LLVM. Я не касаюсь видимых пользователям частей компилятора, поэтому чаще всего не вовлечён в работу по стандартизации.
В этом случае я оказался вовлечён из-за спецификации внутренней встроенной функции memcpy LLVM:
Встроенные функции
llvm.memcpy.*
копируют блок памяти из источника в получатель, которые должны быть или равны, или не пересекаться. [...]Если
<len>
равно 0, то это no-op в зависимости от поведения прикрепленных к аргументам атрибутов. [...]
Встроенная функция llvm.memcpy
может опуститься для вызова функции memcpy
, которая в данном случае обрабатывается как «встроенная функция времени компиляции», несмотря на то, что в конечном итоге она тоже предоставляется библиотекой C.
При использовании в качестве встроенной функции LLVM требует, чтобы и memcpy(x, x, s)
, и memcpy(NULL, NULL, 0)
были чётко определены, даже несмотря на то, что согласно стандарту C они представляют собой UB. В GCC и MSVC используются схожие допущения.
Если мы сделаем memcpy(NULL, NULL, 0)
официально чётко определённой, то избавимся от одного из допущений, а случай с memcpy(x, x, s)
пока сохранится. Обеспечение этого изначально тоже входило в предложение, но позже от него отказались, потому что это плохо сочеталось с другими изменениями.
Забавно, что это изменение в стандарте C появилось, потому что разработчики на Rust постоянно жаловались мне на несоответствие семантик LLVM и C.
Благодарности
Эта статья была написана совместно с Аароном Болменом, который также руководил дискуссией на совещаниях WG14. Выражаю особую благодарность Дэвиду Стоуну, чьи отзывы на ранних этапах написания статьи радикально сменили направление разработки предложения с вызовов библиотеки работы с памятью на операции с нулевой длиной в целом.