Неопределённое поведение (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 lda5,0(a1) adda5,a5,a0 lwa0,0(a5) ret
; prog2 lda5,0(a1) addisp,sp,-16 adda0,a0,a5 lbua2,0(a0) lbua3,1(a0) lbua4,2(a0) lbua5,3(a0) sba2,12(sp) sba3,13(sp) sba4,14(sp) sba5,15(sp) lwa0,12(sp) addisp,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 addisp,sp,-16 sdra,8(sp) luia1,0x40 lia2,1 lia3,34 lia4,-1 lia0,0 lia5,0 jal<mmap@plt> addia1,a0,1 vsetivlizero,8,e16,mf2,ta,ma vle16.vv8,(a1) ; (!) падает здесь, пытаясь читать по два байта с нечетного адреса addia1,a0,17 vsetivlizero,4,e16,mf4,ta,ma vle16.vv9,(a1) lhua1,25(a0) vsetivlizero,8,e32,m1,ta,ma vzext.vf2v10,v8 lhua0,27(a0) vsetivlizero,4,e16,mf4,ta,ma vwaddu.wvv8,v10,v9 vsetivlizero,4,e32,m1,tu,ma vmv.v.vv10,v8 vmv.s.xv8,a1 vsetivlizero,8,e32,m1,ta,ma vredsum.vsv8,v10,v8 vmv.x.sa1,v8 addwa0,a0,a1 ldra,8(sp) addisp,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 < vsetivlizero,8,e16,mf2,ta,ma < vle16.vv8,(a1) --- > vsetivlizero,16,e8,mf2,ta,ma > vle8.vv8,(a1) ; здесь vle16.v заменен на vle8.v 118,120d117 < vsetivlizero,4,e16,mf4,ta,ma < vle16.vv9,(a1) < lhua1,25(a0) 121a119,120 > vle8.vv9,(a1) > lhua1,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) кода)
ссылка на оригинал статьи https://habr.com/ru/articles/942888/
Добавить комментарий