Неопределённое поведение (Undefined Behavior, UB) в C и C++ — одна из причин, по которым разработчики всё чаще ищут языки с полностью определённой семантикой. Одним из самых коварных UB является unaligned access, с точки зрения стандарта C это, например, когда происходит попытка разыменовать указатель как uint32_t, а значение указателя (адрес) не кратно четырём. Один из частых сценариев использования, приводящих к такому UB - получение данных по сети и их интерпретация как чисел.
Почему unaligned access сделали UB в C
На момент утверждения стандарта C89 актуальными процессорами были Intel 80386/80486 (и старее), Motorola 68000, первые SPARC и ряд других. Вероятно, разработчики стандарта C89 исходили из реалий того как работали CPU тех времён и, если 386-е уже справлялись с unaligned access (с нюансами), то к примеру на m68k это вызывало исключение.
Если бы разработчики C89 сделали поведение детерминированным, то компилятору (на архитектурах без поддержки невыравненного доступа) пришлось бы вставлять условные переходы в зависимости от того выравнен адрес или нет, что увеличило бы размер программы (на тот момент это было актуально) и замедлило бы её. Таким образом, разработчики стандарта переложили ответственность за выравнивание на разработчиков.
На сегодняшний день возможны разные варианты реализации unaligned access в CPU:
реализовано и работает быстро (быстрее чем делать несколько выравненных обращений)
реализовано и работает медленно (сделать несколько выравненных обращений к памяти будет быстрее чем одно невыравненное)
не реализовано (приводит к генерации ошибки). на ряде архитектур эта ошибка содержит достаточно информации чтобы ее можно было перехватить и "исправить" (обычно этим занимается ядро ОС, но очевидно, что этот механизм очень медленный)
Чтение int по невыравненному адресу на разных архитектурах
Рассмотрим простейший пример
// 1.c
int main(int argc, char **argv) {
int *p = (int *)(argv[0] + argc);
return *p;
}
Если запускать эту программу без параметров, то argc=1 и происходит чтение невыравненных данных (предполагаю что argv выравнен по sizeof(char *)). Такой код с точки зрения стандарта C является UB если адрес (p) не выравнен по sizeof(int).
На x86_64 и arm64 с подобным кодом проблем не будет, мало того gcc и clang даже не ругнутся на него с опциями -Wall -Wextra -Wpedantic. UBSAN также не будет выдавать никаких предупреждений если запустить это на x86_64.
Для того, чтобы gcc начал выдавать предупреждения по выравниваниям, когда подобный код компилируется для x86_64, надо добавить опцию -Wcast-align=strict. Практический смысл в этом может быть в том, чтобы заранее узнать о потенциальных проблемах до внедрения сборки (и запуска с UBSAN) на платформах, где unaligned access имеет значение.
В любом случае, рассматриваемый выше код это UB, а значит это потенциальная мина замедленного действия. Чтобы избавиться от UB есть два основных варианта переписать этот код:
// 2.c
#include <string.h>
int main(int argc, char **argv) {
int x;
memcpy(&x, argv[0] + argc, sizeof(int));
return x;
}
(в условиях отсутствия libc, надо найти аналог memcpy, например builtin memcpy компилятора). Такой вариант с memcpy не содержит UB и отлично оптимизируется компиляторами. На x86_64 и arm64, gcc-15 и clang-21 на уровне оптимизации >=O1 генерирует точно такой же машинный код как и в варианте без memcpy, т.е. компилятор фактически вырезает memcpy зная что на x86_64 и arm64 unaligned access работает и работает быстро (быстрее чем вычитывать по байту и собирать из них int). Примечание: при использовании _FORTIFY_SOURCE memcpy может быть не оптимизирован.
Другим вариантом избавиться от UB является использование GNU extension (__attribute__((__packed__))
):
// 3.c
struct __attribute__((__packed__)) safe_int {
int val;
};
int main(int argc, char **argv) {
struct safe_int *safe = (struct safe_int *)(argv[0] + argc);
return safe->val;
}
Это расширение поддерживается GCC и clang, некоторые другие компиляторы имеют аналогичные расширения. Такой вариант кода тоже не содержит UB, но не является совместимым с ANSI C. Как и вариант с memcpy, на x86_64 и arm64 этот код прекрасно оптимизируется GCC и clang и получим такой же машинный код как в изначальном варианте с явным unaligned access.
На платформе riscv64 начинается самое интересное. На реальном riscv64-железе, на котором очень медленно работает unaligned access (см. 1 и 2), вариант с явным unaligned access будет работать значительно медленнее чем варианты с memcpy/gnu extension (и это может быть в ~150 раз медленнее). GCC15 по умолчанию не превращает второй и третий вариант в unaligned access, потому что знает что на riscv64 это может работать очень медленно (генерировать трап с последующим исправлением или медленнее чем побайтовый доступ)
riscv64-redhat-linux-gcc-15 -O3 1.c -o prog1
riscv64-redhat-linux-gcc-15 -O3 2.c -o prog2
; prog1
ld a5,0(a1)
add a5,a5,a0
lw a0,0(a5)
ret
; prog2
ld a5,0(a1)
addi sp,sp,-16
add a0,a0,a5
lbu a2,0(a0)
lbu a3,1(a0)
lbu a4,2(a0)
lbu a5,3(a0)
sb a2,12(sp)
sb a3,13(sp)
sb a4,14(sp)
sb a5,15(sp)
lw a0,12(sp)
addi sp,sp,16
ret
Возникает справедливый вопрос - а как заставить GCC/clang скомпилировать второй или третий пример (2.c/3.c) чтобы, по аналогии с x86_64 и arm64, использовался unaligned access вместо побайтового доступа к данным. Ведь существуют riscv64 CPU, где unaligned access работает быстрее побайтовой загрузки, например T-HEAD c906, Tenstorrent Ascalon 8 wide и SpacemiT X-60.
Для clang-20 достаточно добавить опцию '-mno-scalar-strict-align', после чего 2.c будет иметь такой же машинный код как 1.c.
GCC нужно еще дополнительно убедить что unaligned access быстрый, для этого либо явно задать -mcpu (выбрав тот где он действительно быстрый), либо задав -mtune (опять выбрав тот где он действительно быстрый или же size (тюнинг под размер)).
Конечно самый простой способ задать -mcpu под конкретный CPU (семейство/микроархитектуру), например clang-20 -O3 -mcpu=spacemit-x60 2.c
, тогда GCC и Clang сами разберутся какие оптимизации надо включать, но очевидно, что тогда придется собирать разные бинарники под разные платформы. В обратную сторону такой подход не работает, т.е. если пытаться скомпилировать первый пример (1.c) с -O3 и задав -mcpu, про который компилятор знает, что у него медленный unaligned access, компилятор (gcc-15 и clang-20) оставят unaligned access.
Кстати, в Debian Wiki предлагается просто избегать unaligned access на riscv64. С точки зрения автора статьи, лучше писать код вообще без UB (в частности, в случае с unaligned access, через memcpy или __attribute__((__packed__))
) и полагаться на компилятор и если компилятор решит что unaligned access ускорит работу и безопасен, значит, так и есть (хотя от багов в компиляторе никто не защищен).
В ядре Linux для riscv64 реализовано runtime измерение скорости работы unaligned access vs byte access, а результат этого измерения используется в функции do_csum (вычисление CRC). Само вычисление CRC активно используется в сетевой подсистеме ядра (в случае когда вычисление контрольной суммы невозможно заофлодить на сетевую карту или если такой offload выключен). Такой подход позволяет собирать "универсальные" ядра, т.е. запускать одно и то же ядро на CPU с быстрым и медленным unaligned access и эффективно вычислять CRC на обоих. Единственный минус такого подхода в том, что в коде всё равно остаётся UB с точки зрения стандарта C.
unaligned access и векторизация
Возьмём пример отсюда:
// 5.c
#include <inttypes.h>
#include <stdlib.h>
#include <sys/mman.h>
int main()
{
uint32_t sum = 0;
uint8_t *buffer = mmap(NULL, 1<<18, PROT_READ,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
uint16_t *p = (buffer + 1);
int i;
for (i=0;i<14;++i) {
//printf("%d\n", i);
sum += p[i];
}
return sum;
}
Этот код является UB с точки зрения стандарта C. На старых версиях GCC, при векторизации на платформе x86_64 использовалась инструкция movdqa (где последняя 'a' это aligned) вместо movdqu. Компилятор использовал инструкции требующие выравнивания памяти вместо специальных, делая сомнительное предположение что данные выравнены. Современные версии GCC и clang успешно справляются с этим примером при компиляции этого кода для x86_64-платформы и используют movdqu.
При попытке скомпилировать (ванильный clang-21 и gcc-14 от вендора CPU) этот пример с -O3 и явным заданием -mcpu=spacemit-x60 и запустить на CPU spacemit-x60 возникает segfault (bus error), при этом механизм, работающий для скаляров (поймать trap и исправить) не работает
; prog5
addi sp,sp,-16
sd ra,8(sp)
lui a1,0x40
li a2,1
li a3,34
li a4,-1
li a0,0
li a5,0
jal <mmap@plt>
addi a1,a0,1
vsetivli zero,8,e16,mf2,ta,ma
vle16.v v8,(a1) ; (!) падает здесь, пытаясь читать по два байта с нечетного адреса
addi a1,a0,17
vsetivli zero,4,e16,mf4,ta,ma
vle16.v v9,(a1)
lhu a1,25(a0)
vsetivli zero,8,e32,m1,ta,ma
vzext.vf2 v10,v8
lhu a0,27(a0)
vsetivli zero,4,e16,mf4,ta,ma
vwaddu.wv v8,v10,v9
vsetivli zero,4,e32,m1,tu,ma
vmv.v.v v10,v8
vmv.s.x v8,a1
vsetivli zero,8,e32,m1,ta,ma
vredsum.vs v8,v10,v8
vmv.x.s a1,v8
addw a0,a0,a1
ld ra,8(sp)
addi sp,sp,16
ret
Если переписать код без UB (например с помощью memcpy), то векторизация остаётся, но теперь компилятор не использует vle16.v, а заменил их на vle8.v, т.е. загружает данные из памяти в векторные регистры побайтно, а не по два байта.
// 6.c
#include <inttypes.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <string.h>
int main()
{
uint32_t sum = 0;
uint8_t *buffer = mmap(NULL, 1<<18, PROT_READ,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
int i;
for (i=0;i<14;++i) {
//printf("%d\n", i);
uint16_t val;
memcpy(&val, buffer + 1 + 2 * i, sizeof(uint16_t));
sum += val;
}
return sum;
}
Разница в asm-коде для 5.c и 6.c:
2c2
< 5.out: file format elf64-littleriscv
---
> 6.out: file format elf64-littleriscv
115,116c115,116
< vsetivli zero,8,e16,mf2,ta,ma
< vle16.v v8,(a1)
---
> vsetivli zero,16,e8,mf2,ta,ma
> vle8.v v8,(a1) ; здесь vle16.v заменен на vle8.v
118,120d117
< vsetivli zero,4,e16,mf4,ta,ma
< vle16.v v9,(a1)
< lhu a1,25(a0)
121a119,120
> vle8.v v9,(a1)
> lhu a1,25(a0)
В исходниках clang это отражено следующим образом: для spacemit-x60 в профиле CPU есть FeatureUnalignedScalarMem, но нет FeatureUnalignedVectorMem. Если скомпилировать например с -mcpu=sifive-p470, то для примера 6.c будет использоваться vle16.v
С точки зрения стандартного профиля RISC-V, данная особенность говорит о том что CPU spacemit-x60 не соответствует требованиям RVA20U64.
unaligned access и другие аспекты (атомарность, floating point)
Кроме скаляров и векторов, могут быть проблемы с атомарностью операций и риски использования floating point types с unaligned access (см. 1 (A3.5.3), 2)
Заключение
unaligned access в C (и C++) является UB по стандарту и работоспособность такого кода зависит в первую очередь от поддержки соответствующих операций на аппаратном уровне
Явный unaligned access в коде может привести не только к падению, но и к значительной деградации производительности на некоторых платформах, особенно когда это работает в режиме trap+fixup
Есть смысл добавить -Wcast-align в CFLAGS если целевые платформы не только x86_64
Код без UB + (хороший) оптимизирующий компилятор это действительно переносимый, корректно работающий код и высокая производительность (но, возможно, придется подобрать ключи компиляции вплоть до задания конкретного cpu и проверки сгенерированного (asm) кода)
Комментарии (22)
antperetz
01.09.2025 16:42На Intel процессорах можно через флажки включить BUS ERROR для невыровненных данных, я когда раньше писал код для Sparc процессоров, пользовался этим, чтоб не попасть впросак.
#include <stdio.h> int main() { # if defined(__i386__) /* Enable Alignment Checking on x86 */ __asm__("pushf\norl $0x40000,(%esp)\npopf"); # else /* Enable Alignment Checking on x86_64 */ __asm__("pushf\norl $0x40000,(%rsp)\npopf"); # endif int a = 0xffffff; char *c = (char*)&a; c++; int *p = (int*)c; *p = 10; //Bus error as memory accessed by p is not 4 or 8 byte aligned printf ("%x\n", *p); }
svl87 Автор
01.09.2025 16:42Всё никак руки не дойдут найти то место в коде UBSAN где отключается (или почему оно там не детектится) проверка alignment на x86_64. Если собрать с -fsanitize=undefined под arm64 (или riscv64), то UBSAN сообщает о misaligned address.
White__Owl
01.09.2025 16:42Потому что alignment на большинстве платформ не является обязательным. На x86_64 он желателен с точки зрения работы с кешами, но вовсе не обязателен.
А то что UBSAN вам сообщает - это не undefined behavior а слегка усложненная оптимизация работы с памятью. К стандарту языка это вообще никак не относится.
White__Owl
01.09.2025 16:42Да, некоторые платформы не могут использовать не-кратные адреса. Но как и у автора статьи у вас идёт подмена типов - а это и есть источник ошибки. При нормальном написании программы - char данные читаем через char*, а int данные через int* - даже на Spark не будет проблем. Но если вы принудительно путаете типы данных - ну увы...
ruomserg
Вопрос - а почему это именно Undefined, а не Implementation Defined Behavior ?! В существующей трактовке (правда, этим славятся C++ компиляторы - в C это не так явно выражено) - UB это снятие любых тормозов компилятора, и возможность приписать коду с UB любые свойства, удобные для дальнейшей оптимизации. Опять же, C++ предпочитает в силу тяжелой судьбы оптимизировать код методом удаления - поэтому код с UB обычно приводит к исчезновению целых кусков того, что вы написали в исходнике...
ИМХО тут в чистом виде Implementation Defined, а не UB. Конструкция легальная, код должен быть сгенерирован - а уж сработает он или нет - читайте руководство к своему компилятору и процессору...
svl87 Автор
Например, в стандарте C11 есть такое: A pointer to an object or incomplete type may be converted to a pointer to a different object or incomplete type. If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined
White__Owl
Аааа... вот где собака порылась!
Вы не правильно перевели фразу с английского.
If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined
Это не имеет ничего общего с кратностью адреса. Более правильным переводом для aligned for reference type - будет: "сочетается с используемым типом".
В вашем первом примере у вас
это полнейшая чушь с точки зрения типизации. - берем адрес указывающий на первый символ строки (которая содержит имя исполняемого файла), прибавляем к нему единицу и пытаемся прочитать как число. То есть у вас указатель на int читает из указателя на char. Вот это и есть UB в вашем коде, то самое о чём предупреждает текст стандарта. Естественно у вас происходит что-то непонятное и непредсказуемое... Всю остальную статью даже по диагонали читать нет смысла.
Если вам хочется, то можно легко увидеть работу с адресами не кратными четырем на таком примере:
Вы удивитесь, но адрес `a.i2` в этом примере не кратен четырём, но всё работает.
svl87 Автор
Статья как раз о том что так ("берем адрес указывающий на первый символ строки (которая содержит имя исполняемого файла), прибавляем к нему единицу и пытаемся прочитать как число") делать не надо, а надо использовать memcpy (который обычно прекрасно оптимизируется) или __attribute__(packed)
argv используется просто как пример внешних данных, чтобы минимизировать пример кода. в реальности это может быть сетевой пакет, в который положили int без выравнивания (потому что протокол такой)
aamonster
Как обычно: потому что UB компилятор воспринимает, как контракт с программистом ("я никогда не буду этого делать"), и может, исходя из этого контракта, применять какие-то оптимизации. Нет никаких других причин, чтобы что-то было UB, а не implementation-defined.
BorisU
Причина простая, на каком-то железе это тупо упадет
aamonster
Это было бы implementation-specific. Описано, что на этом железе падает – значит, падает.
Но штука именно в том, что программист обязан дать компилятору гарантию, что UB не будет. Например, есть у нас указатель, мы проверяем его младший бит и если тот равен 1 – выполняем какой-то код. Ok, работает. А теперь добавляем после if разыменование этого указателя – фигак, и наш код волшебным образом вырезало, потому что теперь указатель "гарантированно" кратен 4 (или 8).
А был бы implementation-defined – прога бы выполнила наш код, а потом упала.
svl87 Автор
в каких-то случаях проверить что адрес выравнен (потратить немного тактов на это) действительно может иметь смысл если дальше идут "тяжелые" вычисления, это делается например здесь https://github.com/torvalds/linux/blob/c8bc81a52d5a2ac2e4b257ae123677cf94112755/arch/riscv/lib/csum.c#L306 . Но встраивать это в стандарт C никто не собирается, потому что это обяжет компиляторы добавлять такие проверки везде, даже там где данные выравнены (приходят такими извне), но просто доказать это на этапе компиляции невозможно
aamonster
Мы уже про более тонкое различие, когда компилятор не просто позволит программе упасть при обращении по этому адресу, а полагается на то, что программа не упадёт. Грубо говоря, код
имеет право не напечатать bad pointer перед падением.
ruomserg
И вот этого я уже не понимаю. Есть код, который написал разработчик. Задача компилятора - сгенерировать эквивалентный ему машинный код! Это же даже не цикл, где можно вынести инвариант за тело, и сэкономить много машинных тактов. И не assert который можно выключить в продакшн-версии... Если компилятору даются такие широкие права по интерпретации намерений программиста (зачем?!) - надо вводить в язык новое ключевое слово, которое заставляет компилятор сгенерировать код следующего оператора (или блока) "как тут написано": verbatim if(p&1)...)
coctic
Затем же, зачем компилятор имеет право выкинуть код для
В моем примере компилятор знает, что
0&p
- всегда 0, так и в вашем он тоже знает, чтоp&1
- всегда 0, потому у типа int есть требования по выравниванию.Хотите, чтобы компилятор не учитывал требования по выравниванию - скажите об этом компилятору явно:
ну или эквивалент с приведением к другому вырваниванию, лень вспоминать синтаксис.
ruomserg
Вот я не понимаю логику этого решения. Сказать - unaligned access является implementation defined - понимаю. Дальше разработчик компилятора для конкретной платформы должен будет выбрать и задокументировать поведение в такой ситуации, и дальше консистентно его придерживаться. Это никак не ограничивает язык на конкретной платформе, ибо мы не диктуем единственно-правильное поведение (которое действительно на каком-то железе может быть очень дорого, или даже невозможно! реализовать). Например, разработчик компилятора может написать что для int * всегда генерируются машинные команды aligned access. Если там будет невыравненное значение - произойдет прерывание. Или он может добавить платформ-зависимые средства (ключ компиляции, дополнительный атрибут или intrinsic) чтобы сгенерировать медленный но всегда валидный код для unalgned access. Суть в том - что находясь на конкретной платформе - вы можете прочитать как это тут будет работать! И оно так будет работать всегда, а если изменится - то опять же об этом будет написано в документации.
А UB - это очень сильное утверждение: так никогда не может быть. И поэтому код с UB эквивалентен любому другому куску кода (в том числе пустому)... И дальше начинаются странные оптимизации, когда от изменения одного символа в исходном коде - у вас из объектного файла пропадает половина программы... Опять-таки, это больше бочка в сторону C++, а C был всегда более консервативен - и компилировал то, что программист написал, а не "абстрактную программу которая эквивалентна написанному программистом, но только лучше" (c). Плюс - что происходит с кодом UB - нигде не документируется, и компилятор может (формально - оставаясь той же версии) сегодня компилировать так, а завтра - этак...
И я не понимаю - с какой целью это сделано ? Тяжелое наследие гонок компиляторов за наносекундами в бенчмарках ?
aamonster
Именно что оставить возможность для оптимизации. Там порой не в наносекунды упирается, а выкидываются огромные куски ненужного кода. Причём код библиотек на C++ (где куча метапрограммирования на темплейтах) порой на эти оптимизации закладывается (простите, что перескочил от C к C++).
Не хотите этого безумия – меняйте язык. В более поздних языках гораздо, гораздо меньше UB. Типа пусть прога будет чуть медленнее, но программист не сойдёт с ума. Плюс многие моменты обошли за счёт более совершенной системы типов – и таки могут применять оптимизации.
Ну и даже в чистом C, насколько я помню, UB меньше, чем в C++ (но всё равно хватает).
А на крестиках и на це ваш выбор – писать без UB.
ruomserg
Да C++ лучше оставить в стороне - они с кодогенерацией на темплейтах не выживают, если только не применяют оптимизацию: "любой код с UB считается ненаписанным".
Я пытаюсь понять - какие возможности для оптимизации закрывает unaligned access как implementation defined behavior ? Язык программирования С - делался как "переносимый ассемблер". Соответственно, он должен определить подмножество операций, которые гарантированно одинаково выполняются на всех поддерживаемых платформах. А все странности и ограничения платформ - оставьте разработчику компилятора, там наверное разберутся что с этим делать ? А встраивать это как UB прямо в язык (то есть гарантировать что такая конструкция нигде не является корректной и ее можно как хочешь оптимизировать) - я не понимаю...
aamonster
Много лет прошло, уже давно не "переносимый ассемблер". Вот и...
coctic
А разве результатом implementation-defined behavior может быть падение? Кажется, тут предполагается только результат, который в итоге даст программе успешно завершиться.
ruomserg
Если говорить строго - то компилятор может только гарантировать что в ответ на такую-то конструкцию языка - он вставит в объектный файл такие-то машинные команды. Что произойдет дальше - и упадет ли программа, он не знает и знать не может...
В этом смысле, я вижу три вида конструкций:
Для которых язык предусматривает определенный результат (i++ увеличивает i на 1). И даже если вы работаете на какой-то странной платформе где можно только прибавлять два и вычитать один, то вам придется сделать неэффективную кодогенерацию - но i++ должно увеличить переменную на один, а не на два и не на ноль.
Которые валидны, но язык не может предусмотреть единый для всех результат, т.к. из-за разных аппаратных платформ обеспечить такое единообразие очень дорого. Разъяснения для этих случаев нужно искать в документации на компилятор или машину для которой ведется разработка. Это как раз всякие пограничные случаи, где вы хотите сделать что-то низкоуровневое.
Которые синтаксически валидны, но с точки зрения языка не имеют смысла - это как раз UB.
Первая категория - абсолютно понятна. Это - собственно то, для чего создается переносимый язык. Вторая - тоже понятна. Это то - чего нельзя предусмотреть в языке (если мы настаиваем на компиляции в машинный код). Третья категория мне понятна мало. Особенно - как именно решается: UB это - или implementation defined! И уже совсем мне непонятно - в какой момент (а главное - зачем?!) UB стало пониматься разработчиками компиляторов как карт-бланш на применение любых оптимизаций (из которых самая лучшая - кончено же выкидывание кода из генерации, несмотря на то что программист его в исходном файле буквами написал!)...
coctic
Все-таки стандарт языка - это стандарт на поведение программы, а не на генерацию машинного кода. Стандарт описывает поведение именно итоговой программы в "нормальном" случае - т.е. компилятор ведет себя "правилльно", и платформа ведет себя "правильно". Работу при сбоях платформы стандарт не описывает, так что можем это пропустить.
Разделение на implementation defined и undefined behavior (а еще есть unspecified и local-specific behavior) сделано на основе опыта разарботчиков стандартов (а у тех опыт разработчиков компиляторов). Где поведение нельзя сделать единообразным, но можно и осмысленно зафиксировать для конкретной платформы - там implementation defined. Там где оно точно не упадет, но результат неизвестен - стандарт описывает возможные варианты, а какой сработает - заранее сказать нельзя, и фиксировать это в документации не особо осмысленно - unspecified. Implementation defined - это частный случай unspecified, кстати, тогда, когда можно осмысленно требовать фиксации поведения.
А последний случай - когда разнообразие существующих вариантов не позволяет гарантировать, что возможно вообще (с приемлемым уровнем оверхеда) реализовать что-то осмысленное - там undefined. Это типа деления на ноль - какой тут результат может получиться? Ну или невыровненный доступ - слишком дорого генерировать код для некоторых платформ, который это реализует. Это место объявляют undefined. Это вообще-то то, что стандартом запрещено, просто это может случиться в runtime, поэтому компилятор это не отловит, а runtime проверки - опять очень дорого.
А дальше логика разработчика комплиятора - если он знает, что чего-то в программе может не случиться - он это может использовать для оптимизации. Вот как if (0&p) ... известно, что не исполниться, то что после if, так зачем генерировать код. Так и ограничения на defined behavior с точки зрения компилятора - это такие же известные заранее результаты.