简体   繁体   中英

What is a valid pointer in gcc linux x86-64 C++?

I am programming C++ using gcc on an obscure system called linux x86-64. I was hoping that may be there are a few folks out there who have used this same, specific system (and might also be able to help me understand what is a valid pointer on this system). I do not care to access the location pointed to by the pointer, just want to calculate it via pointer arithmetic.

According to section 3.9.2 of the standard:

A valid value of an object pointer type represents either the address of a byte in memory (1.7) or a null pointer.

And according to [expr.add]/4 :

When an expression that has integral type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the expression P points to element x[i] of an array object x with n elements, the expressions P + J and J + P (where J has the value j) point to the (possibly-hypothetical) element x[i + j] if 0 ≤ i + j ≤ n; otherwise, the behavior is undefined . Likewise, the expression P - J points to the (possibly-hypothetical) element x[i − j] if 0 ≤ i − j ≤ n; otherwise, the behavior is undefined.

And according to a stackoverflow question on valid C++ pointers in general :

Is 0x1 a valid memory address on your system? Well, for some embedded systems it is. For most OSes using virtual memory, the page beginning at zero is reserved as invalid.

Well, that makes it perfectly clear! So, besides NULL , a valid pointer is a byte in memory, no, wait, it's an array element including the element right after the array, no, wait, it's a virtual memory page, no, wait, it's Superman!

(I guess that by "Superman" here I mean "garbage collectors"... not that I read that anywhere, just smelled it. Seriously, though, all the best garbage collectors don't break in a serious way if you have bogus pointers lying around; at worst they just don't collect a few dead objects every now and then. Doesn't seem like anything worth messing up pointer arithmetic for.).

So, basically, a proper compiler would have to support all of the above flavors of valid pointers. I mean, a hypothetical compiler having the audacity to generate undefined behavior just because a pointer calculation is bad would be dodging at least the 3 bullets above, right? (OK, language lawyers, that one's yours).

Furthermore, many of these definitions are next to impossible for a compiler to know about. There are just so many ways of creating a valid memory byte (think lazy segfault trap microcode, sideband hints to a custom pagetable system that I'm about to access part of an array, ...), mapping a page, or simply creating an array.

Take, for example, a largish array I created myself, and a smallish array that I let the default memory manager create inside of that:

#include <iostream>
#include <inttypes.h>
#include <assert.h>
using namespace std;

extern const char largish[1000000000000000000L];
asm("largish = 0");

int main()
{
  char* smallish = new char[1000000000];
  cout << "largish base = " << (long)largish << "\n"
       << "largish length = " << sizeof(largish) << "\n"
       << "smallish base = " << (long)smallish << "\n";
}

Result:

largish base = 0
largish length = 1000000000000000000
smallish base = 23173885579280

(Don't ask how I knew that the default memory manager would allocate something inside of the other array. It's an obscure system setting. The point is I went through weeks of debugging torment to make this example work, just to prove to you that different allocation techniques can be oblivious to one another).

Given the number of ways of managing memory and combining program modules that are supported in linux x86-64, a C++ compiler really can't know about all of the arrays and various styles of page mappings.

Finally, why do I mention gcc specifically? Because it often seems to treat any pointer as a valid pointer... Take, for instance:

char* super_tricky_add_operation(char* a, long b) {return a + b;}

While after reading all the language specs you might expect the implementation of super_tricky_add_operation(a, b) to be rife with undefined behavior, it is in fact very boring, just an add or lea instruction. Which is so great, because I can use it for very convenient and practical things like non-zero-based arrays if nobody is putzing with my add instructions just to make a point about invalid pointers. I love gcc .

In summary, it seems that any C++ compiler supporting standard linkage tools on linux x86-64 would almost have to treat any pointer as a valid pointer, and gcc appears to be a member of that club. But I'm not quite 100% sure (given enough fractional precision, that is).

So... can anyone give a solid example of an invalid pointer in gcc linux x86-64? By solid I mean leading to undefined behavior. And explain what gives rise to the undefined behavior allowed by the language specs?

(or provide gcc documentation proving the contrary: that all pointers are valid).

Usually pointer math does exactly what you'd expect regardless of whether pointers are pointing at objects or not.

UB doesn't mean it has to fail . Only that it's allowed to make the whole rest of the program behave strangely in some way. UB doesn't mean that just the pointer-compare result can be "wrong", it means the entire behaviour of the whole program is undefined. This tends to happen with optimizations that depend on a violated assumption.

Interesting corner cases include an array at the very top of virtual address space: a pointer to one-past-the-end would wrap to zero, so start < end would be false?!? But pointer comparison doesn't have to handle that case, because the Linux kernel won't ever map the top page, so pointers into it can't be pointing into or just past objects. See Why can't I mmap(MAP_FIXED) the highest virtual page in a 32-bit Linux process on a 64-bit kernel?


Related:

GCC does have a max object size of PTRDIFF_MAX (which is a signed type) . So for example, on 32-bit x86, an array larger than 2GB isn't fully supported for all cases of code-gen, although you can mmap one.

See my comment on What is the maximum size of an array in C? - this restriction lets gcc implement pointer subtraction (to get a size) without keeping the carry-out from the high bit, for types wider than char where the C subtraction result is in objects, not bytes, so in asm it's (a - b) / sizeof(T) .


Don't ask how I knew that the default memory manager would allocate something inside of the other array. It's an obscure system setting. The point is I went through weeks of debugging torment to make this example work, just to prove to you that different allocation techniques can be oblivious to one another).

First of all, you never actually allocated the space for large[] . You used inline asm to make it start at address 0 , but did nothing to actually get those pages mapped.

The kernel won't overlap existing mapped pages when new uses brk or mmap to get new memory from the kernel, so in fact static and dynamic allocation can't overlap.

Second, char[1000000000000000000L] ~= 2^59 bytes. Current x86-64 hardware and software only support canonical 48-bit virtual addresses (sign-extended to 64-bit). This will change with a future generation of Intel hardware which adds another level of page tables, taking us up to 48+9 = 57-bit addresses. (Still with the top half used by the kernel, and a big hole in the middle.)

Your unallocated space from 0 to ~2^59 covers all user-space virtual memory addresses that are possible on x86-64 Linux, so of course anything you allocate (including other static arrays) will be somewhere "inside" this fake array.


Removing the extern const from the declaration (so the array is actually allocated, https://godbolt.org/z/Hp2Exc ) runs into the following problems:

//extern const 
char largish[1000000000000000000L];
//asm("largish = 0");

/* rest of the code unchanged */
  • RIP-relative or 32-bit absolute ( -fno-pie -no-pie ) addressing can't reach static data that gets linked after large[] in the BSS, with the default code model ( -mcmodel=small where all static code+data is assumed to fit in 2GB )

     $ g++ -O2 large.cpp /usr/bin/ld: /tmp/cc876exP.o: in function `_GLOBAL__sub_I_largish': large.cpp:(.text.startup+0xd7): relocation truncated to fit: R_X86_64_PC32 against `.bss' /usr/bin/ld: large.cpp:(.text.startup+0xf5): relocation truncated to fit: R_X86_64_PC32 against `.bss' collect2: error: ld returned 1 exit status 
  • compiling with -mcmodel=medium places large[] in a large-data section where it doesn't interfere with addressing other static data, but it itself is addressed using 64-bit absolute addressing. (Or -mcmodel=large does that for all static code/data, so every call is indirect movabs reg,imm64 / call reg instead of call rel32 .)

    That lets us compile and link, but then the executable won't run because the kernel knows that only 48-bit virtual addresses are supported and won't map the program in its ELF loader before running it, or for PIE before running ld.so on it.

     peter@volta:/tmp$ g++ -fno-pie -no-pie -mcmodel=medium -O2 large.cpp peter@volta:/tmp$ strace ./a.out execve("./a.out", ["./a.out"], 0x7ffd788a4b60 /* 52 vars */) = -1 EINVAL (Invalid argument) +++ killed by SIGSEGV +++ Segmentation fault (core dumped) peter@volta:/tmp$ g++ -mcmodel=medium -O2 large.cpp peter@volta:/tmp$ strace ./a.out execve("./a.out", ["./a.out"], 0x7ffdd3bbad00 /* 52 vars */) = -1 ENOMEM (Cannot allocate memory) +++ killed by SIGSEGV +++ Segmentation fault (core dumped) 

(Interesting that we get different error codes for PIE vs non-PIE executables, but still before execve() even completes.)


Tricking the compiler + linker + runtime with asm("largish = 0"); is not very interesting, and creates obvious undefined behaviour.

Fun fact #2: x64 MSVC doesn't support static objects larger than 2^31-1 bytes. IDK if it has a -mcmodel=medium equivalent. Basically GCC fails to warn about objects too large for the selected memory model.

<source>(7): error C2148: total size of array must not exceed 0x7fffffff bytes

<source>(13): warning C4311: 'type cast': pointer truncation from 'char *' to 'long'
<source>(14): error C2070: 'char [-1486618624]': illegal sizeof operand
<source>(15): warning C4311: 'type cast': pointer truncation from 'char *' to 'long'

Also, it points out that long is the wrong type for pointers in general (because Windows x64 is an LLP64 ABI, where long is 32 bits). You want intptr_t or uintptr_t , or something equivalent to printf("%p") that prints a raw void* .

The Standard does not anticipate the existence of any storage beyond that which the implementation provides via objects of static, automatic, or thread duration, or the use of standard-library functions like calloc . It consequently imposes no restrictions on how implementations process pointers to such storage, since from its perspective such storage doesn't exist, pointers that meaningfully identify non-existent storage don't exist, and things that don't exist don't need to have rules written about them.

That doesn't mean that the people on the Committee weren't well aware that many execution environments provided forms of storage that C implementations might know nothing about. The expected, however, that people who actually worked with various platforms would be better placed than the Committee to determine what kinds of things programmers would need to do with such "outside" addresses, and how to best support such needs. No need for the Standard to concern itself with such things.

As it happens, there are some execution environments where it is more convenient for a compiler to treat pointers arithmetic like integer math than to do anything else, and many compilers for such platforms treat pointer arithmetic usefully even in cases where they're not required to do so. For 32-bit and 64-bit x86 and x64, I don't think there are any bit patterns for invalid non-null addresses, but it may be possible to form pointers that don't behave as valid pointers to the objects they address.

For example, given something like:

char x=1,y=2;
ptrdiff_t delta = (uintptr_t)&y - (uintptr_t)&x;
char *p = &x+delta;
*p = 3;

even if pointer representation is defined in such a way that using integer arithmetic to add delta to the address of x would yield y , that would in no way guarantee that a compiler would recognize that operations on *p might affect y , even if p holds y 's address. Pointer p would effectively behave as though its address was invalid even though the bit pattern would match that of y 's address.

The following examples show that GCC specifically assumes at least the following:

  • A global array cannot be at address 0.
  • An array cannot wrap around address 0.

Examples of unexpected behavior arising from arithmetic on invalid pointers in gcc linux x86-64 C++ (thank you melpomene):

  • largish == NULL evaluates to false in the program in the question.
  • unsigned n = ...; if (ptr + n < ptr) { /*overflow */ } unsigned n = ...; if (ptr + n < ptr) { /*overflow */ } can be optimized to if (false) .
  • int arr[123]; int n = ...; if (arr + n < arr || arr + n > arr + 123) int arr[123]; int n = ...; if (arr + n < arr || arr + n > arr + 123) can be optimized to if (false).

Note that these examples all involve comparison of the invalid pointers, and therefore may not affect the practical case of non-zero-based arrays. Therefore I have opened a new question of a more practical nature.

Thank you everyone in the chat for helping to narrow down the question.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM