簡體   English   中英

什么是嚴格的別名規則?

[英]What is the strict aliasing rule?

當詢問C 中常見的未定義行為時,人們有時會提到嚴格的別名規則。
他們在說什么?

遇到嚴格別名問題的典型情況是將結構(如設備/網絡消息)覆蓋到系統字大小的緩沖區(如指向uint32_t s 或uint16_t s 的指針)上。 當您將結構覆蓋到這樣的緩沖區上時,或者通過指針轉換將緩沖區覆蓋到這樣的結構上時,您很容易違反嚴格的別名規則。

所以在這種設置中,如果我想向某個東西發送消息,我必須有兩個不兼容的指針指向同一個內存塊。 然后我可能會天真地編寫如下代碼:

typedef struct Msg
{
    unsigned int a;
    unsigned int b;
} Msg;

void SendWord(uint32_t);

int main(void)
{
    // Get a 32-bit buffer from the system
    uint32_t* buff = malloc(sizeof(Msg));
    
    // Alias that buffer through message
    Msg* msg = (Msg*)(buff);
    
    // Send a bunch of messages    
    for (int i = 0; i < 10; ++i)
    {
        msg->a = i;
        msg->b = i+1;
        SendWord(buff[0]);
        SendWord(buff[1]);   
    }
}

嚴格的別名規則使此設置非法:取消引用一個指針,該指針對不屬於兼容類型或 C 2011 6.5 第 7 1段允許的其他類型之一的對象進行別名是未定義的行為。 不幸的是,你仍然可以用這種方式編碼,可能會得到一些警告,讓它編譯得很好,只是在你運行代碼時會出現奇怪的意外行為。

(GCC 在給出別名警告的能力上似乎有些不一致,有時給我們一個友好的警告,有時不是。)

要了解為什么這種行為是未定義的,我們必須考慮嚴格的別名規則購買編譯器的原因。 基本上,有了這個規則,它就不必考慮插入指令來刷新buff每次運行循環的內容。 取而代之的是,在優化時,通過一些關於別名的令人討厭的非強制假設,它可以省略這些指令,在循環運行之前將buff[0]buff[1]加載到 CPU 寄存器中,並加快循環體的速度。 在引入嚴格別名之前,編譯器不得不處於一種偏執的狀態,即buff的內容可能會被任何先前的內存存儲所改變。 因此,為了獲得額外的性能優勢,並假設大多數人不鍵入雙關指針,引入了嚴格的別名規則。

請記住,如果您認為該示例是人為的,那么如果您將緩沖區傳遞給另一個為您發送的函數(如果您有的話),甚至可能會發生這種情況。

void SendMessage(uint32_t* buff, size_t size32)
{
    for (int i = 0; i < size32; ++i) 
    {
        SendWord(buff[i]);
    }
}

並重寫了我們之前的循環以利用這個方便的功能

for (int i = 0; i < 10; ++i)
{
    msg->a = i;
    msg->b = i+1;
    SendMessage(buff, 2);
}

編譯器可能會或可能不會或足夠聰明地嘗試內聯 SendMessage,它可能會或可能不會決定再次加載或不加載 buff。 如果SendMessage是另一個單獨編譯的 API 的一部分,它可能有加載 buff 內容的指令。 再說一次,也許你在 C++ 中,這是編譯器認為它可以內聯的一些模板化頭實現。 或者,這只是您在 .c 文件中為您自己的方便而編寫的內容。 無論如何,可能仍會出現未定義的行為。 即使我們知道幕后發生的一些事情,它仍然違反規則,因此不能保證明確定義的行為。 因此,僅僅通過包裝一個接受我們的單詞分隔緩沖區的函數並不一定有幫助。

那么我該如何解決呢?

  • 使用工會。 大多數編譯器都支持這一點,而不會抱怨嚴格的別名。 這在 C99 中是允許的,在 C11 中是明確允許的。

     union { Msg msg; unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)]; };
  • 您可以在編譯器中禁用嚴格別名( gcc 中的f[no-]strict-aliasing ))

  • 您可以使用char*代替系統的單詞進行別名。 規則允許char*例外(包括signed charunsigned char )。 始終假定char*為其他類型起別名。 但是,這不會以另一種方式起作用:沒有假設您的結構別名為字符緩沖區。

初學者小心

當兩種類型相互疊加時,這只是一個潛在的雷區。 您還應該了解字節序字對齊以及如何通過正確打包結構來處理對齊問題。

腳注

1 C 2011 6.5 7 允許左值訪問的類型有:

  • 與對象的有效類型兼容的類型,
  • 與對象的有效類型兼容的類型的限定版本,
  • 與對象的有效類型相對應的有符號或無符號類型,
  • 對應於對象有效類型的限定版本的有符號或無符號類型,
  • 聚合或聯合類型,在其成員中包括上述類型之一(遞歸地包括子聚合或包含聯合的成員),或
  • 一種字符類型。

我找到的最好的解釋是 Mike Acton, Understanding Strict Aliasing 它稍微專注於 PS3 開發,但基本上只是 GCC。

來自文章:

“嚴格別名是由 C(或 C++)編譯器做出的假設,即取消引用指向不同類型對象的指針永遠不會引用相同的內存位置(即彼此別名。)”

因此,基本上,如果您有一個int*指向某個包含int的內存,然后您將一個float*指向該內存並將其用作float ,那么您就違反了規則。 如果您的代碼不遵守這一點,那么編譯器的優化器很可能會破壞您的代碼。

該規則的例外是char* ,它允許指向任何類型。

筆記

這摘自我的“什么是嚴格的別名規則以及我們為什么要關心?” 寫上去。

什么是嚴格別名?

在 C 和 C++ 中,別名與允許我們訪問存儲值的表達式類型有關。 在 C 和 C++ 中,標准都指定了允許哪些表達式類型為哪些類型設置別名。 允許編譯器和優化器假設我們嚴格遵循別名規則,因此術語嚴格別名規則 如果我們嘗試使用不允許的類型訪問值,則將其歸類為未定義行為( UB )。 一旦我們有未定義的行為,所有的賭注都被取消了,我們的程序的結果就不再可靠了。

不幸的是,由於嚴格的別名違規,我們通常會獲得我們期望的結果,從而有可能具有新優化的編譯器的未來版本會破壞我們認為有效的代碼。 這是不可取的,了解嚴格的別名規則以及如何避免違反它們是一個值得的目標。

為了更多地了解我們為什么關心,我們將討論違反嚴格別名規則時出現的問題,類型雙關,因為類型雙關中使用的常用技術經常違反嚴格的別名規則以及如何正確鍵入雙關。

初步示例

讓我們看一些示例,然后我們可以確切地討論標准所說的內容,檢查一些進一步的示例,然后看看如何避免嚴格的混疊並捕獲我們錯過的違規行為。 這是一個不應令人驚訝的示例(現場示例):

int x = 10;
int *ip = &x;

std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";

我們有一個int*指向一個int占用的內存,這是一個有效的別名。 優化器必須假設通過ip的賦值可以更新x占用的值。

下一個示例顯示了導致未定義行為的別名(現場示例):

int foo( float *f, int *i ) { 
    *i = 1;
    *f = 0.f;
    
    return *i;
}

int main() {
    int x = 0;
    
    std::cout << x << "\n";   // Expect 0
    x = foo(reinterpret_cast<float*>(&x), &x);
    std::cout << x << "\n";   // Expect 0?
}

在函數foo中,我們接受一個int*和一個float* ,在這個例子中,我們調用foo並設置兩個參數指向同一個內存位置,在這個例子中包含一個int 請注意, reinterpret_cast告訴編譯器將表達式視為具有由其模板參數指定的類型。 在這種情況下,我們告訴它將表達式&x視為其類型為float* 我們可能天真地期望第二個cout的結果為0 ,但使用-O2啟用優化后,gcc 和 clang 都會產生以下結果:

0
1

這可能不是預期的,但完全有效,因為我們調用了未定義的行為。 浮點數不能有效地為int對象起別名。 因此,優化器可以假設在取消引用i時存儲的常量 1將是返回值,因為通過f進行的存儲不能有效地影響int對象。 在編譯器資源管理器中插入代碼表明這正是正在發生的事情(現場示例):

foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret

使用基於類型的別名分析 (TBAA)的優化器假定將返回1並將常量值直接移動到帶有返回值的寄存器eax中。 TBAA 使用語言規則關於允許使用別名的類型來優化加載和存儲。 在這種情況下,TBAA 知道float不能別名int並優化i的負載。

現在,到規則書

標准到底說我們被允許和不允許做什么? 標准語言並不簡單,因此對於每個項目,我將嘗試提供代碼示例來演示其含義。

C11 標准是怎么說的?

C11標准在第6.5 節表達式第 7 段中說明了以下內容:

對象的存儲值只能由具有以下類型之一的左值表達式訪問: 88) — 與對象的有效類型兼容的類型,

int x = 1;
int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int

— 與對象的有效類型兼容的類型的限定版本,

int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int

— 與對象的有效類型相對應的有符號或無符號類型,

int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to 
                     // the effective type of the object

gcc/clang 有一個擴展並且允許將unsigned int*分配給int* ,即使它們不是兼容的類型。

— 對應於對象有效類型的限定版本的有符號或無符號類型,

int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type 
                     // that corresponds with to a qualified version of the effective type of the object

— 在其成員中包含上述類型之一的聚合或聯合類型(遞歸地,包括子聚合或包含聯合的成員),或

struct foo {
    int x;
};
    
void foobar( struct foo *fp, int *ip );  // struct foo is an aggregate that includes int among its members so it
                                         // can alias with *ip

foo f;
foobar( &f, &f.x );

— 一種字符類型。

int x = 65;
char *p = (char *)&x;
printf("%c\n", *p );  // *p gives us an lvalue expression of type char which is a character type.
                      // The results are not portable due to endianness issues.

C++17 草案標准是怎么說的

[basic.lval] 第 11節中的 C++17 標准草案說:

如果程序嘗試通過非下列類型之一的泛左值訪問對象的存儲值,則行為未定義: 63

(11.1) — 對象的動態類型,

void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0};        // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n";        // *ip gives us a glvalue expression of type int which matches the dynamic type 
                                 // of the allocated object

(11.2) — 對象動態類型的 cv 限定版本,

int x = 1;
const int *cip = &x;
std::cout << *cip << "\n";  // *cip gives us a glvalue expression of type const int which is a cv-qualified 
                            // version of the dynamic type of x

(11.3) — 與對象的動態類型類似(如 7.5 中定義)的類型,

(11.4) — 對應於對象動態類型的有符號或無符號類型,

// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
    si = 1;
    ui = 2;

    return si;
}

(11.5) — 有符號或無符號類型,對應於對象動態類型的 cv 限定版本,

signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing

(11.6) — 一種聚合或聯合類型,在其元素或非靜態數據成員中包括上述類型之一(遞歸地包括子聚合或包含聯合的元素或非靜態數據成員),

struct foo {
    int x;
};

// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
    fp.x = 1;
    ip = 2;

    return fp.x;
}

foo f;
foobar( f, f.x );

(11.7) — 一種類型,它是對象的動態類型的(可能是 cv 限定的)基類類型,

struct foo { int x; };

struct bar : public foo {};

int foobar( foo &f, bar &b ) {
    f.x = 1;
    b.x = 2;

    return f.x;
}

(11.8) — char、unsigned char 或 std::byte 類型。

int foo( std::byte &b, uint32_t &ui ) {
    b = static_cast<std::byte>('a');
    ui = 0xFFFFFFFF;
  
    return std::to_integer<int>( b );  // b gives us a glvalue expression of type std::byte which can alias
                                       // an object of type uint32_t
}

值得注意的是,上面的列表中沒有包含signed char ,這與C中的一個顯着區別是字符類型

什么是類型雙關語

我們已經到了這一點,我們可能想知道,我們為什么要別名? 答案通常是鍵入 pun ,通常使用的方法違反嚴格的別名規則。

有時我們想繞過類型系統並將對象解釋為不同的類型。 這稱為類型雙關語,將一段內存重新解釋為另一種類型。 類型雙關語對於希望訪問對象的底層表示以查看、傳輸或操作的任務很有用。 我們發現使用類型雙關語的典型領域是編譯器、序列化、網絡代碼等……

傳統上,這是通過獲取對象的地址,將其轉換為我們想要重新解釋它的類型的指針,然后訪問該值來完成的,或者換句話說,通過別名。 例如:

int x = 1;

// In C
float *fp = (float*)&x;  // Not a valid aliasing

// In C++
float *fp = reinterpret_cast<float*>(&x);  // Not a valid aliasing

printf( "%f\n", *fp );

正如我們之前看到的,這不是一個有效的別名,所以我們調用了未定義的行為。 但是傳統的編譯器並沒有利用嚴格的別名規則,而且這種類型的代碼通常可以正常工作,不幸的是,開發人員已經習慣了這種方式。 類型雙關語的一種常見替代方法是通過聯合,這在 C 中有效,但在 C++ 中未定義行為請參閱實時示例):

union u1
{
    int n;
    float f;
};

union u1 u;
u.f = 1.0f;

printf( "%d\n", u.n );  // UB in C++ n is not the active member

這在 C++ 中是無效的,有些人認為聯合的目的僅僅是為了實現變體類型,並認為使用聯合進行類型雙關是一種濫用。

我們如何正確輸入雙關語?

C 和 C++ 中類型雙關的標准方法是memcpy 這可能看起來有點笨拙,但優化器應該認識到memcpy用於類型雙關並優化它並生成一個寄存器來注冊移動。 例如,如果我們知道int64_t的大小與double相同:

static_assert( sizeof( double ) == sizeof( int64_t ) );  // C++17 does not require a message

我們可以使用memcpy

void func1( double d ) {
    std::int64_t n;
    std::memcpy(&n, &d, sizeof d);
    //...

在足夠的優化級別上,任何體面的現代編譯器都會生成與前面提到的reinterpret_cast方法或union類型雙關語方法相同的代碼。 檢查生成的代碼,我們看到它只使用了 register mov(實時編譯器資源管理器示例)。

C++20 和 bit_cast

在 C++20 中,我們可能會獲得bit_cast實現在來自提案的鏈接中可用),它為類型雙關語提供了一種簡單而安全的方法,並且可以在 constexpr 上下文中使用。

以下是如何使用bit_castunsigned int類型雙關語轉換為float的示例,(現場查看):

std::cout << bit_cast<float>(0x447a0000) << "\n"; //assuming sizeof(float) == sizeof(unsigned int)

ToFrom類型不具有相同大小的情況下,它需要我們使用中間結構 15。 我們將使用一個包含sizeof( unsigned int )字符數組(假設 4 字節 unsigned int )作為From類型和unsigned int作為To類型的結構:

struct uint_chars {
    unsigned char arr[sizeof( unsigned int )] = {};  // Assume sizeof( unsigned int ) == 4
};

// Assume len is a multiple of 4 
int bar( unsigned char *p, size_t len ) {
    int result = 0;

    for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
        uint_chars f;
        std::memcpy( f.arr, &p[index], sizeof(unsigned int));
        unsigned int result = bit_cast<unsigned int>(f);

        result += foo( result );
    }

    return result;
}

不幸的是,我們需要這種中間類型,但這是bit_cast的當前約束。

捕獲嚴格的別名違規行為

我們沒有很多好的工具來捕捉 C++ 中的嚴格別名,我們擁有的工具將捕捉一些嚴格別名違規的情況以及一些未對齊的加載和存儲的情況。

使用標志-fstrict-aliasing-Wstrict-aliasing的 gcc 可以捕獲某些情況,盡管並非沒有誤報/誤報。 例如,以下情況將在 gcc 中生成警告(現場查看):

int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught 
               // it was being accessed w/ an indeterminate value below

printf("%i\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));

雖然它不會捕捉到這種額外的情況(現場觀看):

int *p;

p = &a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));

盡管 clang 允許使用這些標志,但它顯然並沒有真正實現警告。

我們可以使用的另一個工具是 ASan,它可以捕獲未對齊的負載和存儲。 盡管這些不是直接的嚴格混疊違規,但它們是嚴格混疊違規的常見結果。 例如,以下情況在使用-fsanitize=address使用 clang 構建時會產生運行時錯誤

int *x = new int[2];               // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6);     // regardless of alignment of x this will not be an aligned address
*u = 1;                            // Access to range [6-9]
printf( "%d\n", *u );              // Access to range [6-9]

我要推薦的最后一個工具是 C++ 特定的,嚴格來說不是一個工具,而是一種編碼實踐,不允許 C 風格的強制轉換。 gcc 和 clang 都將使用-Wold-style-cast生成 C 風格轉換的診斷。 這將強制任何未定義的類型雙關語使用 reinterpret_cast,通常 reinterpret_cast 應該是更仔細的代碼審查的標志。 在代碼庫中搜索 reinterpret_cast 以執行審計也更容易。

對於 C,我們已經涵蓋了所有工具,並且我們還有 tis-interpreter,這是一個靜態分析器,可以詳盡地分析 C 語言的大部分子集的程序。 給定早期示例的 C 版本,其中使用-fstrict-aliasing 會遺漏一種情況(現場查看

int a = 1;
short j;
float f = 1.0;

printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));
    
int *p;

p = &a;
printf("%i\n", j = *((short*)p));

tis-interpeter 能夠捕獲所有三個,以下示例調用 tis-kernel 作為 tis-interpreter(為簡潔起見,編輯了輸出):

./bin/tis-kernel -sa example1.c 
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
              rules by accessing a cell with effective type int.
...

example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
              accessing a cell with effective type float.
              Callstack: main
...

example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
              accessing a cell with effective type int.

最后是目前正在開發的TySan 此清理程序在影子內存段中添加類型檢查信息並檢查訪問以查看它們是否違反別名規則。 該工具可能應該能夠捕獲所有混疊違規,但可能會有很大的運行時開銷。

這是C++03標准第 3.10 節中的嚴格別名規則(其他答案提供了很好的解釋,但沒有提供規則本身):

如果程序嘗試通過以下類型之一以外的左值訪問對象的存儲值,則行為未定義:

  • 對象的動態類型,
  • 對象的動態類型的 cv 限定版本,
  • 與對象的動態類型相對應的有符號或無符號類型,
  • 對應於對象動態類型的 cv 限定版本的有符號或無符號類型,
  • 聚合或聯合類型,在其成員中包括上述類型之一(遞歸地包括子聚合或包含聯合的成員),
  • 一個類型,它是對象的動態類型的(可能是 cv 限定的)基類類型,
  • charunsigned char類型。

C++11C++14措辭(強調更改):

如果程序嘗試通過非下列類型之一的泛左值訪問對象的存儲值,則行為未定義:

  • 對象的動態類型,
  • 對象的動態類型的 cv 限定版本,
  • 與對象的動態類型類似(如 4.4 中定義)的類型,
  • 與對象的動態類型相對應的有符號或無符號類型,
  • 對應於對象動態類型的 cv 限定版本的有符號或無符號類型,
  • 聚合或聯合類型,在其元素或非靜態數據成員中包括上述類型之一(遞歸地包括子聚合或包含聯合的元素或非靜態數據成員),
  • 一個類型,它是對象的動態類型的(可能是 cv 限定的)基類類型,
  • charunsigned char類型。

兩個變化很小: glvalue而不是lvalue ,以及聚合/聯合情況的澄清。

第三個更改提供了更強的保證(放寬了強別名規則):類似類型的新概念現在可以安全地使用別名。


還有C措辭(C99;ISO/IEC 9899:1999 6.5/7;在 ISO/IEC 9899:2011 §6.5 ¶7 中使用完全相同的措辭):

對象的存儲值只能由具有以下類型73) 或 88)之一的左值表達式訪問:

  • 與對象的有效類型兼容的類型,
  • 與對象的有效類型兼容的類型的限定版本,
  • 與對象的有效類型相對應的有符號或無符號類型,
  • 對應於對象有效類型的限定版本的有符號或無符號類型,
  • 聚合或聯合類型,在其成員中包括上述類型之一(遞歸地包括子聚合或包含聯合的成員),或
  • 一種字符類型。

73) 或 88)此列表的目的是指定對象可能或可能不會被別名的情況。

嚴格別名不僅僅指指針,它也影響引用,我為 boost developer wiki 寫了一篇關於它的論文,它很受歡迎,我把它變成了我的咨詢網站上的一個頁面。 它完全解釋了它是什么,為什么它讓人們如此困惑以及如何處理它。 嚴格別名白皮書 特別是它解釋了為什么聯合是 C++ 的危險行為,以及為什么使用 memcpy 是唯一可跨 C 和 C++ 移植的修復程序。 希望這會有所幫助。

作為 Doug T. 已經寫過的內容的附錄,這是一個簡單的測試用例,它可能會使用 gcc 觸發它:

檢查.c

#include <stdio.h>

void check(short *h,long *k)
{
    *h=5;
    *k=6;
    if (*h == 5)
        printf("strict aliasing problem\n");
}

int main(void)
{
    long      k[1];
    check((short *)k,k);
    return 0;
}

使用gcc -O2 -o check check.c編譯。 通常(對於我嘗試過的大多數 gcc 版本)這會輸出“嚴格別名問題”,因為編譯器假定“h”不能與“check”函數中的“k”地址相同。 因此,編譯器會優化if (*h == 5)並始終調用 printf。

對這里感興趣的是 x64 匯編代碼,由 gcc 4.6.3 生成,在 ubuntu 12.04.2 for x64 上運行:

movw    $5, (%rdi)
movq    $6, (%rsi)
movl    $.LC0, %edi
jmp puts

所以 if 條件完全從匯編代碼中消失了。

根據 C89 的基本原理,標准的作者不想要求編譯器給出如下代碼:

int x;
int test(double *p)
{
  x=5;
  *p = 1.0;
  return x;
}

應該要求在賦值語句和返回語句之間重新加載x的值,以便允許p可能指向x的可能性,並且對*p的賦值可能因此改變x的值。 編譯器應該有權假定在上述情況下不會出現別名的概念是沒有爭議的。

不幸的是,C89 的作者以這樣一種方式編寫了他們的規則,如果從字面上理解,即使是下面的函數也會調用未定義的行為:

void test(void)
{
  struct S {int x;} s;
  s.x = 1;
}

因為它使用int類型的左值來訪問struct S類型的對象,而int不屬於可用於訪問struct S的類型。 因為將結構和聯合的所有非字符類型成員的所有使用都視為未定義行為是荒謬的,所以幾乎每個人都認識到至少在某些情況下,一種類型的左值可用於訪問另一種類型的對象. 不幸的是,C 標准委員會未能定義這些情況是什么。

大部分問題是缺陷報告 #028 的結果,該報告詢問了以下程序的行為:

int test(int *ip, double *dp)
{
  *ip = 1;
  *dp = 1.23;
  return *ip;
}
int test2(void)
{
  union U { int i; double d; } u;
  return test(&u.i, &u.d);
}

缺陷報告 #28 指出程序調用未定義行為是因為寫入“double”類型的聯合成員並讀取“int”類型之一的操作調用了實現定義的行為。 這種推理是荒謬的,但構成了有效類型規則的基礎,這些規則不必要地使語言復雜化,而對解決原始問題卻無能為力。

解決原始問題的最佳方法可能是將有關規則目的的腳注視為規范性的,並使規則無法執行,除非在實際涉及使用別名的沖突訪問的情況下。 給定類似的東西:

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   s.x = 1;
   p = &s.x;
   inc_int(p);
   return s.x;
 }

inc_int中沒有沖突,因為對通過*p訪問的存儲的所有訪問都是使用int類型的左值完成的,並且在test中沒有沖突,因為p顯然是從struct S派生的,並且到下一次使用s時,所有將通過p進行的對該存儲的訪問將已經發生。

如果代碼稍微改變...

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   p = &s.x;
   s.x = 1;  //  !!*!!
   *p += 1;
   return s.x;
 }

這里,在標記行上p和對sx的訪問之間存在別名沖突,因為在執行時存在另一個引用,該引用將用於訪問相同的 storage

如果缺陷報告 028 說最初的示例調用了 UB,因為這兩個指針的創建和使用之間存在重疊,這將使事情變得更加清晰,而無需添加“有效類型”或其他此類復雜性。

通過指針強制類型轉換(而不是使用聯合)進行類型雙關是打破嚴格別名的主要示例。

看了很多答案,覺得有必要補充一下:

嚴格的別名(我會稍微描述一下)很重要,因為

  1. 內存訪問可能很昂貴(性能方面),這就是為什么數據在寫回物理內存之前在 CPU 寄存器中進行操作的原因。

  2. 如果將兩個不同 CPU 寄存器中的數據寫入同一個內存空間,我們無法預測在 C 中編碼時哪些數據會“存活”

    在匯編中,我們手動對 CPU 寄存器的加載和卸載進行編碼,我們將知道哪些數據保持不變。 但是 C(謝天謝地)抽象了這個細節。

由於兩個指針可以指向內存中的同一位置,這可能會導致處理可能的沖突的復雜代碼

這個額外的代碼很慢並且會損害性能,因為它執行額外的內存讀/寫操作,這些操作既慢又(可能)不必要。

Strict aliasing rule 允許我們在假設兩個指針不指向同一個內存塊的情況避免冗余機器代碼(另請參見restrict關鍵字)。

嚴格別名聲明可以安全地假設指向不同類型的指針指向內存中的不同位置。

如果編譯器注意到兩個指針指向不同的類型(例如,一個int *和一個float * ),它將假定內存地址不同並且它不會防止內存地址沖突,從而導致更快的機器代碼。

例如

讓我們假設以下函數:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

為了處理a == b的情況(兩個指針都指向同一個內存),我們需要排序和測試我們將數據從內存加載到 CPU 寄存器的方式,所以代碼可能會像這樣結束:

  1. 從內存中加載ab

  2. a添加到b

  3. 保存b重新加載a

    (從 CPU 寄存器保存到內存,從內存加載到 CPU 寄存器)。

  4. b添加到a

  5. a (從 CPU 寄存器)保存到內存中。

第 3 步非常慢,因為它需要訪問物理內存。 但是,需要防止出現ab指向相同內存地址的情況。

嚴格的別名將允許我們通過告訴編譯器這些內存地址明顯不同來防止這種情況發生(在這種情況下,如果指針共享一個內存地址,這將允許進一​​步優化,但無法執行)。

  1. 這可以通過兩種方式告訴編譯器,通過使用不同的類型來指向。 IE:

     void merge_two_numbers(int *a, long *b) {...}
  2. 使用restrict關鍵字。 IE:

     void merge_two_ints(int * restrict a, int * restrict b) {...}

現在,通過滿足 Strict Aliasing 規則,可以避免第 3 步,並且代碼將運行得更快。

事實上,通過添加restrict關鍵字,整個函數可以優化為:

  1. 從內存中加載ab

  2. a添加到b

  3. 將結果保存到ab

由於可能發生沖突(其中ab將加倍而不是加倍),因此以前無法進行此優化。

嚴格的別名不允許不同的指針類型指向相同的數據。

這篇文章應該可以幫助你全面了解這個問題。

從技術上講,在 C++ 中,嚴格的別名規則可能永遠不適用。

注意間接定義( * 運算符):

一元 * 運算符執行間接:應用它的表達式應該是指向對象類型的指針,或指向函數類型的指針,結果是一個左值,指向表達式指向的對象或函數。

同樣來自glvalue的定義

泛左值是一個表達式,它的求值決定了一個對象的身份,(...snip)

因此,在任何定義明確的程序跟蹤中,glvalue 指的是一個對象。 所以所謂的嚴格別名規則永遠不適用。 這可能不是設計師想要的。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM