Welcome to Software Development on Codidact!
Will you help us build our independent community of developers helping developers? We're small and trying to grow. We welcome questions about all aspects of software development, from design to code to QA and more. Got questions? Got answers? Got code you'd like someone to review? Please join us.
Can you ever assume that casting pointers is safe?
This was inspired by the SO question Can you ever assume typecasting pointers is safe? I started to write an answer there, but I realized I'd rather post it here instead, so here's a self-answered Q&A.
Can you ever assume that casting pointers is safe?
Casting implies the use of the ( ) cast operator. As soon as it is used, C seems to allow all manner of wild and crazy casts and they pass compilation, but are those actually fine? How to know which ones that are and which ones that aren't?
Even when restricting oneself only to casts involving pointers, there are surely a lot of different scenarios here. As seen in the linked question, it is suggested that the size of the pointer and data types matter, which is rather obvious. Alignment might be less obvious, and on top of that C comes with a bunch of specialized rules such as exceptions for character type pointers, the "strict pointer aliasing" rules, function pointers and so on.
What is safe, what is unsafe? Is there an exhaustive list of all safe/dangerous scenarios? What does the standard specify and what does it leave as "poorly-defined behavior" such as undefined behavior?
1 answer
You can assume that a cast is safe if, and only if, you do know all the various language rules at play.
Unfortunately there is no shortcuts or a general simple rule here. You simply need to know about all the various types of conversions there are, or otherwise you can assume that any cast that you do is unsafe.
My general advise for beginners to intermediately skilled C programmers is therefore: never use the cast operator. It is reserved for experts only and believe me when I say that I'm not overly pedantic here.
If looking at pointer casts specifically, the first thing one must learn is the difference between conversion and dereferencing. A conversion is the act of changing from one type to another. In this case from one pointer type to another, or from an arithmetic type to a pointer etc.
Dereferencing, in this context, is the act of actually using the new pointer type obtained through a pointer conversion.
C is surprisingly tolerant towards all manner pointer conversions - there are not a lot of things that can go wrong in the conversion itself. (With some exceptions, as we will notice further down.) It's when you start using the new pointer types by dereferencing them, that all manner of pitfalls might open up.
Lets start by looking at what types of conversions involving pointers there are.
Conversions
C has two manner of conversions: implicit or explicit. Implicit are things that go on between the lines, explicit conversions is when the programmer asks for it openly, normally by using a cast. (Another less common explicit conversion would be type punning.)
A common beginner mistake is to speak of "implicit vs explicit casts", there is no such thing. What they actually mean is conversions. A cast is always an explicit conversion.
Furthermore, almost all pointer conversions need to be explicit by means of a cast. We can start there, at the (C23 6.5.5) standard definition of the cast operator:
Conversions that involve pointers, other than where permitted by the constraints of 6.5.17.2, shall be specified by means of an explicit cast.
So when dealing with pointers, we always have to cast. Save for the exceptions pointed out in 6.5.17.2, which are the rules of assignment. The rules of assignment is a list with a number of cases when we do not need to cast, in order for an assignment to be valid and a conversion to happen implicitly. For example while assigning a null pointer constant such as NULL to a pointer.
Otherwise, the specific rules about conversions are covered by the chapter C23 6.3.3.3, where we can read various rules:
void pointer conversions
A pointer to
voidcan be converted to or from a pointer to any object type. A pointer to any object type can be converted to a pointer tovoidand back again; the result shall compare equal to the original pointer.
So as far as void pointers go, we can tell that any conversion goes, pretty much. In the previously mentioned rules of assignment, we would also note that void pointers can even get assigned to/from other pointers without a cast and we still get a pointer conversion. But please note that the standard says object pointers, as opposed to function pointers. We cannot mix void* and function pointers because that is not well-defined by the standard.
Null pointer conversions
C is also quite lenient towards null pointer constants. (I'll cite C17 6.3.2.3 here not to confuse the reader with C23's nullptr_t):
An integer constant expression with the value 0, or such an expression cast to type
void *, is called a null pointer constant. If a null pointer constant is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function.
This too is an allowed exception in the rules of assignment where we need no cast to trigger the conversion.
Integer-to-pointer/pointer-to-integer conversions
Then there are rules for going between pointers and integers:
An integer may be converted to any pointer type. Except as previously specified, the result is implementation-defined, might not be correctly aligned, might not point to an entity of the referenced type, and might be a trap representation.
Any pointer type may be converted to an integer type. Except as previously specified, the result is implementation-defined. If the result cannot be represented in the integer type, the behavior is undefined. The result need not be in the range of values of any integer type.
Here we start to note a bunch of cases where the conversion itself might fail - these manner of conversions are clearly dangerous. And it's quite easy to imagine - consider something like (int*)0x4321 on a system with 32 bit alignment. The address is clearly not on a 32 bit = 4 byte evenly aligned address.
So here is one of the cases where the conversion itself might fail - for example suppose that the given CPU has an instruction trap if you ever load a misaligned address into an index register reserved for pointers. There are a couple of real-world computers that will do just that.
Another obvious problem is when the integer type picked and the address bus used by the system are of different sizes - there will simply not be enough room to store the result.
And looking at (int*)0x4321 alone with no context, we have no idea what's actually stored at that address, if anything. There are also concerns beyond the C standard here such as the address space is virtual and we try to do something at an address where we aren't supposed to be messing around: that could result in OS signals being raised or even hardware exceptions.
Also notably, integer to/from pointer conversions is not one of the exceptions to assignment that we are allowed to do without a cast, which is a common misconception. See "Pointer from integer/integer from pointer without a cast" issues
Pointer-to-pointer conversions
Specifically object pointer to object pointer conversions, is when we convert between two different pointer types and they aren't function pointers:
A pointer to an object type may be converted to a pointer to a different object type. If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined. Otherwise, when converted back again, the result shall compare equal to the original pointer. When a pointer to an object is converted to a pointer to a character type, the result points to the lowest addressed byte of the object. Successive increments of the result, up to the size of the object, yield pointers to the remaining bytes of the object.
The same concerns about alignment are here too - the conversion itself might fail in that case. Between the lines we also have the exotic case where two pointers need not necessarily have the same size and representation, C doesn't really make any guarantees there.
We note a special rule here too: whenever we convert any pointer into a pointer to character type (char*, signed char* or unsigned char*), we are allowed to use that type to inspect each byte of the pointed-at object, as if it was an array of characters. This is used for hardware-related programming, object serialization etc. Alignment is not a concern when we use character types, since they by definition always point at 1 byte and have no alignment (or 1 byte alignment if you will).
But notably there is no special exception for doing the opposite: going from a character type to a pointer to a larger type.
Function pointer conversions
Function pointers are a bit of a special snowflake. Sometimes we can convert to/from them, sometimes we can't. An observant reader of the quoted standard texts might note that the standard sometimes explicitly says object pointer, sometimes it just says pointer. As previously noted, we can't go from void pointers to/from function pointers, but as we can see from the previously quoted texts there is nothing stopping us from converting from a null pointer constant to a function pointer, or converting to/from integers to function pointers.
A pointer to a function of one type may be converted to a pointer to a function of another type and back again; the result shall compare equal to the original pointer. If a converted pointer is used to call a function whose type is not compatible with the referenced type, the behavior is undefined.
Here we can note that the conversion part itself is just fine. But if we use an incorrect function pointer type when calling the actual function, all bets are off. And since there is no generic function pointer like void*, that means that the only function pointer conversion followed by dereferencing/calling the function which is safe, is the case when we use two compatible function pointers. And two function pointers are only compatible if they match parameters and return types exactly (with some historic nowadays obsolete exceptions for "functions taking any parameter").
Dereferencing
Okay so we did a cast and the compiler didn't complain! Ship it? Not quite...
Misalignment might still be a problem
We have already picked up that misalignment is a notable danger which could already trigger an error upon the pointer conversion itself. But just because it didn't doesn't mean that misaligned access is fine on the given system - more likely the problem will happen when you dereference the pointer and try to access what's there, but do so with misaligned type. Some systems support this (most notably most 8/16 bit CPUs) but a whole lot of systems do not.
Pointer/data size and format mismatches
I also already mentioned the scenario where the data type and the pointer type are simply of different sizes, which may compile, but lead to bogus execution.
Is the address even valid to use as we intend to?
It was also mentioned that a lot of systems are guarded by a "Memory Mapping Unit" (MMU) which is hardware support to divide memory into different areas. This is highly CPU-specific. Some systems may prevent physical address accesses. Others may separate executable memory and data memory. Or we could simply be pointing at memory where nothing sensible is stored. All of these scenarios are obviously problematic.
Strict pointer aliasing
Additionally, there is the peculiar "strict pointer aliasing rules" which I explained in detail here: How does the strict aliasing rule enable or prevent compiler optimizations? The first part of the answer gives some explanations of why C is so lenient against pointer conversions - namely to allow type punning and generic programming, the presence of the void* and so on.
In case of hardware-related programming or in case we obtained a chunk of heap allocated memory, there will be cases where a fixed address in memory have no "effective type" and then we may ignore strict aliasing at least until we start using that memory. So if we know what's stored at a fixed address we may set a pointer there and access it. Within reason - there are rules for what we may do with pointers too.
Pointer arithmetic is restricted
Previously I mentioned the case where we may expect any object in C byte by byte using a character pointer. That rule assumes that we treat that object like a character array of exactly sizeof(the_object) bytes. Because in C we are never allowed to do pointer arithmetic beyond the end of an allocated array. Tough luck - specially in embedded systems.
Pointer arithmetic as well as array indexing both boil down to the additive + operator: ptr++ boils down to ptr = ptr + 1, and ptr[i] boils down to *(ptr + i). We can read about the + operator when applied to pointers in a long and complex section of the standard 6.5.6. I will quote parts of it:
For the purposes of these operators, a pointer to an object that is not an element of an array behaves the same as a pointer to the first element of an array of length one with the type of the object as its element type.
That is, if we have int x; then for the purpose of pointer arithmetic that one is equivalent to int x[1];.
Then if we do something like ptr + n where ptr is the pointer operand and we end up with a result:
If both the pointer operand and the result point to elements of the same array object, or one past the last element of the array object, the evaluation shall not produce an overflow; otherwise, the behavior is undefined. If the result points one past the last element of the array object, it shall not be used as the operand of a unary * operator that is evaluated.
So if we convert something to a pointer then commence to use any form of pointer arithmetic or indexing, the underlying type there must be an array. Curiously though, if the compiler doesn't actually know what's stored at that address, we probably get away with it, though that's obviously beyond the scope of the C standard. The problem is when the compiler does know what's stored there and we try to access it differently.
For example, a lot of programmers think that C allows you do something like this:
// BAD!
short arr[n];
int* ptr = (int*)arr;
ptr[4] = something;
The conversion part is fine, we already noted that converting between two object pointer types like short* and int* in itself is probably just fine. Unless there is an alignment requirement for int, then the conversion fail. Or otherwise we can fail when dereferencing the array using a type with different alignment, in case short is aligned at 2 bytes boundaries but int requires 4.
Furthermore, we are attempting to access something with effective type short as an int, which is likely a strict aliasing violation.
Furthermore, we may be going outside the bounds of the original array and in fact since arr is not an int[] array to begin with, what will happen when we cast and start to do crazy pointer arithmetic is anyone's guess.
So there's some 3-4 ways where that code could go severely wrong even though it passed compilation cleanly. It doesn't matter that we know that short is 2 bytes, int is 4 bytes and n is some large number. C doesn't really promise anything for these kind of wild & crazy pointer stunts, except it does promise to be a nasty show-stopper in a number of cases.
And that's all the scenarios I could think about when it comes to pointer casts. There are many other things we can cast, but this answer is already way too long.

1 comment thread