Featured image of post Memory layout: чому sizeof(struct) ≠ сума полів, і до чого тут HardFault

Memory layout: чому sizeof(struct) ≠ сума полів, і до чого тут HardFault

Padding, packed і невирівняний доступ на Cortex-M0/M4/M7, DMA та кеш, різниця між C, C++ і Rust

Знайома ситуація: спроєктували бінарний протокол, усе красиво, struct один-в-один як у специфікації. Шлемо по UART чи CAN, а приймач отримує кашу. Або: працює на Cortex-M4, а на M0+ вилітає в HardFault з “ну я ж нічого дивного не робив”.

Спойлер: struct у C - це не “група змінних”, це конкретний малюнок байтів, і малюємо його не ми, а компілятор. Виводи нижче отримані з GCC 13.3 (x86-64), arm-none-eabi-gcc 13.2.1, pahole 1.25 і rustc 1.84; з іншими версіями деталі asm можуть відрізнятися.

📝 TL;DR
  • Компілятор вставляє між полями невидимі байти (padding), щоб кожне поле лежало за адресою, кратною його alignof.
  • Порядок полів впливає на розмір. Сортуйте від більшого до меншого й перевіряйте через pahole.
  • packed прибирає padding, але скидає вирівнювання всієї структури до 1: на Cortex-M0 побайтово читаються навіть поля на офсеті 0. packed, aligned(4) частково це лікує.
  • HardFault на M0 дає не сам packed, а код, який “забуває” про невирівняність: каст uint8_t * => uint32_t * або взяття адреси packed-поля.
  • packed ≠ “готовий формат для дроту”: є ще порядок байтів і сміття в padding. Для протоколів серіалізуйте явно.
  • Вирівнювання - питання ABI: double на i386 вирівнюється по 4, на ARM і x86-64 по 8. Вирівнюються навіть аргументи в регістрах.
  • На Cortex-M7 з D-cache DMA-буфер вирівнюють по кеш-лінії (32 байти) і за адресою, і за розміром, інакше cache maintenance зачепить сусідні змінні.
  • C++ додає прихованих байтів (vptr), Rust за замовчуванням сам переставляє поля. Різниця між мовами не у “вирівнюванні”, а в тому, що кожна з них гарантує.

Залізо: навіщо взагалі вирівнювання

Пам’ять - не нескінченна стрічка байтів, а набір комірок, до яких ходить шина певної ширини.

  1. Один банк, 8-бітні комірки. Щоб прочитати 32-бітне число, потрібно 4 цикли: байт => байт => байт => байт, а потім склеїти.
  2. Кілька паралельних банків (наприклад, 4 по байту). Якщо 32-бітне значення лежить на адресі, кратній 4, всі чотири банки віддають свій байт одночасно: 1 цикл. Якщо значення “розрізане” між двома сусідніми словами, потрібні два доступи плюс зсув і злиття.

Звідси природне вирівнювання (natural alignment): значення типу T має лежати за адресою, для якої addr % alignof(T) == 0. Для скалярних типів alignof зазвичай дорівнює sizeof, але не завжди: останнє слово за ABI платформи (нижче побачимо, як double на i386 вирівнюється лише по 4). Структура мусить розкласти поля так, щоб ця умова виконувалась для кожного з них.

На 32-бітному ядрі вирівняне слово читається за один доступ до шини, а double - за два, бо це два слова. Невирівняний доступ коштує більше: на одних ядрах прихованими додатковими циклами, на інших (Cortex-M0/M0+) - HardFault.

Експеримент №1: вгадай sizeof

Чотири структури. Спробуйте вгадати розміри на папері, перш ніж дивитись далі:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>

typedef struct { uint8_t  a; uint16_t b; }              a_t;
typedef struct { uint16_t a; uint8_t  b; uint32_t c; }  b_t;
typedef struct { uint8_t  a; double   b; uint32_t c; }  c_t;
typedef struct { double   a; uint32_t b; uint8_t  c; }  d_t;

#define SHOW(T) printf("%-4s sizeof=%2zu alignof=%zu\n", #T, sizeof(T), _Alignof(T))

int main(void) {
    SHOW(a_t); SHOW(b_t); SHOW(c_t); SHOW(d_t);
    printf("c_t offsets: a=%zu b=%zu c=%zu\n",
           offsetof(c_t, a), offsetof(c_t, b), offsetof(c_t, c));
    printf("d_t offsets: a=%zu b=%zu c=%zu\n",
           offsetof(d_t, a), offsetof(d_t, b), offsetof(d_t, c));
    return 0;
}
1
2
3
4
5
6
7
gcc -std=c11 -Wall sizes.c -o sizes && ./sizes
a_t  sizeof= 4 alignof=2
b_t  sizeof= 8 alignof=4
c_t  sizeof=24 alignof=8
d_t  sizeof=16 alignof=8
c_t offsets: a=0 b=8 c=16
d_t offsets: a=0 b=8 c=12

Наївна сума полів проти реальності:

structсума полівреально (x86-64, ARM EABI)alignof
a_t342
b_t784
c_t13248
d_t13168

Колонка alignof - ключ до всього: вирівнювання структури дорівнює найбільшому вирівнюванню серед її полів.

a_t і b_t очевидні: між u8 та u16 один байт padding, між u8 і u32 теж.

А от c_t зазвичай ламає мозок. Рахуємо: a (1 байт), 7 байт padding, b (8 байт, офсет 8), c (4 байти, офсет 16). Закінчуємо на 20. Чому ж 24?

Бо розмір структури мусить бути кратним її вирівнюванню (тут 8, від double). Інакше в масиві c_t arr[2] другий елемент поїде на офсет 20, і його double стане невирівняним. Тож у кінець додається ще 4 байти tail padding. Те саме правило працює рекурсивно для структур усередині структур.

d_t каже те саме з іншого боку: ті самі типи, але великий double стоїть першим, тож внутрішніх дірок немає, а хвостовий padding лише 3 байти (13 => 16).

Порядок полів = економія пам’яті, і безкоштовно. Практичне правило: сортуйте поля за спаданням розміру (точніше, за спаданням alignof).

1
2
struct bad  { uint8_t a; uint32_t b; uint16_t c; };  // 1 + 3 pad + 4 + 2 + 2 pad = 12
struct good { uint32_t b; uint16_t c; uint8_t a; };  // 4 + 2 + 1 + 1 pad         = 8

Попросити компілятор сказати самому

1
2
3
4
5
6
gcc -Wpadded -c sizes.c -o /dev/null
sizes.c:6:39: warning: padding struct to align 'b' [-Wpadded]
sizes.c:7:51: warning: padding struct to align 'c' [-Wpadded]
sizes.c:8:39: warning: padding struct to align 'b' [-Wpadded]
sizes.c:8:16: warning: padding struct size to alignment boundary with 4 bytes [-Wpadded]
sizes.c:9:16: warning: padding struct size to alignment boundary with 3 bytes [-Wpadded]

Бачимо і внутрішній padding, і хвостовий (4 байти для c_t, 3 для d_t). Для реального проєкту -Wpadded надто балакучий, щоб тримати його ввімкненим постійно, але для разової ревізії структур - те, що треба.

Експеримент №2: дивимось через pahole

-Wpadded корисний, але найнаочніше дірки показує pahole (пакет dwarves). Він читає DWARF-інформацію з об’єктного файлу і малює структуру так, як вона лежить у пам’яті. Працює й з об’єктниками під ARM.

1
2
3
4
5
6
#include <stdint.h>

typedef struct { uint8_t  a; double   b; uint32_t c; }  c_t;
typedef struct { double   a; uint32_t b; uint8_t  c; }  d_t;

c_t gc; d_t gd;   // хоча б по одній змінній кожного типу, див. граблі №2
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -c sizes_arm.c -o sizes_arm.o
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pahole -C c_t sizes_arm.o
typedef struct {
	uint8_t                    a;                    /*     0     1 */

	/* XXX 7 bytes hole, try to pack */

	double                     b;                    /*     8     8 */
	uint32_t                   c;                    /*    16     4 */

	/* size: 24, cachelines: 1, members: 3 */
	/* sum members: 13, holes: 1, sum holes: 7 */
	/* padding: 4 */
	/* last cacheline: 24 bytes */
} c_t;

Усе, що ми вирахували вручну, тут написано прямо: діра 7 байт, хвостовий padding 4, 13 корисних байтів у 24. А ось d_t:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pahole -C d_t sizes_arm.o
typedef struct {
	double                     a;                    /*     0     8 */
	uint32_t                   b;                    /*     8     4 */
	uint8_t                    c;                    /*    12     1 */

	/* size: 16, cachelines: 1, members: 3 */
	/* padding: 3 */
	/* last cacheline: 16 bytes */
} d_t;

Жодних XXX hole, лише 3 байти в хвості.

Три граблі, на які можна наступити

1. Голий pahole file.o може мовчати. Без жодної помилки, exit code 0. Причина: структури в прикладі анонімні (typedef struct { ... } c_t;), а голий виклик друкує лише структури з тегом. Формат DWARF тут ні до чого: з -gdwarf-4 поведінка та сама. Лікується двома способами:

1
pahole -C c_t file.o  # шукати за іменем typedef, або дати структурі тег: typedef struct c_s { ... } c_t; і тоді працює й голий pahole

2. Тип, який ніде не використано, у DWARF не потрапляє. GCC за замовчуванням викидає невикористані типи з debug-інформації. Якщо структура фігурує лише в sizeof, pahole скаже type 'X' not found. Або заведіть хоча б одну змінну цього типу (як gc і gd вище), або попросіть GCC нічого не викидати:

1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -fno-eliminate-unused-debug-types -c file.c -o file.o

3. Потрібен -g. Без debug-інформації pahole немає з чого читати.

Експеримент №3: packed і заголовок прошивки

Тепер реалістичний кейс: заголовок образу прошивки, який лежить у Flash і формат якого зафіксований байт-у-байт. Цей файл використовуємо в наступних двох експериментах, тож тут він повністю:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdint.h>
#include <stddef.h>

typedef struct {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_natural_t;

typedef struct __attribute__((packed)) {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_packed_t;

typedef struct __attribute__((packed, aligned(4))) {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_packed_a4_t;

_Static_assert(sizeof(fw_header_natural_t) == 12, "natural: 12");
_Static_assert(offsetof(fw_header_natural_t, crc) == 8, "natural crc @ 8");
_Static_assert(sizeof(fw_header_packed_t)  == 11, "packed: 11");
_Static_assert(offsetof(fw_header_packed_t, crc) == 7, "packed crc @ 7");
_Static_assert(sizeof(fw_header_packed_a4_t) == 12, "packed+aligned(4): 12");

// Читання невирівняного поля: компілятор ЗНАЄ, що воно packed
uint32_t read_crc(const fw_header_packed_t *h)      { return h->crc; }

// Вирівняне поле (офсет 0) у packed-структурі з alignof == 1
uint32_t read_magic(const fw_header_packed_t *h)    { return h->magic; }

// Те саме, але структура packed + aligned(4)
uint32_t read_magic_a4(const fw_header_packed_a4_t *h) { return h->magic; }
uint32_t read_crc_a4(const fw_header_packed_a4_t *h)   { return h->crc; }

// Взяття адреси packed-поля: packed-ність губиться
uint32_t crc_of(const uint32_t *p);   // десь в іншому файлі: return *p;
uint32_t read_via_ptr(const fw_header_packed_t *h) { return crc_of(&h->crc); }

У natural-варіанті між flags (офсет 6) і crc компілятор вставляє 1 байт padding, crc стає на офсет 8, розмір 12. У packed padding немає: crc на офсеті 7, розмір 11.

На другій схемі видно саме той випадок із розділу про банки пам’яті: crc починається в одному слові, а закінчується в іншому.

Зверніть увагу на _Static_assert. Вони компілюються в нуль байтів, але якщо хтось через рік додасть поле чи змінить прапор компілятора, впаде збірка з людським повідомленням, а не прошивка в полі.

packed виглядає як ідеальне рішення. Тепер про ціну.

Експеримент №4: скільки коштує packed

Функція read_crc з fw.c читає невирівняне поле:

1
uint32_t read_crc(const fw_header_packed_t *h) { return h->crc; }

Компілюємо під три конфігурації. У .s немає імен полів C, лише зсуви на кшталт [r0, #7], тому -fverbose-asm додає коментарі, а sed вирізає потрібну функцію:

1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S -fverbose-asm fw.c -o fw_m4.s
1
arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -O2 -S -fverbose-asm fw.c -o fw_m0.s
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S -fverbose-asm -mno-unaligned-access fw.c -o fw_m4_nounal.s
1
sed -n '/^read_crc:/,/^\s*bx\s*lr/p' fw_m4.s     # і так само для fw_m0.s, fw_m4_nounal.s

Cortex-M4 (ARMv7-M, невирівняний доступ підтримується апаратно):

1
2
3
read_crc:
        ldr     r0, [r0, #7]    @ unaligned
        bx      lr

Одна інструкція. GCC лише ставить коментар @ unaligned. Ціна прихована: ядро всередині робить кілька доступів до шини замість одного, тож це повільніше, але в асемблері цього не видно.

Cortex-M0 (ARMv6-M, апаратної підтримки немає):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
read_crc:
        ldrb    r2, [r0, #8]
        ldrb    r3, [r0, #7]
        lsls    r2, r2, #8
        orrs    r2, r3
        ldrb    r3, [r0, #9]
        ldrb    r0, [r0, #10]
        lsls    r3, r3, #16
        orrs    r3, r2
        lsls    r0, r0, #24
        orrs    r0, r3
        bx      lr

Тут уже видно, що відбувається: чотири ldrb (по байту), три зсуви, три злиття. Десять інструкцій замість однієї.

Cortex-M4 з -mno-unaligned-access: те саме, але з barrel shifter в orr
1
2
3
4
5
6
7
8
9
read_crc:
        ldrb    r3, [r0, #7]
        ldrb    r1, [r0, #8]
        ldrb    r2, [r0, #9]
        ldrb    r0, [r0, #10]
        orr     r3, r3, r1, lsl #8
        orr     r3, r3, r2, lsl #16
        orr     r0, r3, r0, lsl #24
        bx      lr

Thumb-2 уміє зсувати другий операнд прямо в orr, тому окремі lsls не потрібні: 7 інструкцій замість 10.

Ключовий висновок: ціну packed визначає ядро. Компілятор знає, що поле невирівняне (бо ви самі написали packed), і підбирає безпечну послідовність для конкретної архітектури. Тож у цьому випадку HardFault не буде, буде лише повільніше й довше.

Сюрприз: побайтово читається навіть magic

packed прибирає не лише padding між полями: він скидає alignof всієї структури до 1. Компілятор більше не може припускати, що сам вказівник h вирівняний, тому на M0 навіть magic на офсеті 0 збирається з байтів:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
read_magic:                     @ Cortex-M0, fw_header_packed_t
        ldrb    r2, [r0, #1]
        ldrb    r3, [r0]
        lsls    r2, r2, #8
        orrs    r2, r3
        ldrb    r3, [r0, #2]
        ldrb    r0, [r0, #3]
        lsls    r3, r3, #16
        orrs    r3, r2
        lsls    r0, r0, #24
        orrs    r0, r3
        bx      lr

Ліки - повернути структурі вирівнювання, не повертаючи padding між полями: __attribute__((packed, aligned(4))) (тип fw_header_packed_a4_t у fw.c). Тепер компілятор знає, що база вирівняна по 4, і поля на “чесних” офсетах читаються однією інструкцією навіть на M0 (коментарі в asm - мої):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
read_magic_a4:                  @ Cortex-M0
        ldr     r0, [r0]
        bx      lr

read_crc_a4:                    @ Cortex-M0
        ldrb    r3, [r0, #7]    @ байт 7 окремо
        ldr     r0, [r0, #8]    @ слово 8..11 вирівняне
        lsls    r0, r0, #8      @ байт 11 (tail padding) виштовхується
        orrs    r0, r3
        bx      lr

Навіть невирівняний crc подешевшав: компілятор читає вирівняне слово з офсету 8 і доклеює один байт. Легально це саме тому, що байт 11 належить структурі (tail padding).

функціяM4M4 -mno-unaligned-accessM0
read_crc (packed)1710
read_magic (packed)1710
read_magic_a4 (packed + aligned(4))111
read_crc_a4 (packed + aligned(4))134

Кількість інструкцій без bx lr.

📌 Важливо

aligned(4) повертає tail padding: sizeof(fw_header_packed_a4_t) == 12, а не 11. Для заголовка у Flash, який ви самі розмічаєте, це зазвичай прийнятно. Для формату, де після 11-го байта одразу йде payload, - ні. Тому _Static_assert на розмір у fw.c стоїть і для цього типу.

Де M0 справді падає

HardFault на M0 приходить, коли компілятор не знає, що адреса невирівняна. Два типові способи йому “не сказати”: каст байтового буфера та взяття адреси packed-поля.

Каст вказівника

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdint.h>
#include <string.h>

// Компілятор НЕ знає, що адреса невирівняна: каст  -  це обіцянка
uint32_t read_cast(const uint8_t *buf) {
    return *(const uint32_t *)(buf + 1);
}

// Коректно: memcpy
uint32_t read_memcpy(const uint8_t *buf) {
    uint32_t v;
    memcpy(&v, buf + 1, sizeof v);
    return v;
}

// Коректно і з явним порядком байтів: little-endian з будь-якої адреси
uint32_t get_u32le(const uint8_t *p) {
    return (uint32_t)p[0]       | (uint32_t)p[1] << 8 |
           (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@ Cortex-M0
read_cast:
        adds    r0, r0, #1
        ldr     r0, [r0]
        bx      lr

@ Cortex-M4 (будь-які прапори, навіть -mno-unaligned-access)
read_cast:
        ldr     r0, [r0, #1]
        bx      lr

Звичайний ldr. Ніякого @ unaligned, ніякої побайтової збірки: для компілятора каст uint8_t * => uint32_t * означає обіцянку “тут точно вирівняно по 4” (а за стандартом C ще й порушує strict aliasing). Якщо buf + 1 не кратне 4, то на M0/M0+ це миттєвий HardFault, а на M3/M4/M7 - тихий повільний доступ (поки не ввімкнемо trap, див. нижче).

Звідки на M0 окремий adds?

У Thumb-1 LDR із immediate-офсетом кодує зсув, кратний 4 (#0..#124), тому [r0, #1] просто не існує як інструкція. Сам набір інструкцій M0 нагадує, що слово мусить бути вирівняне.

Адреса packed-поля

Другий спосіб - передати адресу поля туди, де чекають звичайний вказівник:

1
2
uint32_t crc_of(const uint32_t *p);   // десь в іншому файлі: return *p;
uint32_t read_via_ptr(const fw_header_packed_t *h) { return crc_of(&h->crc); }

Усередині crc_of немає жодної інформації про packed: там звичайний ldr r0, [r0], а приходить у нього h + 7. На M0 - HardFault. GCC про це попереджає за замовчуванням:

1
fw.c:44:68: warning: taking address of packed member of 'struct <anonymous>' may result in an unaligned pointer value [-Waddress-of-packed-member]
🚨 Обережно

Це найчастіший реальний сценарій: структура packed, а поле передають у CRC- чи parse-функцію за вказівником. Попередження -Waddress-of-packed-member варто тримати як помилку (-Werror=address-of-packed-member).

Як читати правильно

У cast.c є ще два варіанти. Ось що з них робить компілятор:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@ Cortex-M4
read_memcpy:
        ldr     r0, [r0, #1]    @ unaligned
        bx      lr
get_u32le:
        ldr     r0, [r0]        @ unaligned
        bx      lr

@ Cortex-M0, -O2
read_memcpy:
        push    {lr}
        sub     sp, sp, #12
        adds    r1, r0, #1
        movs    r2, #4
        add     r0, sp, #4
        bl      memcpy
        ldr     r0, [sp, #4]
        add     sp, sp, #12
        pop     {pc}
get_u32le:                      @ 10 інструкцій, як у read_crc
        ...

memcpy коректний скрізь, і на M4 згортається в один ldr. Але на M0 GCC 13 залишив справжній виклик бібліотечної memcpy. get_u32le зі зсувами коректний скрізь, на M4 GCC сам розпізнає патерн і теж згортає його в один ldr, а на M0 дає ту саму побайтову збірку без виклику. І головне: він фіксує порядок байтів, про що memcpy нічого не знає. Для читання з протокольних буферів це найкращий вибір.

Компілятор і санітайзер як сторожі

  • -Wcast-align на ARM попереджає про каст uint8_t * => uint32_t * (cast increases required alignment of target type). На x86 той самий прапор мовчить, бо архітектура терпить невирівняні доступи. Там потрібен -Wcast-align=strict.
  • Якщо бізнес-логіка тестується на хості, -fsanitize=alignment ловить невирівняний доступ у рантаймі, навіть на x86:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <assert.h>
#include <stdint.h>
#include "cast.c"  // так, це трішки hack-абельно

int main(void) {
    _Alignas(4) const uint8_t buf[8] = { 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0, 0, 0 };
    assert(get_u32le(buf + 1) == 0xDEADBEEF);
    assert(read_memcpy(buf)   == 0xDEADBEEF);   // на little-endian хості
    assert(read_cast(buf)     == 0xDEADBEEF);   // UBSan зупинить тут
    return 0;
}
1
2
gcc -std=c11 -O1 -g -fsanitize=alignment -fno-sanitize-recover=alignment cast_test.c -o cast_test && ./cast_test
cast.c:6:49: runtime error: load of misaligned address 0x7ffe38e3a431 for type 'const uint32_t', which requires 4 byte alignment

get_u32le і memcpy проходять, а read_cast зупиняє тест з точним рядком.

Як знайти невирівняні доступи на M3/M4/M7

На цих ядрах невирівняний доступ мовчки працює, тобто баги ховаються. Але trap можна ввімкнути:

1
SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk;   // CMSIS

Тепер кожен невирівняний LDR/STR дасть UsageFault (з прапорцем UNALIGNED в UFSR, а якщо UsageFault-обробник не ввімкнений, ескалюється в HardFault). Це не про “повільні місця”, а про всі місця, де ви покладаєтесь на апаратну терпимість. Корисно, щоб на M4 відловити код, який “випадково” зламається при міграції на M0.

Один нюанс: з trap’ом код із read_crc вище (ldr r0, [r0, #7] @ unaligned) почне падати, бо GCC за замовчуванням вважає невирівняний ldr допустимим. Тож разом із trap’ом додавайте -mno-unaligned-access: тоді компілятор для packed-полів сам перейде на побайтове читання. Підсумок: прапор компілятора + trap = чесна перевірка, що код переносимий на M0.

Малі типи: LDRB, LDRSB, LDRH

Байт можна прочитати з будь-якого офсету однією інструкцією, і він одразу опиняється в молодших бітах регістра: нічого зсувати чи маскувати вручну не треба.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdint.h>

typedef struct __attribute__((packed)) {
    uint8_t  pad;   // offset 0: лише щоб зсунути наступні поля
    uint8_t  u8;    // offset 1
    int8_t   s8;    // offset 2
    uint16_t u16;   // offset 3: непарний, не кратний 2
} small_t;

uint32_t read_u8 (const small_t *s) { return s->u8;  }
int32_t  read_s8 (const small_t *s) { return s->s8;  }
uint32_t read_u16(const small_t *s) { return s->u16; }
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S small_int.c -o small_int_m4.s
1
arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -O2 -S small_int.c -o small_int_m0.s
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@ Cortex-M4
read_u8:
        ldrb    r0, [r0, #1]
        bx      lr
read_s8:
        ldrsb   r0, [r0, #2]
        bx      lr
read_u16:
        ldrh    r0, [r0, #3]    @ unaligned
        bx      lr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@ Cortex-M0
read_u8:
        ldrb    r0, [r0, #1]
        bx      lr
read_s8:
        ldrb    r0, [r0, #2]
        sxtb    r0, r0
        bx      lr
read_u16:
        ldrb    r3, [r0, #3]
        ldrb    r0, [r0, #4]
        lsls    r0, r0, #8
        orrs    r0, r3
        bx      lr
  • ldrb бере байт з непарного офсету й розширює нулями до 32 біт - на обох ядрах одна інструкція.
  • Для знакового байта на M4 є ldrsb з immediate-офсетом, на M0 такої форми немає (у Thumb-1 LDRSB вміє лише регістровий офсет), тому ldrb + sxtb.
  • uint16_t на офсеті 3 вже невирівняний: на M4 це ldrh з @ unaligned, на M0 - збирання з двох байтів.

Порівняйте з архітектурами без байтових завантажень: там довелося б читати ціле слово, зсувати й маскувати & 0xFF. На ARM апаратура бере це на себе.

Padding - це ще не все: не шліть struct в мережу

Ви можете подумати: “Ага, packed, 11 байтів, шлю структуру як є”. Ні. Подивімося, що реально лежить у пам’яті звичайної (не-packed) структури і що має піти в протокол:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

typedef struct {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_natural_t;

static void put_u32le(uint8_t *p, uint32_t v) { p[0] = v; p[1] = v >> 8; p[2] = v >> 16; p[3] = v >> 24; }
static void put_u16le(uint8_t *p, uint16_t v) { p[0] = v; p[1] = v >> 8; }

#define HDR_NET_SIZE 11u  // розмір із специфікації протоколу, а не sizeof()

static size_t hdr_serialize(uint8_t *out, const fw_header_natural_t *h) {
    put_u32le(out + 0, h->magic);
    put_u16le(out + 4, h->version);
    out[6] = h->flags;
    put_u32le(out + 7, h->crc);
    return HDR_NET_SIZE;
}

static void dump(const char *name, const void *p, size_t n) {
    printf("%s", name);
    for (size_t i = 0; i < n; i++)
        printf("%02x ", ((const uint8_t *)p)[i]);
    printf("\n");
}

int main(void) {
    fw_header_natural_t h;
    memset(&h, 0xCC, sizeof h);  // імітуємо "сміття" на стеку
    h.magic   = 0xA5A50001;
    h.version = 3;
    h.flags   = 0x80;
    h.crc     = 0xDEADBEEF;

    uint8_t wire[HDR_NET_SIZE];
    hdr_serialize(wire, &h);

    dump("у мережі:        ", wire, sizeof wire);
    dump("сира структура:  ", &h, sizeof h);
    return 0;
}
1
2
3
4
gcc -std=c11 -Wall -O2 ser.c -o ser && ./ser
у мережі:        01 00 a5 a5 03 00 80 ef be ad de
сира структура:  01 00 a5 a5 03 00 80 cc ef be ad de
#                                     ^^ padding

memset(..., 0xCC, ...) імітує стек: значення padding-байта стандартом не визначене, тож у реальному коді там буде те, що лежало на стеку до цього. Тобто сира структура в проводі - ще й витік вмісту пам’яті. Плюс її порядок байтів диктує архітектура відправника. Cortex-M (на практиці) і x86 - little-endian, але перший же big-endian партнер по мережі зламає все.

Тому правило таке: packed - для форматів, що лежать у Flash і читаються тим самим кодом (заголовки образів, конфіг-блоки). Усе, що йде назовні, серіалізуйте явно: зсувами, як put_u32le/get_u32le, у Rust - to_le_bytes()/from_le_bytes(). Так ви одночасно вирішуєте padding, endianness і вирівнювання.

⚠️ Попередження

packed не для MMIO-регістрів. Регістри периферії мають бути природно вирівняні й читатися своєю шириною, а packed може перетворити 32-бітний доступ на чотири байтові, які регістр не підтримує або на які реагує інакше (наприклад, read-to-clear).

Вирівнювання - питання ABI, а не мови

Чому double у структурі вирівнюється по 8, хоча шина 32-бітна? Бо так сказав ABI: менше доступів до шини ціною більшого padding. Перевіримо: той самий sizes.c, але під 32-бітний x86:

1
2
3
4
5
6
7
gcc -m32 -std=c11 sizes.c -o sizes32 && ./sizes32
a_t  sizeof= 4 alignof=2
b_t  sizeof= 8 alignof=4
c_t  sizeof=16 alignof=4  # <-- було 24 і 8
d_t  sizeof=16 alignof=4
c_t offsets: a=0 b=4 c=12
d_t offsets: a=0 b=8 c=12

На i386 System V double усередині структури вирівнюється лише по 4, тож c_t стискається з 24 до 16 (b на офсеті 4, а не 8). На ARM EABI (AAPCS) та x86-64 вимога 8. Висновок: розмір структури не переноситься між платформами, і це ще одна причина ставити _Static_assert.

А що з double на Cortex-M4?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct { unsigned char a; double b; } natural_t;
typedef struct __attribute__((packed)) { unsigned char a; double b; } packed_t;

natural_t gn; packed_t gp;   // щоб типи потрапили в DWARF для pahole

double read_natural(const natural_t *p) { return p->b; }
double read_packed (const packed_t  *p) { return p->b; }

double add (double a, double b) { return a + b; }
float  addf(float  a, float  b) { return a + b; }
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -O2 -S dbl.c -o dbl.s
1
2
3
4
5
6
7
8
9
read_natural:
        vldr.64 d0, [r0, #8]
        bx      lr

read_packed:
        ldr     r2, [r0, #1]    @ unaligned
        ldr     r3, [r0, #5]    @ unaligned
        vmov    d0, r2, r3
        bx      lr

Вирівняний double вантажиться однією vldr.64. Для невирівняного компілятор не може використати vldr (на невирівняній адресі вона дає UsageFault завжди, див. вище), і навіть ldrd не може. Тож бере два одиночних ldr і перекладає їх у FPU-регістр через vmov.

pahole показує ціну в байтах:

1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -g -c dbl.c -o dbl.o
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pahole -C natural_t dbl.o
typedef struct {
	unsigned char              a;                    /*     0     1 */

	/* XXX 7 bytes hole, try to pack */

	double                     b;                    /*     8     8 */

	/* size: 16, cachelines: 1, members: 2 */
	/* sum members: 9, holes: 1, sum holes: 7 */
	/* last cacheline: 16 bytes */
} natural_t;
1
2
3
4
5
6
7
8
pahole -C packed_t dbl.o
typedef struct {
	unsigned char              a;                    /*     0     1 */
	double                     b;                    /*     1     8 */

	/* size: 9, cachelines: 1, members: 2 */
	/* last cacheline: 9 bytes */
} __attribute__((__packed__)) packed_t;
💡 FPU на M4 - одинарної точності

fpv4-sp-d16 вміє лише float. Функція add з dbl.c (a + b для double) компілюється у bl __aeabi_dadd, тобто в програмну емуляцію, а addf для float - в одну vadd.f32. Padding навколо double на цьому тлі дрібниця. Якщо точність дозволяє, беріть float.

Регістри та стек теж вирівнюються

Правила вирівнювання діють не лише в структурах. AAPCS передає перші аргументи в r0 - r3, і 64-бітне значення мусить зайняти парну пару регістрів:

1
2
3
4
5
6
7
#include <stdint.h>

uint64_t sink(uint32_t a, uint64_t b);  // визначення  -  в іншому файлі

uint64_t call(void) {
    return sink(0x11111111u, 0x2222222233333333ull);
}
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S args.c -o args.s

Компілятор пише константи в десятковому вигляді, коментарі з hex і поясненнями - мої:

1
2
3
4
5
call:
        mov     r2, #858993459  @ 0x33333333  -  молодша половина b
        mov     r3, #572662306  @ 0x22222222  -  старша половина b
        mov     r0, #286331153  @ 0x11111111  -  a
        b       sink            @ r1 не використано: це padding у регістрах

a іде в r0, r1 пропускається, b іде в r2:r3. Порядок аргументів має значення так само, як порядок полів: sink(uint64_t b, uint32_t a) вліз би в r0 - r2 і залишив r3 вільним для ще одного аргументу. Якби аргументів не вистачило регістрів, на стеку 64-бітне значення так само лягло б на адресу, кратну 8.

Сам стек теж вирівняний: AAPCS вимагає, щоб SP був кратний 8 на межах публічних функцій, а Cortex-M при вході у виняток вирівнює кадр по 8 (CCR.STKALIGN; на M0 завжди). А от розміщення локальних змінних усередині кадру - справа компілятора: кілька char цілком можуть лежати поруч на непарних адресах, тому покладатися тут ні на що не варто.

C++: те саме, плюс сюрпризи

C++ повністю успадковує правила C, але додає власні джерела прихованого розміру. Усі приклади в одному файлі:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <cstdio>
#include <cstdint>
#include <type_traits>

struct NoVirt   { uint32_t a; };
struct WithVirt { uint32_t a; virtual void f() {} };

struct Empty {};
struct DerivedEmpty { Empty e; uint32_t x; };  // порожнє поле-член
struct DerivedEBO : Empty { uint32_t x; };     // порожній базовий клас

struct Logger {};
struct Widget { [[no_unique_address]] Logger log; uint32_t value; };

struct alignas(32) Dma { uint8_t b[64]; };

static_assert(std::is_standard_layout_v<NoVirt>);
static_assert(!std::is_standard_layout_v<WithVirt>);

int main() {
    WithVirt w;  // щоб vtable точно потрапила в бінарник
    w.f();
    std::printf("NoVirt=%zu WithVirt=%zu\n", sizeof(NoVirt), sizeof(WithVirt));
    std::printf("member Empty=%zu  base Empty (EBO)=%zu  [[no_unique_address]]=%zu\n",
                sizeof(DerivedEmpty), sizeof(DerivedEBO), sizeof(Widget));
    std::printf("Dma align=%zu\n", alignof(Dma));
}
1
2
3
4
g++ -std=c++20 lay.cpp -o lay && ./lay  # x86-64
NoVirt=4 WithVirt=16
member Empty=8  base Empty (EBO)=4  [[no_unique_address]]=4
Dma align=32
1
2
3
4
g++ -m32 -std=c++20 lay.cpp -o lay32 && ./lay32  # 32-бітний режим
NoVirt=4 WithVirt=8
member Empty=8  base Empty (EBO)=4  [[no_unique_address]]=4
Dma align=32
1
2
3
nm -C lay | grep vtable  # чия vtable потрапила в бінарник
0000000000003d88 V vtable for WithVirt
                 U vtable for __cxxabiv1::__class_type_info@CXXABI_1.3

Що тут видно:

  • vptr. Одна virtual-функція, і об’єкт розтовстів: на 32-бітному таргеті +4 байти (4 => 8), на x86-64 аж до 16 через вирівнювання. nm показує, що vtable є лише у WithVirt (друга - службова, з RTTI). У масивах поліморфних об’єктів у RAM це відчутно, тому вбудований код часто воліє switch або таблиці функцій.
  • EBO. Порожній базовий клас не займає місця (4), а порожнє поле-член займає (8, бо кожен об’єкт мусить мати власну адресу). [[no_unique_address]] (C++20) дає той самий ефект для полів-членів.
  • alignas(32) - стандартний спосіб для DMA-буферів. У C довго було лише розширення компілятора (__attribute__((aligned)), __alignof__), _Alignas/_Alignof з’явились у C11, а в C23 alignas/alignof стали повноцінними ключовими словами, як у C++.
  • Не standard-layout. static_assert у файлі перевіряє, що WithVirt не standard-layout: покладатися на порядок полів, offsetof чи сумісність із C для таких типів не можна. Для того, що йде у Flash чи в протокол, тримайте прості структури й ставте static_assert(std::is_standard_layout_v<T>).

Rust: інша філософія

У C/C++ порядок полів у пам’яті = порядок у коді. У Rust за замовчуванням (repr(Rust)) компілятору дозволено переставляти поля, щоб зекономити місце.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use std::mem::{align_of, offset_of, size_of};

struct AutoOrder { a: u8, b: u32, c: u16 }

#[repr(C)]
struct CLayout { a: u8, b: u32, c: u16 }

#[repr(C, packed)]
struct FwHeader { magic: u32, version: u16, flags: u8, crc: u32 }

#[repr(C, align(32))]
struct DmaBuf([u8; 64]);

// перевірка layout на етапі компіляції, аналог _Static_assert
const _: () = assert!(size_of::<FwHeader>() == 11);
const _: () = assert!(offset_of!(FwHeader, crc) == 7);

fn main() {
    println!("repr(Rust): size={} align={}",
             size_of::<AutoOrder>(), align_of::<AutoOrder>());
    println!("  offsets: a={} b={} c={}",
             offset_of!(AutoOrder, a), offset_of!(AutoOrder, b),
             offset_of!(AutoOrder, c));
    println!("repr(C):    size={} align={}",
             size_of::<CLayout>(), align_of::<CLayout>());
    println!("packed:     size={} align={}",
             size_of::<FwHeader>(), align_of::<FwHeader>());
    println!("DmaBuf:     size={} align={}",
             size_of::<DmaBuf>(), align_of::<DmaBuf>());
    println!("&u32={} Option<&u32>={}",
             size_of::<&u32>(), size_of::<Option<&u32>>());

    let h = FwHeader {
        magic: 0xA5A5_0001,
        version: 3,
        flags: 0,
        crc: 0xDEAD_BEEF
    };
    let crc = h.crc;  // копія значення: ОК
    println!("crc = {:#x}, bytes LE = {:02x?}", crc, crc.to_le_bytes());
}
1
2
3
4
5
6
7
8
rustc -O lay.rs -o lay && ./lay  # offset_of! потребує Rust 1.77+
repr(Rust): size=8 align=4
  offsets: a=6 b=0 c=4
repr(C):    size=12 align=4
packed:     size=11 align=1
DmaBuf:     size=64 align=32
&u32=8 Option<&u32>=8
crc = 0xdeadbeef, bytes LE = [ef, be, ad, de]

Ті самі u8, u32, u16: у C це 12 байт, а в repr(Rust) компілятор переставив поля (b на 0, c на 4, a на 6) й отримав 8, рівно як наша “відсортована” struct good у C. Звільнення від людського фактора, але є застереження: layout repr(Rust) не гарантований і може змінитися між версіями компілятора. Тому для всього, що виходить за межі вашого Rust-коду (FFI, регістри, Flash, мережа), потрібен явний #[repr(C)] або #[repr(C, packed)].

const _: () = assert!(...) у файлі - Rust-аналог _Static_assert: невдала перевірка зупиняє компіляцію.

Layout кожного типу від компілятора: -Z print-type-sizes (nightly)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
rustc +nightly -Z print-type-sizes -O lay.rs  # з cargo: cargo +nightly rustc -- -Z print-type-sizes
print-type-size type: `CLayout`: 12 bytes, alignment: 4 bytes
print-type-size     field `.a`: 1 bytes
print-type-size     padding: 3 bytes
print-type-size     field `.b`: 4 bytes, alignment: 4 bytes
print-type-size     field `.c`: 2 bytes
print-type-size     end padding: 2 bytes
print-type-size type: `AutoOrder`: 8 bytes, alignment: 4 bytes
print-type-size     field `.b`: 4 bytes
print-type-size     field `.c`: 2 bytes
print-type-size     field `.a`: 1 bytes
print-type-size     end padding: 1 bytes

Це найближчий аналог pahole для Rust: видно і переставлені поля, і padding.

Крім того:

  • Niche optimization. Option<&u32> займає стільки ж, скільки &u32 (8 на 64-бітному хості): нульовий вказівник для &T заборонений, тож None кодується ним, а окремий тег не потрібен.
  • Для вирівнювання DMA-буферів: #[repr(C, align(32))] на обгортці над масивом (DmaBuf у файлі).
  • Посилання на поле packed-структури. Rust не пустить у UB:
1
let r = &h.crc;  // посилання на невирівняне поле
1
2
3
4
error[E0793]: reference to packed field is unaligned
  = note: packed structs are only aligned by one byte, and many modern architectures penalize unaligned field accesses
  = note: creating a misaligned reference is undefined behavior (even if that reference is never dereferenced)
  = help: copy the field contents to a local variable, or replace the reference with a raw pointer and use `read_unaligned`/`write_unaligned` (loads and stores via `*p` must be properly aligned even when using raw pointers)

Це та сама пастка, що й read_via_ptr у C, але в Rust вона hard error, а не попередження. Читайте поле копією (let crc = h.crc;), як у прикладі вище.

Зведена таблиця

CC++Rust (repr(Rust))Rust (repr(C))
Порядок полівяк у кодіяк у коді (база => похідний)не гарантований, компілятор оптимізуєяк у коді
Paddingприродне вирівнюванняте самемінімізується автоматичнояк у C
Packed__attribute__((packed)) / #pragma packте саме#[repr(packed)]#[repr(C, packed)]
Вирівнювання для DMAaligned(N) / _Alignas (C11), alignas (C23)alignas(N)#[repr(align(N))]#[repr(C, align(N))]
Прихований розмірнемаєvptr, базинемаєнемає
Перевірка в compile time_Static_assertstatic_assertconst _: () = assert!(...) + offset_of!те саме
Інструментиpahole, -Wpadded, -Wcast-alignте саме + nm -C для vtable-Z print-type-sizesте саме
Придатність для FFI/Flashтаклише standard-layoutнітак

Золоті правила розробника

  1. Сортуйте поля від більшого до меншого і перевіряйте результат у pahole -C <тип> (або -Wpadded). Не рахуйте в голові.
  2. Фіксуйте розмір і зсуви через _Static_assert / static_assert / const assert!. Це страховка від сюрпризів при зміні платформи, компілятора чи прапорів.
  3. packed лише для форматів, що лежать у Flash. Не для MMIO, не “щоб влізло” в кожну структуру. Якщо формат дозволяє, додайте aligned(4), щоб не платити побайтовим читанням за вирівняні поля.
  4. Усе, що йде в мережу, серіалізуйте явно. packed не лікує ні endianness, ні сміття в padding.
  5. Не беріть адрес поля packed-структури й не кастуйте байтовий буфер у uint32_t *. Для читання з буфера є get_u32le-подібні функції або memcpy. Тримайте -Wcast-align і -Waddress-of-packed-member увімкненими, на хості - -fsanitize=alignment.
  6. Пам’ятайте про ядро. На M0/M0+ нечесний невирівняний ldr = HardFault. На M3/M4/M7 це мовчки повільно (а для ldrd/ldm/vldr - теж fault), ловиться через UNALIGN_TRP разом із -mno-unaligned-access.
  7. У Rust усе, що виходить за межі Rust-коду, має #[repr(C)]. repr(Rust) лишайте для внутрішніх структур.
System Status: Running (0 HardFaults in last 24h) | Compiled with -Os
Створено з Hugo
Тема Stack, дизайн Jimmy