10 ms·
Unaligned accesses in C/C++: what, why and solutions to do it properly
- JonChesterfield 3y agoI expected this to be an aliasing violation and was not disappointed. Dereferencing a uint8_t through a uint64_t is undefined behaviour whatever the target architecture is. That it works for the author on x64 just means the compiler didn't break it this time around. In particular, an 8 byte aligned uint8_t also can't be dereferenced via a uint64_t. It isn't UB because the alignment is wrong, it's UB by definition and also the alignment might be wrong. This is a woeful state of affairs propagated by an ill founded and widespread confidence that the C and C++ standards probably aren't as hostile to type aliasing as they actually are.
- gpderetta 3y agoIf uint8_t is a char type (and invariably it is), then it is fully defined. Similarly, it is perfectly safe to cast a correctly aligned uint8_t* to uint64_t* and dereference it as long as you originally stored an uint64_t into it (or the compiler can't prove you didn't). Remember aliasing UB is always on derefencing with the wrong dynamic type, not on casting pointers.
- Chabsff 3y agoYou are correct in principle, but the code in the original post definitely exhibits strict aliasing violations. Not only that, it didn't need to.
- gpderetta 3y agoThe original code computing the hash by casting uint8_t* to uint64_t* for wide leads is generally wrong, but the memcpy one is fine.
- tom_ 3y agoI'm surprised the gcc and clang people have never noticed that uint8_t is an integer type, and therefore does not need to be a character type.
- gpderetta 3y agoWell, if we were starting from scratch {u,}int8_t should definitely not be character type; instead we would have byte_t (plus an explicitly sized variant, octect_t?) as the general universally aliasable byte type, and all of these would be guaranteed to be distinct types. But today, uint8_t is in practice always a char type. You can static_assert if you want to be completely sure. Or just use std::byte.
- JonChesterfield 3y agoThere's an old gcc bug report about whether uint8_t should have the magic aliasing properties of char or not. I don't remember what they concluded. You can cast any pointer you like to any other pointer. When you dereference it, if the declared type is not the type of the underlying object, bad times for you. An interface which takes a uint8_t* and immediately invokes UB if you pass it anything other than a pointer to a uint64_t can indeed be written, but one should expect people to pass it things other than a cast uint64_t*. Further, the magic aliasing char doesn't work in both directions. If this code was changed to take a char instead of a uint8_t it would be exactly as undefined as it is currently. You can aim a char* into a uint64_t and deref the char, but you can't aim a uint64_t* into a char and deref the uint64_t.*
- quietbritishjim 3y agoThey ended up making uint8_t a typedef for unsigned char rather than a distinct type (which is permitted but not required by the standard) so the semantics are now clear. Edit: That's what I remember but I can't find evidence of it. The docs say this is ABI dependent so perhaps the above statement is true on some specific ABI (if I haven't misremembered it entirely). But I can't track down wheere a relevant ABI would be documented. https://gcc.gnu.org/onlinedocs/gcc-13.2.0/gcc/Architecture-implementation.html https://gcc.gnu.org/onlinedocs/gcc-13.2.0/gcc/Architecture-i... Edit 2: this header has a typedef of uint8_t to unsigned char, but it's part of glibc rather than gcc https://android.googlesource.com/platform/prebuilts/gcc/linux-x86/host/i686-linux-glibc2.7-4.6/+/refs/heads/tools_r20/sysroot/usr/include/stdint.h https://android.googlesource.com/platform/prebuilts/gcc/linu...
- kzrdude 3y agoI thought if uint8_t exists, it logically must be the same as unsigned char? You put some of the statements of the standards together and that's their combined consequence.
- quietbritishjim 3y agoNo it doesn't have to be, and it wasn't. That was the whole problem. They were both unsigned integer types with 8 bit widths but they were distinct and, in particular, uint8_t didn't have the aliasing exception that unsigned char does.
- leni536 3y agoNo, the relationship is directional. You can reinterpret a pointer to an integral type to a pointer to unsigned char and read the underlying bytes. The other way around is UB.
- gpderetta 3y agoWhat I mean is that this is perfectly valid code: uint64_t x=0; uint8_t * p = (uint8_t*)&x; uint64_t y = *(uint64_t*)p; It is not the casting that is the problem, it is the dereferencing. And as long as that matches the dynamic type, it is ok.
- AnimalMuppet 3y agoWell, yes, if you're playing this kind of game, the compiler cannot type-check what you're doing (or sanity-check it in any other way). You're outside the area covered by the standard. You're coding without a net. But C and C++ are explicitly intended for this kind of situation. You know enough about how types are represented in memory to fiddle with their internals, and you have sufficient reason to do so? Go for it. Just don't expect the type checking to save you, because it won't.
- JonChesterfield 3y agoI'd be totally happy with C or C++ here if the attitude to type checking was "you've done weird things so you're on your own, be careful". Things like mutating a vtable could be fair game, just do it carefully. However that's not the setup. C++ in particular, and increasingly C copying from it, optimises assuming you never do anything weird with pointers. This makes application code that doesn't do such things faster in a separate compilation world. It makes system code that does do such things undefined, forcing either compiler flags (fno-strict-aliasing and friends, aka writing in a different language that looks like the nominal one) or taking care around separate compilation to make sure the compiler never sees both sides of the boundary at the same time.
- trealira 3y agoIf I had the skill, I would want to create my own C compiler that tried not to optimize things like this, e.g. don't optimize away infinite loops, don't optimize away signed overflow checks or do incorrect algebraic simplifications because signed overflow is undefined behavior, and by default act as though -fno-strict-aliasing were on. Add options for trapping on overflow or optimizing away overflow checks, signed or unsigned. Just do register allocation and minor constant folding (but again, don't act like signed overflow doesn't exist, and don't do constant folding across pointer writes if its address has been taken). There exist open source C compilers that aren't GCC or Clang, such as TCC and Zig, but to my knowledge they all produce really, really bad assembly. There existed proprietary C compilers in the 1980s and 1990s that were written by one or two people that still produced solid assembly, so it's possible, just no one has done it, so Clang and GCC are the only free and open source options for decent assembly for C. Those two also have the downside of being C++ compilers, making them more complex than they need to be. Maybe another option would be to improve an already existing open source C compiler's codegen than to write a new one from scratch again, assuming they'd agree with the above ideals.
- pkkm 3y ago> This is a woeful state of affairs propagated by an ill founded and widespread confidence that the C and C++ standards probably aren't as hostile to type aliasing as they actually are. Yep. Honestly, while I get the historical reasons for strict aliasing, I wish it hadn't been created. Just make -fno-strict-aliasing the default and normalize the abundant use of restrict, maybe shorten it to res or rest just like we use const instead of typing out constant. These implicit rules that usually, but not always, do what you want can be a bigger footgun than having to do things explicitly.
- tialaramex 3y ago> just like we use const instead of typing out constant. Those aren't constants†, they're immutable variables which are quite different. Because of the "as if" rule and provenance in some cases you get the same benefit as with a real constant, but that's not what this is. † In C or C++. In some other languages const does, logically, get you an actual constant.
- pkkm 3y agoUnless you think that the abbreviation const stands for some other word than constant, I'm not sure how that's relevant here?
- tialaramex 3y agoBecause these aren't actually constants it would actually be even worse if the keyword was "constant" rather than "const". Jargon that's identical to ordinary words but is just plain wrong if taken that way makes it harder to be understood. In a language where the const keyword gets you an actual constant this condition wouldn't arise.
- wahern 3y agoHow historical is the importance of strict aliasing? AFAIU, strict aliasing survives not because of alignment issues when type punning, but because optimizing compilers heavily rely on non-aliasing to guarantee semantic correctness when reordering code, especially in and around loops. And the performance benefit of reordering has grown exponentially in lock-step with the emergence and increasing sophistication of superscalar architectures, pipelining, and cache hierarchies. Strict aliasing, IOW, is as a practical matter mostly about when and to what extent compilers (and sometimes processors) can optimize code through automatic reordering, much like wrt parallelism and memory barriers. In this sense, the role of strict aliasing is more important than it ever has been, and while technically "merely" a matter of performance (correctness could be obtained by completely abstaining from reordering), the performance of modern processors plummets precipitously when implicit hardware parallelism can't be fully leveraged.
- xscott 3y agoI think we're getting to the point where C++ users should define two classes: class Integer { // uses unsigned integers for all operations // but converts to signed when you need output }; class Pointer { // uses void* and memcpy for all loads or stores }; Put all of the correct operator overloading on those to get the ergonomics we had in the 90s. Then we can safely ignore all the undefined behavior which has been so eagerly embraced by the compiler writers, and we can go back to treating the hardware as though it is sane. (Which the hardware is desperately trying to fake being anyways) After that, we can create some sane container types which don't offer up dangling references or pointers, and Bob's your uncle.
- celrod 3y agoIf my signed integers overflow, that is wrong. I want to be able to detect if it ever happens, not have that be defined as perfectly valid wrapping behavior. If it's undefined, it's perfectly legal for the compiler to make overflow trap, so I can catch it, or confirm it never happens when running tests our debugging. `-ftrapv` or ``-fsanitize=undefined` do this.
- xscott 3y agoTo each their own. I want my 64 bit integers to do what the CPU does, and I could bore you with useful examples where two's complement wrap around is exactly the right behavior. But more importantly, I don't ever want to worry about stumbling into undefined behavior, and compiler extensions like -ftrapv or -fwrapv are not part of the standard. I can't count on those being there when I switch compilers, and if I write a library for others, I can't count on the application developer using the flags I need.
- AnimalMuppet 3y agoSomething something Duff's Device...
- tralarpa 3y agoWith gcc, the option -Wcast-align=strict is useful. Edit: And if the buffer to convert is very large, one could first check the alignment of the pointer (with alignof(T)) in C++
- derf_ 3y agoYou run into these problems on x86, also, with SIMD intrinsics like _mm_cvtepi8_epi32() (which converts four 8-bit integers to 32-bit integers with sign extension). The underlying instruction, PMOVSXBD, when used with a (4-byte) memory operand, imposes no alignment restrictions. But the intrinsic takes an __m128i value (not a pointer), and __m128i (the C type) requires 16-byte alignment. If you try casting your pointer to (__m128i *) and dereferencing, sometimes the compiler will optimize it correctly (to PMOVSXBD with a memory operand, which really only loads 4 bytes), and sometimes (for example, when optimizations are disabled) it will emit MOVDQA, which does a 16-byte aligned load. Tools like UBSan will also (correctly) complain about it. Eventually __mm_loadu_epi32() was added, but it was released in a broken state in the initial gcc implementation: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=99754 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=99754 so you can't rely on it. There is __mm_load_ss(), but gcc's implementation derefs a (float *), so it still requires 4-byte alignment: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=84508 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=84508 The workarounds described in the article (combined with _mm_cvtsi32_si128()) are the only reliable solution I have found. Various compilers still generate an extra MOVD instruction instead of using a memory operand, and reportedly ICC will even do an extra load into a general-purpose register before moving the value to a SIMD register: https://stackoverflow.com/questions/72837929/mm-loadu-si32-not-recognized-by-gcc-on-ubuntu https://stackoverflow.com/questions/72837929/mm-loadu-si32-n... Those extra moves are cheap on x86, so it is not the end of the world, but the whole situation is less than ideal.
- CoastalCoder 3y agoReally interesting, thanks! I'm a little surprised about the icc thing, since it has/had a reputation for really good Intel x86 codegen.
- rwmj 3y agoParticularly insidious if you don't stick to the 16 byte stack alignment. We had one case where we calling from assembly code back to C, but the asm didn't honour the C stack alignment. The C code was trying to access some vector variable on the stack and crashed because the compiler generates code assuming %rsp is 16 byte aligned when you enter the function. The stack was only unaligned some of the time and the crash happened very distant from the wrapper, making it a pain to track down. (Asm wrapper has since been fixed).
- amluto 3y agoThe “multiple loads” solution is IMO correct. It does precisely what it looks like it does, it doesn’t rely on any assumptions about how integers are represented, and, IMO best of all, it completely gets rid of the “native endian” crap that has plagued C programs for decades. (And you computer can convert endianness in a cycle or two. There is very little excuse for optimizing based on “native endian”.) edit: about the only good thing I can say about “native endian” is that it maybe justified in cases where the value never leaves the machine. Hash tables in memory come to mind, and the article seems to be about that.
- Ono-Sendai 3y agoHave been dealing with this issue recently. memcpy was quite slow in my tests however, it was ~twice as fast to check the alignment and load as floats (was dealing with floats in my case).
- pengaru 3y agoThere have been several memcpy performance regressions affecting modern CPUs in recent years, might want to investigate if that's part of what you were observing.
- wyldfire 3y ago"-fsanitize=undefined" helps uncover this kind of UB and more. While you're there, pile on with ASan: "-fsanitize=address,undefined".
- mlochbaum 3y agoA solution used in the Linux kernel[0] is to use a struct with the packed attribute, which implies the fields aren't necessarily aligned. Simplifying, I think this macro should work the same in gcc and similar: apply to any pointer to get something that behaves as *p but will read or write as unaligned. #define unaligned(P) (((struct { typeof(*(P)) x; } __attribute__((packed))*)(P))->x) [0] https://github.com/torvalds/linux/blob/7b9e664beb237d90bc600f117668227af5ce53ae/include/asm-generic/unaligned.h#L12-L15 https://github.com/torvalds/linux/blob/7b9e664beb237d90bc600...
- JonChesterfield 3y agoUses of pointers to members of packed structs are prone to being miscompiled by GCC so I suspect that only works in combination with the various other not-actually-C flags the kernel has to pass around.
- knorker 3y agoThe right way to cast stuff in memory since C++20 is std::bit_cast, right? https://en.cppreference.com/w/cpp/numeric/bit_cast https://en.cppreference.com/w/cpp/numeric/bit_cast
- Night_Thastus 3y ago>and optimizes it correclty, as alignment rules are still valid I can't tell if this is a joke or not, haha.
- dataflow 3y agoQ: How do you do atomic unaligned accesses from C++? (and I'm sure someone will say "align your data". But that's not always possible.) Edit: I mean on x86, where it's possible. I couldn't even find any intrinsics for the corresponding lock instructions. And yes I'm aware of the performance penalty.
- cjensen 3y agoPortably? You don't. That's a CPU limitation for some architectures.
- cesarb 3y agoAFAIK, in general, it's not possible; it's a hardware limitation of the CPU. Some architectures like the x86 do allow unaligned atomic accesses, but in a very heavy handed way (doing an unaligned atomic access on the x86 locks the whole bus, stalling all processor cores at the same time). The Linux kernel recently gained the ability to detect this situation and, depending on configuration by the administrator, it could either log a kernel warning or kill the offending process. Some discussion about this on LWN found on a quick web search: https://lwn.net/Articles/790464/ https://lwn.net/Articles/790464/ https://lwn.net/Articles/806466/ https://lwn.net/Articles/806466/ https://lwn.net/Articles/911219/ https://lwn.net/Articles/911219/
- deleted 3y ago[deleted]
- JonChesterfield 3y agoC++ cannot represent an atomic type with less than natural alignment, and you cannot access that which does not exist, so that's the end of your road. Consider a language with lower intrinsic overhead. Usually the answer is to ignore the atomic abstractions of C++ and use the GCC style atomic intrinsics (they take a void* instead of some atomic qualified thing) but I'm not totally confident they'll do the right thing if the target memory has less than natural alignment. Beware crossing cache lines with your operation. I'm not sure what the x64 instructions do in such a case.
- epx 3y agoHad a problem like that while porting an IEEE 11073 parser library to Emscriptem. Funnily enough, the code worked happily in x86 and ARM environments, but failed in Emscriptem.
- 12345ieee 3y agoAs other comments say, this is missing packed unions, which is the best solution I've found while writing a memory editor. See: https://github.com/scanmem/scanmem/blob/c6045a8677f37a51b9760132b39fdfd265b1196a/value.h#L95-L117 https://github.com/scanmem/scanmem/blob/c6045a8677f37a51b976...
- kaycebasques 3y agoI'm in the process of updating the docs for Pigweed's pw_alignment lib. Would that lib help here or are we talking about a different problem? https://pigweed.dev/pw_alignment/ https://pigweed.dev/pw_alignment/ (I'm not that familiar with this problem so I figured I would take this opportunity on a seemingly related problem to get more perspective.)
- el_pollo_diablo 3y agoHere is another solution for loading a potentially unaligned 64-bit integer in the CPU's byte order, that does not violate strict aliasing: uint64_t load64(uint8_t const *b) { union { uint64_t u64; uint8_t u8[8]; } u = {.u8 = { b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7] }}; return u.u64; } Type punning through a union is explicitly supported by the C standard, insofar as we do not evaluate uninitialized memory or trap representations. We are fine here, as uint64_t has no trap representations, and must have exactly the size of 8 uint8_ts, as those types have no padding bits. GCC appears to compile this function to a plain unaligned load at optimization level 2, when supported, e.g. on arm64 (see https://godbolt.org/z/frEox67sG https://godbolt.org/z/frEox67sG).
- el_pollo_diablo 3y agoFurthermore, one advantage of this solution over using memcpy is that the compiler can still understand what it does (and aggressively optimize it) in a freestanding environment.