
Неопределённое поведение (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. Выражаю особую благодарность Дэвиду Стоуну, чьи отзывы на ранних этапах написания статьи радикально сменили направление разработки предложения с вызовов библиотеки работы с памятью на операции с нулевой длиной в целом.
Комментарии (20)
viordash
13.02.2025 07:31NULL - NULL как возвращающее 0
мне кажется зря это пытаются определить.
И считаю правильным, что для memXXX null это UB. В любом случае в них не должен null попадать. Есть же пропозалы на nullability аттрибуты, и это позволит в compile-time проверять, да и код станет чище и быстрее без лишних проверок.Wesha
13.02.2025 07:31без лишних проверок.
Ви так говорите, как будто это что-то хорошее.
brat_viktor
13.02.2025 07:31Да, это - быстродействие
Wesha
13.02.2025 07:31это — быстродействие
Ви так говорите, как будто Ви куда‑то настолько торопитесь, что Вам 3 ГГц — и тех не хватает.
А по‑хорошему «[библиотечный] метод должен принимать любые значения, включая потенциально ошибочные».
qwerty19106
13.02.2025 07:31По хорошему должно быть 2 библиотечных метода:
1) принимает любые значения, проверяет их на ошибки.
Название короткое и удобное.2) принимает любые значения, не проверяет их на ошибки (UB)
Длинное неудобное название (натипа xyz_unchecked), и требуется явное указание компилятору, что ты всё делаешь правильно.vanxant
13.02.2025 07:31По хорошему, у методов должны быть две точки входа - основная и "быстрая". А между ними должны располагаться пролог с проверками входных данных и ранними return-ами (как в данном случае, если размер 0 то вернуть ок).
Пролог должен быть частью определения функции и доступен компилятору вызывающего кода.
Для неявных вызовов (по указателю, например) - используется основная точка входа.
Для явных, когда функция и её пролог точно известны на момент компиляции, компилятор вызывающего кода может заинлайнить пролог и проверить, не приводит ли это к выкидыванию части проверок. Например, если мы проверили размер на ноль где-то выше, то в ветке где 0 мы можем вообще выкинуть вызов memXYZ. А в ветке где не ноль, то можем выкинуть вторую проверку и прыгать сразу в быструю точку входа.
qwerty19106
13.02.2025 07:31Согласен.
Но у программиста все-равно должен быть способ прыгнуть сразу в "быструю" точку входа, т.к. компилятор не всегда может выкинуть проверки.Например, сейчас я пишу реализацию кольцевого буфера. И в методе
write
проверяю что размер входных данных (count
) меньшеsize_t / 2
. Размер буфера тоже меньшеsize_t / 2
.Это значит что любые проверки на переполнение для
head + count
иtail + count
можно выкинуть, но компилятор об этом никак не догадается.
viordash
13.02.2025 07:31Если компилятор гарантирует, что переменная не может быть NULL, то зачем тратить ресурсы на проверку того, что невозможно? Ресурсы не только у cpu, но и написание кода, добавление теста на бранч с null и т.п.
Насчет библиотечных методов частично соглашусь, так как предполагаю, если в стандарт занесут нулябельность, то c либами будут нюансы. И в них в публичных методах придется проверять NULL. Но библиотеки это еще не все программирование.Wesha
13.02.2025 07:31Если компилятор гарантирует, что переменная не может быть NULL, то зачем тратить ресурсы на проверку того, что невозможно?
Современные компиляторы достаточно умные для того, чтобы на этапе компиляции выкинуть эту проверку как «всегда false и потому никогда не исполняющуюся».
viordash
13.02.2025 07:31если возможно, то покажите пример си кода, чтобы выкинуло проверку. В недавнем прошлом, я столкнуся с подобным желанием, но не смог добиться от gcc\clang такой оптимизации в простом методе модификации строки. Проверка оставалась всегда, а статичным метод нельзя было сделать.
Serge3leo
13.02.2025 07:31"нулябельность" там была, почти всегда. Это ж ещё надо постараться, что б найти проблему с тем, что у чего-то указатель NULL, если его длина 0. Это больше общий случай, нежели частный.
Но неконтролируемое расширение толкования UB сверх всяких разумных мер, и в этом месте, тоже потребует кода для выделения этого частного случая (NULL длины 0) из обобщённого кода работы.
Работает - не трогай. Тем более, что это ничего полезного не принесёт.
Serge3leo
13.02.2025 07:31Хм, а это ничего, что в стандарте C написано "`malloc()` ... If size is zero, the behavior of malloc is implementation-defined. For example, a null pointer may be returned. Alternatively, a non-null pointer may be returned; but ..." ?
Если работу с NULL длины 0 объявить UB, то потребуется проанализировать прорву наследованного кода на этот предмет, и добавить кучу проверок на предмет длины 0 с обходом. О каком быстродействии может идти речь, при необходимости доработок такого рода?
Впрочем, и в новом коде, обход работы с NULL длины 0 только добавит всяких `if`. К примеру, если
malloc(0)
будет возвращать не 0, а 42, то иfree(ptr)
должно будет сравнивать ptr не только с 0, но и 42. И т.д. и т.п.И это только первый контрпример, который сразу вспомнился лично мне. Реально их гораздо больше.
vanxant
13.02.2025 07:31Поясню, почему
NULL - NULL == 0
идея весьма сомнительная.Потому что в ОС нулевыми считаются указатели не только с адресом 0, а вообще с любыми малыми адресами. В частности, на amd64 в большинстве ОС нулями считаются все адреса меньше 2Мб, а на солярке - меньше 4 Гб. ОС тупо не выделяют физическую память под эти адреса, а 2 Мб — это размер "средней" страницы в амд64.
Зачем это надо? Ну потому что запись вида
x[3].y
вполне себе даст ненулевой адрес, даже еслиx == 0
.KivApple
13.02.2025 07:31В Си NULL это константа, имеющее одно значение так или иначе.
NULL + 1 пройдёт любую проверку на NULL, так как x == NULL способен сравнить только с одним значением.
Компилятору пофиг, какие зарезервированные адреса у разных ОС (да и не отличаются принципиально зарезервированные адреса от просто ещё не спроецированных, кроме того что зарезервированные точно не выдаст тебе системный аллокатор). Это чисто мера runtime перестраховки такая же как и, например, рандомизация кучи. Учитывать это не требуется в 99% программ. И компилятор тоже не учитывает в кодогенерации.
vanxant
13.02.2025 07:31NULL + 1 пройдёт любую проверку на NULL,
Зависит от стандарта и компилятора. Сейчас скорее всего да, но так было не всегда. Скажем, в солярке 20 лет назад проверка на NULL выполнялась только для старших 32 бит адреса, независимо от младших, и это было явно определено в руководстве к штатному компилятору. Да и сам NULL официально приравняли к ((void*)0) опять же относительно недавно (позже, чем завезли nullptr в плюсы)
Serge3leo
ЕМНИП, на AIX, `malloc(0)` возвращал `NULL`.
Поскольку по POSIX `free(NULL)` - норма жизни, это не вызывало принципиальных проблем. В общем, отрадно, что они до этого добрались, и иногда сужают области этих новомодных UB.
kmeaw
С malloc(0)==NULL случай интересный, это implementation-defined поведение, которому есть разумное объяснение.
Бывает полезно, когда malloc(0) возвращает то же, что и malloc(1) - это позволяет написать обобщённый код, одновременно выделяющий память под структуру (в том числе нулевого размера) и использующий указатель в качестве уникального идентификатора в какой-то другой структуре данных.
Serge3leo
Прошу прощения, всем известно, что нультерминированые строки - тормоза, каких мало. И вот был код, работал спокойно, многими десятилетиями:
Но тут понабежали адепты UB имени последнего, то ли draft, то ли С23. Вот чего ради, даже этот абсолютно корректный код пытаться обозвать UB?
Ну, да, `memcpy(NULL, NULL, 0)`, и что? Последние 50 лет никому не мешал, на всех платформах, и тут здравствуй дерево. Ну, это так, умозрительный пример, в реалиях, используется не
malloc()
в хардкоде, а ссылка на функцию оптимального аллокатора для данного приложения, и т.д..И подобных вариантов использования - легион.
И слава Богу, что им хоть на этом последнем рубеже, надавали по щам и отправили в пешее эротическое... Подозреваю, если чуть-чуть призадуматься и копнуть хоть на сантиметр, то окажется, что 2/3 новомодных UB имеет похожую природу.
Явно, в комитет нужен кто-то типа, или Т..., или П..., т.к. "повесточка" давно вышла из под контроля.
15432
Ну как абсолютно корректный.. Однажды я захотел намеренно вызвать segfault и написал разыменование ноля. Компилятор выкинул опкод присвоения, будто его и не было, а в значении переменной оказалось вообще значение соседнего регистра. И это было достаточно неожиданно.
Serge3leo
Забавное поведение компилятора. Хотя, конечно, при работе с NULL длины 0 разыменование не происходит, но эти "забавы" пугают.
И, как видим, многих пугают, если "рекомендовано реализовать изменение ретроактивно". Похоже, новые стандарты на компиляторы, способны сделать хороший код ошибочным и/или опасным.