Как стать автором
Поиск
Написать публикацию
Обновить

Демистификация unaligned access undefined behavior в C

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров1.4K

Неопределённое поведение (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)

Заключение

  1. unaligned access в C (и C++) является UB по стандарту и работоспособность такого кода зависит в первую очередь от поддержки соответствующих операций на аппаратном уровне

  2. Явный unaligned access в коде может привести не только к падению, но и к значительной деградации производительности на некоторых платформах, особенно когда это работает в режиме trap+fixup

  3. Есть смысл добавить -Wcast-align в CFLAGS если целевые платформы не только x86_64

  4. Код без UB + (хороший) оптимизирующий компилятор это действительно переносимый, корректно работающий код и высокая производительность (но, возможно, придется подобрать ключи компиляции вплоть до задания конкретного cpu и проверки сгенерированного (asm) кода)

Теги:
Хабы:
+6
Комментарии26

Публикации

Ближайшие события