[{"content":"Familiar situation: you designed a binary protocol, everything\u0026rsquo;s beautiful, the struct matches the spec one-to-one. You send it over UART or CAN, and the receiver gets garbage. Or: it works on a Cortex-M4, but on an M0+ it dies with a HardFault and a \u0026ldquo;but I didn\u0026rsquo;t do anything weird\u0026rdquo;.\nSpoiler: a struct in C - is not \u0026ldquo;a group of variables\u0026rdquo;, it\u0026rsquo;s a specific picture of bytes, and that picture is drawn not by us but by the compiler. The outputs below come from GCC 13.3 (x86-64), arm-none-eabi-gcc 13.2.1, pahole 1.25 and rustc 1.84; with other versions the asm details may differ.\n📝 TL;DR The compiler inserts invisible bytes (padding) between fields so that every field sits at an address that is a multiple of its alignof. Field order affects size. Sort fields from largest to smallest and check with pahole. packed removes padding, but drops the alignment of the whole struct to 1: on Cortex-M0 even fields at offset 0 are read byte by byte. packed, aligned(4) partially fixes this. A HardFault on M0 is caused not by packed itself, but by code that \u0026ldquo;forgets\u0026rdquo; about the misalignment: a cast uint8_t * =\u0026gt; uint32_t * or taking the address of a packed field. packed ≠ \u0026ldquo;ready-made wire format\u0026rdquo;: there\u0026rsquo;s also byte order and garbage in the padding. For protocols, serialize explicitly. Alignment - is an ABI matter: double on i386 is aligned to 4, on ARM and x86-64 to 8. Even arguments in registers get aligned. On a Cortex-M7 with D-cache, a DMA buffer is aligned to the cache line (32 bytes) both by address and by size, otherwise cache maintenance will hit neighboring variables. C++ adds hidden bytes (vptr), Rust reorders fields by itself by default. The difference between the languages is not in \u0026ldquo;alignment\u0026rdquo;, but in what each of them guarantees. Hardware: why alignment exists at all Memory is not an endless tape of bytes, but a set of cells accessed over a bus of a particular width.\nOne bank, 8-bit cells. To read a 32-bit number you need 4 cycles: byte =\u0026gt; byte =\u0026gt; byte =\u0026gt; byte, and then stitch them together. Several parallel banks (say, 4 one-byte banks). If a 32-bit value sits at an address divisible by 4, all four banks return their byte at once: 1 cycle. If the value is \u0026ldquo;cut\u0026rdquo; across two neighboring words, you need two accesses plus a shift and a merge. Hence natural alignment: a value of type T must sit at an address for which addr % alignof(T) == 0. For scalar types alignof usually equals sizeof, but not always: the platform\u0026rsquo;s ABI has the final say (below we\u0026rsquo;ll see how double on i386 is aligned only to 4). A struct has to lay out its fields so that this condition holds for each of them.\nOn a 32-bit core an aligned word is read in a single bus access, while a double takes two, since it\u0026rsquo;s two words. An unaligned access costs more: on some cores through hidden extra cycles, on others (Cortex-M0/M0+) - a HardFault.\nExperiment #1: guess the sizeof Four structs. Try to guess the sizes on paper before reading on:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stddef.h\u0026gt; #include \u0026lt;stdint.h\u0026gt; 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(\u0026#34;%-4s sizeof=%2zu alignof=%zu\\n\u0026#34;, #T, sizeof(T), _Alignof(T)) int main(void) { SHOW(a_t); SHOW(b_t); SHOW(c_t); SHOW(d_t); printf(\u0026#34;c_t offsets: a=%zu b=%zu c=%zu\\n\u0026#34;, offsetof(c_t, a), offsetof(c_t, b), offsetof(c_t, c)); printf(\u0026#34;d_t offsets: a=%zu b=%zu c=%zu\\n\u0026#34;, 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 \u0026amp;\u0026amp; ./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 The naive sum of fields vs. reality:\nstruct sum of fields actual (x86-64, ARM EABI) alignof a_t 3 4 2 b_t 7 8 4 c_t 13 24 8 d_t 13 16 8 The alignof column - is the key to everything: a struct\u0026rsquo;s alignment equals the largest alignment among its fields.\na_t and b_t are obvious: one byte of padding between u8 and u16, and likewise between u8 and u32.\nBut c_t usually breaks people\u0026rsquo;s brains. Let\u0026rsquo;s count: a (1 byte), 7 bytes of padding, b (8 bytes, offset 8), c (4 bytes, offset 16). We end at 20. So why 24?\nBecause the size of a struct must be a multiple of its alignment (here 8, thanks to double). Otherwise in an array c_t arr[2] the second element would land at offset 20, and its double would become misaligned. So another 4 bytes of tail padding get added at the end. The same rule works recursively for structs inside structs.\n--- title: \"c_t - 24 bytes\" config: packet: bitsPerRow: 8 --- packet-beta 0: \"a\" 1-7: \"_padding_\" 8-15: \"b (double)\" 16-19: \"c\" 20-23: \"_tail_\"d_t says the same thing from the other side: same types, but the big double comes first, so there are no internal holes, and the tail padding is only 3 bytes (13 =\u0026gt; 16).\n--- title: \"d_t = 16 bytes: same fields\" config: packet: bitsPerRow: 8 --- packet-beta 0-7: \"a (double)\" 8-11: \"b\" 12: \"c\" 13-15: \"_tail_\"Field order = memory savings, and for free. A practical rule: sort fields by descending size (more precisely, by descending alignof).\n1 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 Ask the compiler to tell you itself 1 2 3 4 5 6 gcc -Wpadded -c sizes.c -o /dev/null sizes.c:6:39: warning: padding struct to align \u0026#39;b\u0026#39; [-Wpadded] sizes.c:7:51: warning: padding struct to align \u0026#39;c\u0026#39; [-Wpadded] sizes.c:8:39: warning: padding struct to align \u0026#39;b\u0026#39; [-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] We see both the internal padding and the tail padding (4 bytes for c_t, 3 for d_t). For a real project -Wpadded is too chatty to keep on all the time, but for a one-off review of your structs - it\u0026rsquo;s exactly what you need.\nExperiment #2: looking through pahole -Wpadded is useful, but the holes are shown most clearly by pahole (the dwarves package). It reads the DWARF info from an object file and draws the struct the way it lies in memory. It works with ARM object files too.\n1 2 3 4 5 6 #include \u0026lt;stdint.h\u0026gt; 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; // at least one variable of each type, see pitfall #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; Everything we worked out by hand is stated right here: a 7-byte hole, 4 bytes of tail padding, 13 useful bytes out of 24. And now d_t:\n1 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; No XXX holes, just 3 bytes at the tail.\nThree pitfalls you can step on 1. A bare pahole file.o may stay silent. No error at all, exit code 0. The reason: the structs in the example are anonymous (typedef struct { ... } c_t;), and a bare invocation prints only structs that have a tag. The DWARF format has nothing to do with it: with -gdwarf-4 the behavior is the same. There are two ways to fix it:\n1 pahole -C c_t file.o # look up by typedef name, or give the struct a tag: typedef struct c_s { ... } c_t; and then bare pahole works too 2. A type that isn\u0026rsquo;t used anywhere doesn\u0026rsquo;t make it into DWARF. By default GCC drops unused types from the debug info. If a struct shows up only in sizeof, pahole will say type 'X' not found. Either create at least one variable of that type (like gc and gd above), or ask GCC not to drop anything:\n1 arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -fno-eliminate-unused-debug-types -c file.c -o file.o 3. You need -g. Without debug info pahole has nothing to read from.\nExperiment #3: packed and a firmware header Now a realistic case: a firmware image header that sits in Flash and whose format is fixed byte by byte. We\u0026rsquo;ll use this file in the next two experiments, so here it is in full:\n1 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 \u0026lt;stdint.h\u0026gt; #include \u0026lt;stddef.h\u0026gt; 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, \u0026#34;natural: 12\u0026#34;); _Static_assert(offsetof(fw_header_natural_t, crc) == 8, \u0026#34;natural crc @ 8\u0026#34;); _Static_assert(sizeof(fw_header_packed_t) == 11, \u0026#34;packed: 11\u0026#34;); _Static_assert(offsetof(fw_header_packed_t, crc) == 7, \u0026#34;packed crc @ 7\u0026#34;); _Static_assert(sizeof(fw_header_packed_a4_t) == 12, \u0026#34;packed+aligned(4): 12\u0026#34;); // Reading an unaligned field: the compiler KNOWS it\u0026#39;s packed uint32_t read_crc(const fw_header_packed_t *h) { return h-\u0026gt;crc; } // An aligned field (offset 0) in a packed struct with alignof == 1 uint32_t read_magic(const fw_header_packed_t *h) { return h-\u0026gt;magic; } // Same thing, but the struct is packed + aligned(4) uint32_t read_magic_a4(const fw_header_packed_a4_t *h) { return h-\u0026gt;magic; } uint32_t read_crc_a4(const fw_header_packed_a4_t *h) { return h-\u0026gt;crc; } // Taking the address of a packed field: the packed-ness gets lost uint32_t crc_of(const uint32_t *p); // somewhere in another file: return *p; uint32_t read_via_ptr(const fw_header_packed_t *h) { return crc_of(\u0026amp;h-\u0026gt;crc); } In the natural variant the compiler inserts 1 byte of padding between flags (offset 6) and crc, crc lands at offset 8, and the size is 12. In packed there\u0026rsquo;s no padding: crc is at offset 7, and the size is 11.\n--- title: \"fw_header_natural_t\" config: packet: bitsPerRow: 4 --- packet-beta 0-3: \"magic\" 4-5: \"version\" 6: \"flags\" 7: \"pad\" 8-11: \"crc\"--- title: \"fw_header_packed_t\" config: packet: bitsPerRow: 4 --- packet-beta 0-3: \"magic\" 4-5: \"version\" 6: \"flags\" 7-10: \"crc\"The second diagram shows exactly the case from the memory banks section: crc starts in one word and ends in another.\nNote the _Static_asserts. They compile to zero bytes, but if someone a year from now adds a field or changes a compiler flag, the build will fail with a human-readable message instead of the firmware failing in the field.\npacked looks like the perfect solution. Now for the price.\nExperiment #4: what packed costs The read_crc function from fw.c reads an unaligned field:\n1 uint32_t read_crc(const fw_header_packed_t *h) { return h-\u0026gt;crc; } We compile for three configurations. There are no C field names in the .s file, only offsets like [r0, #7], so -fverbose-asm adds comments, and sed cuts out the function we need:\n1 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 \u0026#39;/^read_crc:/,/^\\s*bx\\s*lr/p\u0026#39; fw_m4.s # and likewise for fw_m0.s, fw_m4_nounal.s Cortex-M4 (ARMv7-M, unaligned access is supported in hardware):\n1 2 3 read_crc: ldr r0, [r0, #7] @ unaligned bx lr One instruction. GCC only leaves an @ unaligned comment. The cost is hidden: inside, the core makes several bus accesses instead of one, so it\u0026rsquo;s slower, but you can\u0026rsquo;t see that in the assembly.\nCortex-M0 (ARMv6-M, no hardware support):\n1 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 Now you can see what\u0026rsquo;s going on: four ldrb (a byte each), three shifts, three merges. Ten instructions instead of one.\nCortex-M4 with -mno-unaligned-access: same thing, but with the barrel shifter in 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 can shift the second operand right inside orr, so separate lsls aren\u0026rsquo;t needed: 7 instructions instead of 10.\nThe key takeaway: the price of packed is determined by the core. The compiler knows the field is unaligned (because you wrote packed yourself) and picks a safe sequence for the specific architecture. So in this case there won\u0026rsquo;t be a HardFault, it will just be slower and longer.\nSurprise: even magic is read byte by byte packed removes not only the padding between fields: it drops the alignof of the whole struct to 1. The compiler can no longer assume that the pointer h itself is aligned, so on M0 even magic at offset 0 is assembled from bytes:\n1 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 The cure - give the struct its alignment back without bringing back the padding between fields: __attribute__((packed, aligned(4))) (the fw_header_packed_a4_t type in fw.c). Now the compiler knows that the base is 4-aligned, and fields at \u0026ldquo;honest\u0026rdquo; offsets are read with a single instruction even on M0 (the comments in the asm - are mine):\n1 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] @ byte 7 separately ldr r0, [r0, #8] @ word 8..11 is aligned lsls r0, r0, #8 @ byte 11 (tail padding) gets shifted out orrs r0, r3 bx lr Even the unaligned crc got cheaper: the compiler reads the aligned word at offset 8 and glues on one more byte. This is legal precisely because byte 11 belongs to the struct (tail padding).\nfunction M4 M4 -mno-unaligned-access M0 read_crc (packed) 1 7 10 read_magic (packed) 1 7 10 read_magic_a4 (packed + aligned(4)) 1 1 1 read_crc_a4 (packed + aligned(4)) 1 3 4 Instruction count, not including bx lr.\n📌 Important aligned(4) brings tail padding back: sizeof(fw_header_packed_a4_t) == 12, not 11. For a header in Flash that you lay out yourself, that\u0026rsquo;s usually acceptable. For a format where the payload follows right after the 11th byte, - it\u0026rsquo;s not. That\u0026rsquo;s why there\u0026rsquo;s a _Static_assert on the size in fw.c for this type too.\nWhere M0 really crashes A HardFault on M0 happens when the compiler doesn\u0026rsquo;t know the address is unaligned. There are two typical ways of \u0026ldquo;not telling\u0026rdquo; it: casting a byte buffer and taking the address of a packed field.\nPointer cast 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include \u0026lt;stdint.h\u0026gt; #include \u0026lt;string.h\u0026gt; // The compiler does NOT know the address is unaligned: a cast - is a promise uint32_t read_cast(const uint8_t *buf) { return *(const uint32_t *)(buf + 1); } // Correct: memcpy uint32_t read_memcpy(const uint8_t *buf) { uint32_t v; memcpy(\u0026amp;v, buf + 1, sizeof v); return v; } // Correct, and with explicit byte order: little-endian from any address uint32_t get_u32le(const uint8_t *p) { return (uint32_t)p[0] | (uint32_t)p[1] \u0026lt;\u0026lt; 8 | (uint32_t)p[2] \u0026lt;\u0026lt; 16 | (uint32_t)p[3] \u0026lt;\u0026lt; 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 (any flags, even -mno-unaligned-access) read_cast: ldr r0, [r0, #1] bx lr A plain ldr. No @ unaligned, no byte-by-byte assembly: for the compiler, a cast uint8_t * =\u0026gt; uint32_t * means a promise of \u0026ldquo;this is definitely 4-aligned\u0026rdquo; (and per the C standard it also violates strict aliasing). If buf + 1 isn\u0026rsquo;t a multiple of 4, then on M0/M0+ it\u0026rsquo;s an instant HardFault, and on M3/M4/M7 - a silent slow access (until we enable the trap, see below).\nWhy does M0 have a separate adds? In Thumb-1, LDR with an immediate offset encodes an offset that\u0026rsquo;s a multiple of 4 (#0..#124), so [r0, #1] simply doesn\u0026rsquo;t exist as an instruction. The M0 instruction set itself reminds you that a word must be aligned.\nAddress of a packed field The second way - passing a field\u0026rsquo;s address to somewhere that expects a regular pointer:\n1 2 uint32_t crc_of(const uint32_t *p); // somewhere in another file: return *p; uint32_t read_via_ptr(const fw_header_packed_t *h) { return crc_of(\u0026amp;h-\u0026gt;crc); } Inside crc_of there\u0026rsquo;s no information about packed at all: it\u0026rsquo;s a plain ldr r0, [r0], and what arrives in it is h + 7. On M0 - HardFault. GCC warns about this by default:\n1 fw.c:44:68: warning: taking address of packed member of \u0026#39;struct \u0026lt;anonymous\u0026gt;\u0026#39; may result in an unaligned pointer value [-Waddress-of-packed-member] 🚨 Caution This is the most common real-world scenario: the struct is packed, and the field is passed by pointer into a CRC or parse function. It\u0026rsquo;s worth keeping the -Waddress-of-packed-member warning as an error (-Werror=address-of-packed-member).\nHow to read correctly cast.c has two more variants. Here\u0026rsquo;s what the compiler makes of them:\n1 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 instructions, same as read_crc ... memcpy is correct everywhere, and on M4 it collapses into a single ldr. But on M0 GCC 13 left a real call to the library memcpy. get_u32le with shifts is correct everywhere; on M4 GCC recognizes the pattern itself and also collapses it into a single ldr, while on M0 it gives the same byte-by-byte assembly with no call. And most importantly: it pins down the byte order, which memcpy knows nothing about. For reading from protocol buffers it\u0026rsquo;s the best choice.\nThe compiler and the sanitizer as watchmen -Wcast-align on ARM warns about a cast uint8_t * =\u0026gt; uint32_t * (cast increases required alignment of target type). On x86 the same flag stays silent, because the architecture tolerates unaligned accesses. There you need -Wcast-align=strict. If your business logic is tested on the host, -fsanitize=alignment catches unaligned access at runtime, even on x86: 1 2 3 4 5 6 7 8 9 10 11 #include \u0026lt;assert.h\u0026gt; #include \u0026lt;stdint.h\u0026gt; #include \u0026#34;cast.c\u0026#34; // yes, this is a bit hack-ish 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); // on a little-endian host assert(read_cast(buf) == 0xDEADBEEF); // UBSan will stop here return 0; } 1 2 gcc -std=c11 -O1 -g -fsanitize=alignment -fno-sanitize-recover=alignment cast_test.c -o cast_test \u0026amp;\u0026amp; ./cast_test cast.c:6:49: runtime error: load of misaligned address 0x7ffe38e3a431 for type \u0026#39;const uint32_t\u0026#39;, which requires 4 byte alignment get_u32le and memcpy pass, while read_cast stops the test with the exact line.\nHow to find unaligned accesses on M3/M4/M7 On these cores an unaligned access silently works, which means the bugs hide. But you can enable a trap:\n1 SCB-\u0026gt;CCR |= SCB_CCR_UNALIGN_TRP_Msk; // CMSIS Now every unaligned LDR/STR will raise a UsageFault (with the UNALIGNED flag in UFSR, and if the UsageFault handler isn\u0026rsquo;t enabled, it escalates to a HardFault). This isn\u0026rsquo;t about \u0026ldquo;slow spots\u0026rdquo;, it\u0026rsquo;s about every place where you rely on hardware tolerance. Useful for catching, on an M4, code that will \u0026ldquo;accidentally\u0026rdquo; break when migrating to M0.\nOne nuance: with the trap on, the code from read_crc above (ldr r0, [r0, #7] @ unaligned) will start to fail, because by default GCC considers an unaligned ldr acceptable. So along with the trap, add -mno-unaligned-access: then for packed fields the compiler will switch to byte-by-byte reads on its own. The upshot: compiler flag + trap = an honest check that the code is portable to M0.\nSmall types: LDRB, LDRSB, LDRH A byte can be read from any offset with a single instruction, and it lands right in the low bits of the register: no need to shift or mask anything by hand.\n1 2 3 4 5 6 7 8 9 10 11 12 #include \u0026lt;stdint.h\u0026gt; typedef struct __attribute__((packed)) { uint8_t pad; // offset 0: only there to shift the following fields uint8_t u8; // offset 1 int8_t s8; // offset 2 uint16_t u16; // offset 3: odd, not a multiple of 2 } small_t; uint32_t read_u8 (const small_t *s) { return s-\u0026gt;u8; } int32_t read_s8 (const small_t *s) { return s-\u0026gt;s8; } uint32_t read_u16(const small_t *s) { return s-\u0026gt;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 takes a byte from an odd offset and zero-extends it to 32 bits - one instruction on both cores. For a signed byte, M4 has ldrsb with an immediate offset, while M0 has no such form (in Thumb-1 LDRSB only supports a register offset), hence ldrb + sxtb. A uint16_t at offset 3 is already unaligned: on M4 it\u0026rsquo;s ldrh with @ unaligned, on M0 - assembly from two bytes. Compare with architectures that have no byte loads: there you\u0026rsquo;d have to read a whole word, shift, and mask with \u0026amp; 0xFF. On ARM the hardware takes care of that.\nPadding - isn\u0026rsquo;t everything: don\u0026rsquo;t send a struct over the network You might think: \u0026ldquo;Aha, packed, 11 bytes, I\u0026rsquo;ll send the struct as is.\u0026rdquo; No. Let\u0026rsquo;s look at what actually sits in memory in a regular (non-packed) struct and what should go into the protocol:\n1 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 \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdint.h\u0026gt; #include \u0026lt;stddef.h\u0026gt; #include \u0026lt;string.h\u0026gt; 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 \u0026gt;\u0026gt; 8; p[2] = v \u0026gt;\u0026gt; 16; p[3] = v \u0026gt;\u0026gt; 24; } static void put_u16le(uint8_t *p, uint16_t v) { p[0] = v; p[1] = v \u0026gt;\u0026gt; 8; } #define HDR_NET_SIZE 11u // size from the protocol spec, not sizeof() static size_t hdr_serialize(uint8_t *out, const fw_header_natural_t *h) { put_u32le(out + 0, h-\u0026gt;magic); put_u16le(out + 4, h-\u0026gt;version); out[6] = h-\u0026gt;flags; put_u32le(out + 7, h-\u0026gt;crc); return HDR_NET_SIZE; } static void dump(const char *name, const void *p, size_t n) { printf(\u0026#34;%s\u0026#34;, name); for (size_t i = 0; i \u0026lt; n; i++) printf(\u0026#34;%02x \u0026#34;, ((const uint8_t *)p)[i]); printf(\u0026#34;\\n\u0026#34;); } int main(void) { fw_header_natural_t h; memset(\u0026amp;h, 0xCC, sizeof h); // simulating \u0026#34;garbage\u0026#34; on the stack h.magic = 0xA5A50001; h.version = 3; h.flags = 0x80; h.crc = 0xDEADBEEF; uint8_t wire[HDR_NET_SIZE]; hdr_serialize(wire, \u0026amp;h); dump(\u0026#34;on the wire: \u0026#34;, wire, sizeof wire); dump(\u0026#34;raw struct: \u0026#34;, \u0026amp;h, sizeof h); return 0; } 1 2 3 4 gcc -std=c11 -Wall -O2 ser.c -o ser \u0026amp;\u0026amp; ./ser on the wire: 01 00 a5 a5 03 00 80 ef be ad de raw struct: 01 00 a5 a5 03 00 80 cc ef be ad de # ^^ padding memset(..., 0xCC, ...) simulates the stack: the value of a padding byte is not defined by the standard, so in real code it will be whatever was lying on the stack before. Which means a raw struct on the wire - is also a memory content leak. Plus its byte order is dictated by the sender\u0026rsquo;s architecture. Cortex-M (in practice) and x86 - are little-endian, but the first big-endian network partner will break everything.\nSo the rule is: packed - is for formats that sit in Flash and are read by the same code (image headers, config blocks). Anything that goes outside, serialize explicitly: with shifts, like put_u32le/get_u32le, in Rust - to_le_bytes()/from_le_bytes(). That way you solve padding, endianness, and alignment all at once.\n⚠️ Warning packed is not for MMIO registers. Peripheral registers must be naturally aligned and read at their own width, while packed can turn a 32-bit access into four byte accesses, which the register either doesn\u0026rsquo;t support or reacts to differently (for example, read-to-clear).\nAlignment - is an ABI matter, not a language one Why is a double in a struct aligned to 8 even though the bus is 32-bit? Because the ABI said so: fewer bus accesses at the cost of more padding. Let\u0026rsquo;s check: the same sizes.c, but for 32-bit x86:\n1 2 3 4 5 6 7 gcc -m32 -std=c11 sizes.c -o sizes32 \u0026amp;\u0026amp; ./sizes32 a_t sizeof= 4 alignof=2 b_t sizeof= 8 alignof=4 c_t sizeof=16 alignof=4 # \u0026lt;-- was 24 and 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 On i386 System V a double inside a struct is aligned only to 4, so c_t shrinks from 24 to 16 (b at offset 4, not 8). On ARM EABI (AAPCS) and x86-64 the requirement is 8. The takeaway: a struct\u0026rsquo;s size is not portable across platforms, and that\u0026rsquo;s one more reason to put in a _Static_assert.\nAnd what about double on 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; // so the types make it into DWARF for pahole double read_natural(const natural_t *p) { return p-\u0026gt;b; } double read_packed (const packed_t *p) { return p-\u0026gt;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 An aligned double is loaded with a single vldr.64. For an unaligned one the compiler can\u0026rsquo;t use vldr (on an unaligned address it always raises a UsageFault, see above), and can\u0026rsquo;t even use ldrd. So it takes two single ldrs and moves them into the FPU register via vmov.\npahole shows the cost in bytes:\n1 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; 💡 The M4 FPU - is single-precision fpv4-sp-d16 handles only float. The add function from dbl.c (a + b for double) compiles into bl __aeabi_dadd, i.e. software emulation, while addf for float - into a single vadd.f32. Against that backdrop, padding around a double is small potatoes. If the precision allows, go with float.\nRegisters and the stack get aligned too Alignment rules apply not only in structs. AAPCS passes the first arguments in r0 - r3, and a 64-bit value must occupy an even register pair:\n1 2 3 4 5 6 7 #include \u0026lt;stdint.h\u0026gt; uint64_t sink(uint32_t a, uint64_t b); // definition - in another file uint64_t call(void) { return sink(0x11111111u, 0x2222222233333333ull); } 1 arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S args.c -o args.s The compiler writes constants in decimal, the hex comments and explanations - are mine:\n1 2 3 4 5 call: mov r2, #858993459 @ 0x33333333 - low half of b mov r3, #572662306 @ 0x22222222 - high half of b mov r0, #286331153 @ 0x11111111 - a b sink @ r1 unused: this is padding in registers a goes into r0, r1 is skipped, b goes into r2:r3. Argument order matters just like field order: sink(uint64_t b, uint32_t a) would fit into r0 - r2 and leave r3 free for one more argument. If the arguments ran out of registers, on the stack a 64-bit value would likewise land at an address that\u0026rsquo;s a multiple of 8.\nThe stack itself is aligned too: AAPCS requires SP to be a multiple of 8 at public function boundaries, and on exception entry Cortex-M aligns the frame to 8 (CCR.STKALIGN; on M0 always). But the placement of local variables inside a frame - is up to the compiler: several chars may well sit next to each other at odd addresses, so there\u0026rsquo;s nothing to rely on here.\nC++: the same, plus surprises C++ fully inherits the C rules, but adds its own sources of hidden size. All the examples are in one file:\n1 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 \u0026lt;cstdio\u0026gt; #include \u0026lt;cstdint\u0026gt; #include \u0026lt;type_traits\u0026gt; struct NoVirt { uint32_t a; }; struct WithVirt { uint32_t a; virtual void f() {} }; struct Empty {}; struct DerivedEmpty { Empty e; uint32_t x; }; // empty data member struct DerivedEBO : Empty { uint32_t x; }; // empty base class 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\u0026lt;NoVirt\u0026gt;); static_assert(!std::is_standard_layout_v\u0026lt;WithVirt\u0026gt;); int main() { WithVirt w; // so the vtable definitely ends up in the binary w.f(); std::printf(\u0026#34;NoVirt=%zu WithVirt=%zu\\n\u0026#34;, sizeof(NoVirt), sizeof(WithVirt)); std::printf(\u0026#34;member Empty=%zu base Empty (EBO)=%zu [[no_unique_address]]=%zu\\n\u0026#34;, sizeof(DerivedEmpty), sizeof(DerivedEBO), sizeof(Widget)); std::printf(\u0026#34;Dma align=%zu\\n\u0026#34;, alignof(Dma)); } 1 2 3 4 g++ -std=c++20 lay.cpp -o lay \u0026amp;\u0026amp; ./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 \u0026amp;\u0026amp; ./lay32 # 32-bit mode 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 # whose vtable ended up in the binary 0000000000003d88 V vtable for WithVirt U vtable for __cxxabiv1::__class_type_info@CXXABI_1.3 What we see here:\nvptr. One virtual function, and the object got fat: on a 32-bit target +4 bytes (4 =\u0026gt; 8), on x86-64 all the way to 16 because of alignment. nm shows that only WithVirt has a vtable (the other one - is a service one, from RTTI). In arrays of polymorphic objects in RAM this is noticeable, which is why embedded code often prefers switch or function tables. EBO. An empty base class takes no space (4), while an empty data member does (8, because every object must have its own address). [[no_unique_address]] (C++20) gives the same effect for data members. alignas(32) - the standard way to do DMA buffers. In C for a long time there were only compiler extensions (__attribute__((aligned)), __alignof__), _Alignas/_Alignof appeared in C11, and in C23 alignas/alignof became full-fledged keywords, like in C++. Not standard-layout. The static_assert in the file checks that WithVirt is not standard-layout: you can\u0026rsquo;t rely on field order, offsetof, or C compatibility for such types. For anything that goes to Flash or into a protocol, keep structs simple and add static_assert(std::is_standard_layout_v\u0026lt;T\u0026gt;). Rust: a different philosophy In C/C++ the field order in memory = the order in code. In Rust by default (repr(Rust)) the compiler is allowed to reorder fields to save space.\n1 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]); // compile-time layout check, the analog of _Static_assert const _: () = assert!(size_of::\u0026lt;FwHeader\u0026gt;() == 11); const _: () = assert!(offset_of!(FwHeader, crc) == 7); fn main() { println!(\u0026#34;repr(Rust): size={} align={}\u0026#34;, size_of::\u0026lt;AutoOrder\u0026gt;(), align_of::\u0026lt;AutoOrder\u0026gt;()); println!(\u0026#34; offsets: a={} b={} c={}\u0026#34;, offset_of!(AutoOrder, a), offset_of!(AutoOrder, b), offset_of!(AutoOrder, c)); println!(\u0026#34;repr(C): size={} align={}\u0026#34;, size_of::\u0026lt;CLayout\u0026gt;(), align_of::\u0026lt;CLayout\u0026gt;()); println!(\u0026#34;packed: size={} align={}\u0026#34;, size_of::\u0026lt;FwHeader\u0026gt;(), align_of::\u0026lt;FwHeader\u0026gt;()); println!(\u0026#34;DmaBuf: size={} align={}\u0026#34;, size_of::\u0026lt;DmaBuf\u0026gt;(), align_of::\u0026lt;DmaBuf\u0026gt;()); println!(\u0026#34;\u0026amp;u32={} Option\u0026lt;\u0026amp;u32\u0026gt;={}\u0026#34;, size_of::\u0026lt;\u0026amp;u32\u0026gt;(), size_of::\u0026lt;Option\u0026lt;\u0026amp;u32\u0026gt;\u0026gt;()); let h = FwHeader { magic: 0xA5A5_0001, version: 3, flags: 0, crc: 0xDEAD_BEEF }; let crc = h.crc; // copy of the value: OK println!(\u0026#34;crc = {:#x}, bytes LE = {:02x?}\u0026#34;, crc, crc.to_le_bytes()); } 1 2 3 4 5 6 7 8 rustc -O lay.rs -o lay \u0026amp;\u0026amp; ./lay # offset_of! needs 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 \u0026amp;u32=8 Option\u0026lt;\u0026amp;u32\u0026gt;=8 crc = 0xdeadbeef, bytes LE = [ef, be, ad, de] The same u8, u32, u16: in C that\u0026rsquo;s 12 bytes, while in repr(Rust) the compiler reordered the fields (b at 0, c at 4, a at 6) and got 8, exactly like our \u0026ldquo;sorted\u0026rdquo; struct good in C. Relief from the human factor, but with a caveat: the repr(Rust) layout is not guaranteed and can change between compiler versions. So for everything that crosses the boundary of your Rust code (FFI, registers, Flash, network) you need an explicit #[repr(C)] or #[repr(C, packed)].\nconst _: () = assert!(...) in the file - is the Rust analog of _Static_assert: a failed check stops compilation.\nThe layout of every type from the compiler: -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 # with 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 This is the closest analog of pahole for Rust: you can see both the reordered fields and the padding.\nBesides that:\nNiche optimization. Option\u0026lt;\u0026amp;u32\u0026gt; takes as much as \u0026amp;u32 (8 on a 64-bit host): a null pointer is forbidden for \u0026amp;T, so None is encoded with it, and no separate tag is needed. For aligning DMA buffers: #[repr(C, align(32))] on a wrapper around an array (DmaBuf in the file). A reference to a field of a packed struct. Rust won\u0026rsquo;t let you into UB: 1 let r = \u0026amp;h.crc; // reference to an unaligned field 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) This is the same trap as read_via_ptr in C, but in Rust it\u0026rsquo;s a hard error, not a warning. Read the field by copy (let crc = h.crc;), as in the example above.\nSummary table C C++ Rust (repr(Rust)) Rust (repr(C)) Field order as in the code as in the code (base =\u0026gt; derived) not guaranteed, the compiler optimizes as in the code Padding natural alignment same minimized automatically as in C Packed __attribute__((packed)) / #pragma pack same #[repr(packed)] #[repr(C, packed)] Alignment for DMA aligned(N) / _Alignas (C11), alignas (C23) alignas(N) #[repr(align(N))] #[repr(C, align(N))] Hidden size none vptr, bases none none Compile-time check _Static_assert static_assert const _: () = assert!(...) + offset_of! same Tools pahole, -Wpadded, -Wcast-align same + nm -C for vtables -Z print-type-sizes same Suitable for FFI/Flash yes standard-layout only no yes Golden rules for developers Sort fields from largest to smallest and verify the result with pahole -C \u0026lt;type\u0026gt; (or -Wpadded). Don\u0026rsquo;t count in your head. Pin down the size and offsets with _Static_assert / static_assert / const assert!. It\u0026rsquo;s insurance against surprises when the platform, compiler, or flags change. packed only for formats that sit in Flash. Not for MMIO, not \u0026ldquo;to make it fit\u0026rdquo; in every struct. If the format allows, add aligned(4) so you don\u0026rsquo;t pay with byte-by-byte reads for aligned fields. Serialize everything that goes to the network explicitly. packed cures neither endianness nor garbage in padding. Don\u0026rsquo;t take the address of a field of a packed struct, and don\u0026rsquo;t cast a byte buffer to uint32_t *. For reading from a buffer there are get_u32le-style functions or memcpy. Keep -Wcast-align and -Waddress-of-packed-member on, and on the host - -fsanitize=alignment. Remember the core. On M0/M0+ a dishonest unaligned ldr = HardFault. On M3/M4/M7 it\u0026rsquo;s silently slow (and for ldrd/ldm/vldr - a fault too), and it\u0026rsquo;s caught via UNALIGN_TRP together with -mno-unaligned-access. In Rust, everything that crosses the boundary of Rust code gets #[repr(C)]. Leave repr(Rust) for internal structs. ","date":"2026-10-21T20:12:12+03:00","image":"/post/struct-memory-layout/cover.png","permalink":"/post/struct-memory-layout/","title":"Memory layout: why sizeof(struct) ≠ the sum of its fields, and what HardFault has to do with it"},{"content":"So, I\u0026rsquo;ve decided to start a blog (again) Ages ago, back when things were simpler, I used to write a few technical posts on Blogger. It was a cool experience, and occasionally it\u0026rsquo;s still handy to take a trip down memory lane and revisit some old setups.\nThen I noticed @Schistos documenting their setups in a similar way, nostalgia hit me, and I decided to dive back in and see what\u0026rsquo;s currently trending. After exploring a few static site generators, Hugo really spoke to me - and that\u0026rsquo;s exactly what you\u0026rsquo;re looking at right now 😉\nHow does it work? 📝 Note Hugo is a static site generator. You write your content in Markdown files, and Hugo transforms them into ready-to-use HTML/CSS/JS pages during the build process. This ensures maximum loading speed and security, as there is no database or active backend running on the server.\nIt\u0026rsquo;s a perfect fit for a Git repository, and you can easily set up CI/CD to build and publish it to GitHub or GitLab Pages, which is exactly what we\u0026rsquo;re going to do.\nPicking a Theme First things first, head over to themes.gohugo.io and pick something you like. After spending several hours browsing every possible (and impossible) theme, I landed on Stack.\nThe theme\u0026rsquo;s GitHub README suggests using a repository template for a quick start. But hey, we\u0026rsquo;re not looking for the easy way out! I\u0026rsquo;ll be doing everything manually. Plus, I\u0026rsquo;m planning to host this on GitLab instead of GitHub, so I\u0026rsquo;ll need to tweak a few things anyway.\nInstalling Hugo Let\u0026rsquo;s get Hugo and Git installed:\n1 sudo pacman -Sy hugo git And a quick sanity check:\n1 2 hugo version hugo v0.165.0+extended+withdeploy linux/amd64 BuildDate=unknown Creating the Project 1 hugo new project blog.hardfault.win Hugo gives us a few hints on what to do next. Let\u0026rsquo;s see what we\u0026rsquo;ve got:\n1 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 cd blog.hardfault.win \u0026amp;\u0026amp; hugo server -D Watching for changes in archetypes, assets, content, data, i18n, layouts, static Watching for config changes in hugo.toml Start building sites … hugo v0.165.0+extended+withdeploy linux/amd64 BuildDate=unknown WARN found no layout file for \u0026#34;html\u0026#34; for kind \u0026#34;home\u0026#34;: You should create a template file which matches Hugo Layouts Lookup Rules for this combination. WARN found no layout file for \u0026#34;html\u0026#34; for kind \u0026#34;taxonomy\u0026#34;: You should create a template file which matches Hugo Layouts Lookup Rules for this combination. │ EN ──────────────────┼──── Pages │ 4 Paginator pages │ 0 Non-page files │ 0 Static files │ 0 Processed images │ 0 Aliases │ 0 Cleaned │ 0 Built in 2 ms Environment: \u0026#34;development\u0026#34; Serving pages from disk Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender Web Server is available at http://localhost:1313/ (bind address 127.0.0.1) Press Ctrl+C to stop We can see the directories Hugo is watching, but we also see some warnings about missing templates. Time to fix that.\nSetting Up the Theme Hugo uses Go modules for dependency management, so let\u0026rsquo;s turn our project into a Go module:\n1 2 3 4 hugo mod init blog.hardfault.win go: creating new go.mod: module blog.hardfault.win hugo: to add module requirements and sums: hugo mod tidy Here\u0026rsquo;s what the go.mod looks like:\n1 2 3 module blog.hardfault.win go 1.26.6 Next, let\u0026rsquo;s add our theme as a dependency:\n1 2 3 4 5 # ... [module] [[module.imports]] path = \u0026#34;github.com/CaiJimmy/hugo-theme-stack/v4\u0026#34; Restart the server:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 hugo server -D Watching for changes in archetypes, assets, content, data, i18n, layouts, static Watching for config changes in hugo.toml, go.mod Start building sites … hugo v0.165.0+extended+withdeploy linux/amd64 BuildDate=unknown │ EN ──────────────────┼──── Pages │ 8 Paginator pages │ 0 Non-page files │ 0 Static files │ 0 Processed images │ 0 Aliases │ 3 Cleaned │ 0 Built in 33 ms Environment: \u0026#34;development\u0026#34; Serving pages from disk Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender Web Server is available at http://localhost:1313/ (bind address 127.0.0.1) Press Ctrl+C to stop No more warnings! Let\u0026rsquo;s check it out in the browser:\nIt\u0026rsquo;s a bit bare for now, but it works. Let\u0026rsquo;s examine the current dependency graph:\n1 2 hugo mod graph blog.hardfault.win github.com/CaiJimmy/hugo-theme-stack/v4@v4.0.3 Customizing the Theme 💡 Tip To make it easier to browse the theme\u0026rsquo;s source code and apply tweaks (if needed), use the hugo mod vendor command. This downloads a local copy of the module into the _vendor directory, allowing you to search the theme code using your IDE.\n1 hugo mod vendor This creates a _vendor directory in our project:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 tree -d _vendor -L 5 _vendor └── github.com └── CaiJimmy └── hugo-theme-stack └── v4 ├── archetypes ├── assets ├── config ├── data ├── i18n └── layouts 11 directories Let\u0026rsquo;s copy the theme\u0026rsquo;s configs into our project:\n1 cp -r _vendor/github.com/CaiJimmy/hugo-theme-stack/v4/config . 1 2 3 4 5 6 7 tree config config └── _default ├── module.toml └── params.toml 2 directories, 2 files module.toml isn\u0026rsquo;t very exciting - just Hugo dependencies. But params.toml is where all the main theme settings live.\n📌 Important To keep things organized, it\u0026rsquo;s better to move the root hugo.toml into the config/_default/ directory and split it into thematic files.\nMove the file:\n1 mv hugo.toml config/_default/ And while we\u0026rsquo;re at it, let\u0026rsquo;s tidy things up: move the import from config.toml to module.toml, rename some files, and so on. Here\u0026rsquo;s the result:\n1 2 3 baseURL = \u0026#39;https://hardfault.win/\u0026#39; locale = \u0026#39;en\u0026#39; title = \u0026#39;HardFault.win\u0026#39; 1 2 3 4 5 6 [hugoVersion] extended = true min = \u0026#34;0.157.0\u0026#34; [imports] path = \u0026#34;github.com/CaiJimmy/hugo-theme-stack/v4\u0026#34; Now, let\u0026rsquo;s set up the sidebar menu:\n1 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 48 49 50 51 52 53 54 55 56 57 58 59 60 61 [[social]] identifier = \u0026#34;github\u0026#34; name = \u0026#34;GitHub\u0026#34; url = \u0026#34;https://github.com/elektro-NIK\u0026#34; [social.params] icon = \u0026#34;brand-github\u0026#34; [[social]] identifier = \u0026#34;gitlab\u0026#34; name = \u0026#34;GitLab\u0026#34; url = \u0026#34;https://gitlab.com/elektro.NIK\u0026#34; [social.params] icon = \u0026#34;brand-gitlab\u0026#34; [[social]] identifier = \u0026#34;linkedin\u0026#34; name = \u0026#34;LinkedIn\u0026#34; url = \u0026#34;https://www.linkedin.com/in/elektro-nik\u0026#34; [social.params] icon = \u0026#34;brand-linkedin\u0026#34; [[main]] identifier = \u0026#34;home\u0026#34; name = \u0026#34;Home\u0026#34; url = \u0026#34;/\u0026#34; weight = 10 [main.params] icon = \u0026#34;home\u0026#34; [[main]] identifier = \u0026#34;about\u0026#34; name = \u0026#34;About\u0026#34; url = \u0026#34;/about/\u0026#34; weight = 20 [main.params] icon = \u0026#34;user\u0026#34; [[main]] identifier = \u0026#34;archives\u0026#34; name = \u0026#34;Archives\u0026#34; url = \u0026#34;/archives/\u0026#34; weight = 30 [main.params] icon = \u0026#34;archives\u0026#34; [[main]] identifier = \u0026#34;search\u0026#34; name = \u0026#34;Search\u0026#34; url = \u0026#34;/search/\u0026#34; weight = 40 [main.params] icon = \u0026#34;search\u0026#34; [[main]] identifier = \u0026#34;links\u0026#34; name = \u0026#34;Links\u0026#34; url = \u0026#34;/links/\u0026#34; weight = 50 [main.params] icon = \u0026#34;link\u0026#34; And let\u0026rsquo;s add the icons that weren\u0026rsquo;t included in the theme:\n1 2 3 4 5 6 tree assets/icons assets/icons ├── brand-gitlab.svg └── brand-linkedin.svg 1 directory, 2 files Currently, all links lead to 404 pages, but it\u0026rsquo;s a start! Let\u0026rsquo;s add the pages so we can see how they work:\n1 hugo new content content/page/about/index.md 1 hugo new content content/page/archives/index.md 1 hugo new content content/page/links/index.md 1 hugo new content content/page/search/index.md Edit the files:\n1 2 3 4 5 +++ date = \u0026#39;2026-01-01T00:00:00+00:00\u0026#39; draft = false title = \u0026#39;About\u0026#39; +++ 1 2 3 4 5 6 +++ date = \u0026#39;2026-01-01T00:00:00+00:00\u0026#39; draft = false title = \u0026#39;Archives\u0026#39; layout = \u0026#34;archives\u0026#34; +++ 1 2 3 4 5 +++ date = \u0026#39;2026-01-01T00:00:00+00:00\u0026#39; draft = false title = \u0026#39;Links\u0026#39; +++ 1 2 3 4 5 6 7 8 +++ date = \u0026#39;2026-01-01T00:00:00+00:00\u0026#39; draft = false title = \u0026#39;Search\u0026#39; type = \u0026#34;page\u0026#34; layout = \u0026#34;search\u0026#34; outputs = [\u0026#34;html\u0026#34;, \u0026#34;json\u0026#34;] +++ Finally, let\u0026rsquo;s add an avatar and a favicon:\n1 2 3 4 5 6 tree assets/img assets/img ├── avatar.png └── favicon.png 1 directory, 2 files 1 2 3 4 5 6 7 8 9 10 11 12 13 # ... favicon = \u0026#34;img/favicon.png\u0026#34; # ... [sidebar] compact = false avatar = \u0026#34;img/avatar.png\u0026#34; emoji = \u0026#34;🦀\u0026#34; subtitle = \u0026#34;Cortex-M panics, but we don\u0026#39;t\u0026#34; # ... And let\u0026rsquo;s create a test post:\n1 hugo new content content/post/hello_world/index.md 1 2 3 4 5 6 7 ## Hello World ### Test text And there you have it! The site is now usable. In the next part, we\u0026rsquo;ll tackle adding multi-language support.\nConfiguration Cheatsheet File Purpose hugo.toml Main site settings (baseURL, title, locale) params.toml Stack theme settings (Sidebar, Footer, Cookies, etc.) module.toml Hugo version management and theme import as a module menu.toml Navigation and social links for the sidebar languages.toml Multi-language settings (if used) ","date":"2026-08-27T22:24:40+03:00","image":"/post/hugo-1/Screenshot3.png","permalink":"/post/hugo-1/","title":"Getting Hugo Up and Running"}]