I don't like quoting the C standards in general, but here, consider these said while nodding yes to the above posts; I'll explain why, further below, after the horizontal line.
I believe it's illegal to cast a char array to any other type and access it as such, although I have surely done it many times before I knew better and it worked - typically problems only occur when concurrent accesses are made through the two incompatible pointers.
Well,
unsigned char is the special type: it allows access to the storage representation of other types:
Values stored in non-bit-field objects of any other object type consist of n×CHAR_BIT bits, where n is the size of an object of that type, in bytes. The value may be copied into an object of type unsigned char [n] (e.g., by memcpy); the resulting set of bytes is called the object representation of the value.
C99 and later has three pointer qualifiers:
const,
volatile, and
restrict.
const is a promise that the code itself will not try to modify the value.
volatile tells the compiler that the value may be changed by external code or causes at any point during execution.
restrict is an aliasing-related promise: that the pointed to object will only be referenced directly or indirectly via this particular pointer only; that any access to the pointed to object will depend on the value of this pointer. (An entire chapter, 6.7.3.1 in C99, is dedicated for the
formal definition of
restrict, though.)
Type punning via an union was described in ISO C99 as a footnote (6.5.2.3 Structure and union members, footnote 82):
If the member used to access the contents of a union object is not the same as the member last used to store a value in the object, the appropriate part of the object representation of the value is reinterpreted as an object representation in the new type as described in 6.2.6 (a process sometimes called "type punning"). This might be a trap representation.
The
common initial sequence was described in ISO C99 6.5.2.3p5:
if a union contains several structures that share a common initial sequence (see below), and if the union object currently contains one of these structures, it is permitted to inspect the common initial part of any of them anywhere that a declaration of the complete type of the union is visible. Two structures share a common initial sequence if corresponding members have compatible types (and, for bit-fields, the same widths) for a sequence of one or more initial members.
In a very real sense, the
"default C" language has become more abstracted and
"further away from the hardware" in a sense, in succeeding ISO C standards. However, in my opinion, ISO C99 also added the tools to drill straight through those abstractions: type punning, exact-width two's complement standard types
intN_t and
uintN_t, minimum/fast two's complement types
int_fastN_t and
uint_fastN_t,
size_t,
intmax_t and
uintmax_t,
intptr_t and
uintptr_t, and so on.
The main point about ISO C99 was that it did not state anything new, only documented existing agreements and behaviour of C compilers that their users had found useful/necessary. You could say that the increased abstraction was necessary to allow better optimization schemes to evolve, while the added features were necessary for the low-level programmers (mostly kernel and library programmers) to keep performance and portability across a large diverse set of architectures. (At this point, computer architectures were even more diverse than now.)
Then came the odd misstep that is ISO C11. It was mostly a push by Microsoft to allow their C++ compiler to compile ISO C also (they still refuse to support ISO C99, though); and the infamous Annex K that is likely to be removed from the next ISO C standard, defining their "safe I/O functions". It's main impact was aligning the atomic memory model semantics with C++, plus the
_Generic macro facility allowing type-dependent polymorphic functions via a preprocessor macro –– that e.g.
func(X) resolves to say
func_int(X) if
X is of type
int,
func_d(X) if
X is of type
double, and so on.
(Some disagree vehemently with this characterization, but I say the existence of Annex K is proof enough. There is also the entire OOXML debacle in the same timeframe (first decade of this century), which in my opinion illustrates the approach MS then had with "standardization": weapon, rather than collaboration.)
ISO C17 was basically a stationary point. Not only was this around the time Microsoft changed its approach to open source and to standardization in a lesser degree, but C17 added very little anything new.
If we look at what is to become ISO/IEC 9899:2024 (
Wikipedia), it looks like the standard development is switching back to the practice-driven way C99 was developed, by incorporating features and facilities already provided by various C compilers that have been found useful (and sometimes necessary). Sure, the new bit operations in
<stdbit.h> have new names, like
stdc_count_ones() instead of
popcount(), but we can deal with those as things settle. (I also haven't checked how the new things stand with respect to freestanding vs. hosted implementations –– i.e., their impact on embedded development ––, but I'm expecting it is sane/positive.)
In my opinion, all of the above means that those who want to write efficient low-level code in C, need to keep track with the ISO C standards, but even moreso with the features and facilities their toolchains provide; especially with
binutils,
gcc, and
clang. The original language as described by K&R has drifted
a lot since then, away from its low-level simple origins; but the same tasks and performance (but with even better portability!) can still be achieved by using the
new language features.
In particular, in embedded development, I very much rely on ELF object file format features exposed by the compiler and linker: the
__attribute__((section (foo)) shenanigans. Even in systems programming,
<dlfcn.h> is indispensable for me for run-time extensions (plug-ins and such).
Is it worth it, chasing a moving target like this, instead of just staying with good ol' K&R C?Well, I remember the time in the nineties when it was
easy to exceed the performance of C compiler-generated assembly (by gcc, icc, pathscale, portland group) by rewriting it by hand. Nowadays, SIMD vectorization and possibly avoiding one or two unnecessary register moves at the beginning of a function is about it: the optimization has progressed by leaps and bounds. To me, the changes are worth it: I do eagerly expect switching to C23/C24 as soon as it becomes practical. And I do write a lot of C, both freestanding (microcontroller/embedded) and hosted (especially combining with POSIX C) systems stuff.