Định nghĩa về “dễ bay hơi” này dễ bay hơi hay GCC có một số vấn đề về tuân thủ tiêu chuẩn?


89

Tôi cần một chức năng (như SecureZeroMemory từ WinAPI) luôn không có bộ nhớ và không được tối ưu hóa, ngay cả khi trình biên dịch cho rằng bộ nhớ sẽ không bao giờ được truy cập lại sau đó. Có vẻ như là một ứng cử viên hoàn hảo cho sự biến động. Nhưng tôi đang gặp một số vấn đề khi thực sự làm việc này với GCC. Đây là một chức năng ví dụ:

void volatileZeroMemory(volatile void* ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = (volatile unsigned char*)ptr;

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

Đủ đơn giản. Nhưng mã mà GCC thực sự tạo ra nếu bạn gọi nó là rất khác nhau với phiên bản trình biên dịch và số byte mà bạn thực sự đang cố gắng bằng không. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 và 4.5.3 không bao giờ bỏ qua những biến động.
  • GCC 4.6.4 và 4.7.3 bỏ qua tính dễ bay hơi đối với kích thước mảng 1, 2 và 4.
  • GCC 4.8.1 cho đến 4.9.2 bỏ qua biến động đối với kích thước mảng 1 và 2.
  • GCC 5.1 cho đến 5.3 bỏ qua tính dễ bay hơi đối với kích thước mảng 1, 2, 4, 8.
  • GCC 6.1 chỉ bỏ qua nó cho bất kỳ kích thước mảng nào (điểm thưởng cho tính nhất quán).

Bất kỳ trình biên dịch nào khác mà tôi đã thử nghiệm (clang, icc, vc) tạo ra các cửa hàng mà tôi mong đợi, với bất kỳ phiên bản trình biên dịch nào và bất kỳ kích thước mảng nào. Vì vậy, tại thời điểm này, tôi tự hỏi, liệu đây có phải là một lỗi biên dịch GCC (khá cũ và nghiêm trọng?) Hay là định nghĩa về biến động trong tiêu chuẩn không chính xác rằng đây thực sự là hành vi tuân thủ, khiến về cơ bản là không thể viết bản portable " Chức năng "SecureZeroMemory"?

Chỉnh sửa: Một số quan sát thú vị.

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <atomic>

void callMeMaybe(char* buf);

void volatileZeroMemory(volatile void* ptr, std::size_t size)
{
    for (auto bytePtr = static_cast<volatile std::uint8_t*>(ptr); size-- > 0; )
    {
        *bytePtr++ = 0;
    }

    //std::atomic_thread_fence(std::memory_order_release);
}

std::size_t foo()
{
    char arr[8];
    callMeMaybe(arr);
    volatileZeroMemory(arr, sizeof arr);
    return sizeof arr;
}

Việc ghi có thể có từ callMeMaybe () sẽ làm cho tất cả các phiên bản GCC ngoại trừ 6.1 tạo ra các cửa hàng dự kiến. Nhận xét trong hàng rào bộ nhớ cũng sẽ làm cho GCC 6.1 tạo ra các cửa hàng, mặc dù chỉ kết hợp với khả năng ghi từ callMeMaybe ().

Ai đó cũng đã đề xuất xóa bộ nhớ đệm. Microsoft không cố xóa bộ nhớ cache trong "SecureZeroMemory". Dù sao thì bộ nhớ đệm cũng có khả năng bị mất hiệu lực khá nhanh, vì vậy đây có lẽ không phải là vấn đề lớn. Ngoài ra, nếu một chương trình khác đang cố gắng thăm dò dữ liệu hoặc nếu nó sẽ được ghi vào tệp trang, nó sẽ luôn là phiên bản 0.

Cũng có một số lo ngại về GCC 6.1 sử dụng memset () trong hàm độc lập. Trình biên dịch GCC 6.1 trên godbolt có thể là một bản dựng bị hỏng, vì GCC 6.1 dường như tạo ra một vòng lặp bình thường (giống như 5.3 làm trên godbolt) cho chức năng độc lập đối với một số người. (Đọc bình luận về câu trả lời của zwol.)


4
IMHO sử dụng volatilelà một lỗi trừ khi được chứng minh khác. Nhưng rất có thể là một lỗi. volatilekhông được chỉ rõ là nguy hiểm - chỉ cần không sử dụng nó.
Jesper Juhl

19
@JesperJuhl: Không, volatilethích hợp trong trường hợp này.
Dietrich Epp

9
@NathanOliver: Điều đó sẽ không hoạt động, bởi vì các trình biên dịch có thể tối ưu hóa các kho lưu trữ đã chết ngay cả khi họ sử dụng memset. Vấn đề là các trình biên dịch biết chính xác những gì memsetlàm.
Dietrich Epp

8
@PaulStelian: Điều đó sẽ tạo ra một volatilecon trỏ, chúng tôi muốn một con trỏ đến volatile(chúng tôi không quan tâm liệu ++có nghiêm ngặt hay không, nhưng liệu *p = 0có nghiêm ngặt hay không).
Dietrich Epp

7
@JesperJuhl: Không có gì được chỉ rõ về tính dễ bay hơi.
GManNickG

Câu trả lời:


82

Hành vi của GCC có thể đang tuân thủ và ngay cả khi nó không phù hợp, bạn không nên dựa vào volatileđể làm những gì bạn muốn trong những trường hợp như thế này. Ủy ban C được thiết kế volatilecho các thanh ghi phần cứng ánh xạ bộ nhớ và cho các biến được sửa đổi trong quá trình điều khiển bất thường (ví dụ: bộ xử lý tín hiệu và setjmp). Đó là những thứ duy nhất mà nó đáng tin cậy. Sẽ không an toàn khi sử dụng như một chú thích chung chung "không tối ưu hóa điều này".

Đặc biệt, tiêu chuẩn không rõ ràng về một điểm chính. (Tôi đã chuyển đổi mã của bạn sang C; không nên có bất kỳ sự khác biệt nào giữa C và C ++ ở đây. Tôi cũng đã thực hiện thủ công nội tuyến sẽ xảy ra trước khi tối ưu hóa đáng ngờ, để hiển thị những gì trình biên dịch "nhìn thấy" tại thời điểm đó .)

extern void use_arr(void *, size_t);
void foo(void)
{
    char arr[8];
    use_arr(arr, sizeof arr);

    for (volatile char *p = (volatile char *)arr;
         p < (volatile char *)(arr + 8);
         p++)
      *p = 0;
}

Vòng lặp xóa bộ nhớ truy cập arrthông qua một giá trị đủ điều kiện dễ bay hơi, nhưng arrbản thân nó không được khai báo volatile. Do đó, ít nhất được cho phép để trình biên dịch C suy ra rằng các cửa hàng được tạo bởi vòng lặp đã "chết" và xóa hoàn toàn vòng lặp. Có văn bản trong C Rationale ngụ ý rằng ủy ban có ý định yêu cầu các cửa hàng đó được bảo tồn, nhưng bản thân tiêu chuẩn không thực sự đưa ra yêu cầu đó, như tôi đã đọc.

Để thảo luận thêm về những gì tiêu chuẩn yêu cầu hoặc không yêu cầu, hãy xem Tại sao một biến cục bộ dễ bay hơi được tối ưu hóa khác với đối số dễ bay hơi và tại sao trình tối ưu hóa tạo vòng lặp no-op từ biến số sau? , Việc truy cập một đối tượng không bay hơi được khai báo thông qua tham chiếu / con trỏ dễ bay hơi có đưa ra các quy tắc dễ bay hơi khi truy cập đã nêu không? lỗi GCC 71793 .

Để biết thêm về những gì mà ủy ban nghĩ volatile là cho, tìm kiếm trên C99 Lý do cho từ "ổn định". Bài báo của John Regehr " Volatiles is Miscompiled " minh họa chi tiết cách mà các trình volatilebiên dịch sản xuất có thể không đáp ứng được những kỳ vọng của lập trình viên . Loạt bài tiểu luận của đội LLVM " Điều mà mọi lập trình viên C nên biết về hành vi không xác định " không đề cập cụ thể volatilenhưng sẽ giúp bạn hiểu cách thức và lý do tại sao các trình biên dịch C hiện đại không phải là " trình biên dịch di động".


Đối với câu hỏi thực tế về cách triển khai một hàm thực hiện những gì bạn muốn volatileZeroMemory: Bất kể tiêu chuẩn yêu cầu hoặc có ý nghĩa yêu cầu những gì, sẽ là khôn ngoan nhất nếu giả sử rằng bạn không thể sử dụngvolatile cho việc này. Có một sự thay thế có thể được dựa vào để làm việc, bởi vì nó sẽ phá vỡ quá nhiều thứ khác nếu nó không làm việc:

extern void memory_optimization_fence(void *ptr, size_t size);
inline void
explicit_bzero(void *ptr, size_t size)
{
   memset(ptr, 0, size);
   memory_optimization_fence(ptr, size);
}

/* in a separate source file */
void memory_optimization_fence(void *unused1, size_t unused2) {}

Tuy nhiên, bạn phải hoàn toàn đảm bảo rằng điều đó memory_optimization_fencekhông được gạch chân trong bất kỳ trường hợp nào. Nó phải nằm trong tệp nguồn của chính nó và nó không được tối ưu hóa thời gian liên kết.

Có các tùy chọn khác, dựa vào các phần mở rộng của trình biên dịch, có thể sử dụng được trong một số trường hợp và có thể tạo mã chặt chẽ hơn (một trong số chúng đã xuất hiện trong ấn bản trước của câu trả lời này), nhưng không có tùy chọn nào là phổ biến.

(Tôi khuyên bạn nên gọi hàm explicit_bzero , vì nó có sẵn dưới tên đó trong nhiều thư viện C. Có ít nhất bốn ứng cử viên khác cho tên này, nhưng mỗi chức năng chỉ được chấp nhận bởi một thư viện C duy nhất.)

Bạn cũng nên biết rằng, ngay cả khi bạn có thể làm cho nó hoạt động, nó có thể là không đủ. Đặc biệt, hãy xem xét

struct aes_expanded_key { __uint128_t rndk[16]; };

void encrypt(const char *key, const char *iv,
             const char *in, char *out, size_t size)
{
    aes_expanded_key ek;
    expand_key(key, ek);
    encrypt_with_ek(ek, iv, in, out, size);
    explicit_bzero(&ek, sizeof ek);
}

Giả sử phần cứng có hướng dẫn tăng tốc AES, nếu expand_keyencrypt_with_eknằm trong dòng, trình biên dịch có thể giữ ekhoàn toàn trong tệp đăng ký vectơ - cho đến khi lệnh gọi tới explicit_bzero, buộc nó phải sao chép dữ liệu nhạy cảm vào ngăn xếp chỉ để xóa nó và, tệ hơn, không làm điều gì đáng tiếc về các khóa vẫn còn nằm trong các thanh ghi vector!


6
Điều đó thật thú vị ... Tôi muốn xem tham khảo các nhận xét của ủy ban.
Dietrich Epp

10
Làm thế nào để hình vuông này với định nghĩa của 6.7.3 (7) volatile[...] Do đó, bất kỳ biểu thức nào đề cập đến một đối tượng như vậy sẽ được đánh giá đúng theo các quy tắc của máy trừu tượng, như được mô tả trong 5.1.2.3. Hơn nữa, tại mọi điểm trình tự, giá trị được lưu trữ cuối cùng trong đối tượng sẽ phù hợp với giá trị được quy định bởi máy trừu tượng , trừ khi được sửa đổi bởi các yếu tố chưa biết đã đề cập trước đó. Điều gì tạo nên quyền truy cập vào một đối tượng có kiểu đủ điều kiện thay đổi được xác định thực thi. ?
Iwillnotexist Idonotexist Idonotexist

15
@IwillnotexistIdonotexist Từ khóa trong đoạn văn đó là đối tượng . volatile sig_atomic_t flag;là một vật dễ bay hơi . *(volatile char *)foochỉ đơn thuần là một truy cập thông qua một giá trị đủ tiêu chuẩn dễ bay hơi và tiêu chuẩn không yêu cầu điều đó phải có bất kỳ hiệu ứng đặc biệt nào.
zwol

3
Tiêu chuẩn cho biết một cái gì đó phải đáp ứng các tiêu chí nào để được thực hiện "tuân thủ". Không cần cố gắng mô tả việc triển khai trên một nền tảng nhất định phải đáp ứng những tiêu chí nào để trở thành một triển khai "tốt" hoặc "có thể sử dụng được". Cách xử lý của GCC volatilecó thể đủ để làm cho nó trở thành một triển khai "tuân thủ", nhưng điều đó không có nghĩa là nó đủ để trở thành "tốt" hoặc "hữu ích". Đối với nhiều loại lập trình hệ thống, nó phải được coi là thiếu sót một cách đáng tiếc về những mặt đó.
supercat

3
Thông số C cũng nói thẳng rằng "Một triển khai thực tế không cần đánh giá một phần của biểu thức nếu nó có thể suy ra rằng giá trị của nó không được sử dụng và không có tác dụng phụ cần thiết nào được tạo ra ( bao gồm bất kỳ tác dụng phụ nào gây ra bởi việc gọi hàm hoặc truy cập một đối tượng dễ bay hơi ) . " (nhấn mạnh của tôi).
Johannes Schaub - litb

15

Tôi cần một chức năng (như SecureZeroMemory từ WinAPI) luôn luôn bằng không bộ nhớ và không được tối ưu hóa,

Đây là những gì chức năng tiêu chuẩn memset_sdành cho.


Về việc liệu hành vi này với biến động có phù hợp hay không, điều đó hơi khó nói và biến động đã được nói là có nhiều lỗi từ lâu.

Một vấn đề là các thông số kỹ thuật nói rằng "Quyền truy cập vào các đối tượng dễ bay hơi được đánh giá nghiêm ngặt theo các quy tắc của máy trừu tượng." Nhưng điều đó chỉ đề cập đến 'các đối tượng dễ bay hơi', không truy cập một đối tượng không bay hơi thông qua một con trỏ đã được bổ sung thêm tính năng dễ bay hơi. Vì vậy, rõ ràng nếu một trình biên dịch có thể nói rằng bạn không thực sự truy cập vào một đối tượng dễ bay hơi thì xét cho cùng, nó không bắt buộc phải coi đối tượng đó là dễ bay hơi.


4
Lưu ý: Đây là một phần của tiêu chuẩn C11 và chưa có sẵn trong tất cả các loại công cụ.
Dietrich Epp

5
Một điều thú vị là hàm này được chuẩn hóa cho C11 nhưng không phải cho C ++ 11, C ++ 14 hoặc C ++ 17. Vì vậy, về mặt kỹ thuật, nó không phải là một giải pháp cho C ++, nhưng tôi đồng ý rằng đây có vẻ là lựa chọn tốt nhất từ ​​góc độ thực tế. Tại thời điểm này, tôi tự hỏi liệu hành vi từ GCC có phù hợp hay không. Chỉnh sửa: Trên thực tế, VS 2015 không có memset_s, vì vậy nó không phải là tất cả các di động.
cooky451

2
@ cooky451 Tôi nghĩ C ++ 17 kéo thư viện chuẩn C11 vào bằng cách tham khảo (xem Misc thứ hai).
nwp

14
Ngoài ra, mô tả memset_stheo tiêu chuẩn C11 là một lời nói quá. Nó là một phần của Phụ lục K, là tùy chọn trong C11 (và do đó cũng tùy chọn trong C ++). Về cơ bản, tất cả những người triển khai, bao gồm cả Microsoft, những người có ý tưởng về nó ngay từ đầu (!), Đã từ chối tiếp nhận nó; lần cuối tôi nghe họ nói về việc loại bỏ nó trong C-next.
zwol

8
@ cooky451 Trong một số vòng kết nối, Microsoft nổi tiếng với việc ép buộc mọi thứ vào tiêu chuẩn C trước sự phản đối của những người khác và sau đó không buồn tự thực hiện nó. (Ví dụ nghiêm trọng nhất về điều này là việc C99 nới lỏng các quy tắc về loại cơ bản size_tđược phép sử dụng. Win64 ABI không phù hợp với C90. Điều đó có thể là ... không ổn , nhưng không khủng khiếp ... nếu MSVC đã thực sự nhặt C99 thứ như uintmax_t%zumột cách kịp thời, nhưng họ đã không ).
Zwol

2

Tôi cung cấp phiên bản này dưới dạng C ++ di động (mặc dù ngữ nghĩa khác nhau một cách tinh tế):

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = new (ptr) volatile unsigned char[size];

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

Bây giờ bạn có quyền truy cập ghi vào một đối tượng dễ bay hơi , không chỉ là truy cập vào một đối tượng không bay hơi được thực hiện thông qua chế độ xem dễ bay hơi của đối tượng.

Sự khác biệt về ngữ nghĩa là bây giờ nó chính thức kết thúc vòng đời của bất kỳ (các) đối tượng nào chiếm giữ vùng bộ nhớ, vì bộ nhớ đã được sử dụng lại. Vì vậy, quyền truy cập vào đối tượng sau khi zeroing nội dung của nó bây giờ chắc chắn là hành vi không xác định (trước đây nó sẽ là hành vi không xác định trong hầu hết các trường hợp, nhưng một số ngoại lệ chắc chắn tồn tại).

Để sử dụng zeroing này trong suốt thời gian tồn tại của đối tượng thay vì ở cuối, người gọi nên sử dụng vị trí newđể đặt lại một phiên bản mới của kiểu ban đầu.

Mã có thể được làm ngắn hơn (mặc dù ít rõ ràng hơn) bằng cách sử dụng khởi tạo giá trị:

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    new (ptr) volatile unsigned char[size] ();
}

và tại thời điểm này, nó là một lớp lót và hầu như không đảm bảo một chức năng trợ giúp nào cả.


2
Nếu các truy cập vào đối tượng sau khi thực thi hàm sẽ gọi UB, điều đó có nghĩa là các truy cập đó có thể mang lại các giá trị mà đối tượng đã giữ trước khi nó bị "xóa". Làm thế nào mà không phải là đối lập của an ninh?
supercat

0

Có thể viết một phiên bản di động của hàm bằng cách sử dụng một đối tượng dễ bay hơi ở phía bên phải và buộc trình biên dịch bảo toàn các cửa hàng vào mảng.

void volatileZeroMemory(void* ptr, unsigned long long size)
{
    volatile unsigned char zero = 0;
    unsigned char* bytePtr = static_cast<unsigned char*>(ptr);

    while (size--)
    {
        *bytePtr++ = zero;
    }

    zero = static_cast<unsigned char*>(ptr)[zero];
}

Đối zerotượng được khai báovolatile để đảm bảo trình biên dịch không thể đưa ra giả định nào về giá trị của nó mặc dù nó luôn đánh giá là 0.

Biểu thức gán cuối cùng đọc từ một chỉ số dễ bay hơi trong mảng và lưu trữ giá trị trong một đối tượng dễ bay hơi. Vì không thể tối ưu hóa việc đọc này, nó đảm bảo rằng trình biên dịch phải tạo ra các cửa hàng được chỉ định trong vòng lặp.


1
Điều này hoàn toàn không hoạt động ... chỉ cần nhìn vào mã đang được tạo.
cooky451

1
Sau khi đọc ASM mo 'được tạo của tôi tốt hơn, nó dường như nội tuyến cuộc gọi hàm và giữ lại vòng lặp, nhưng không thực hiện bất kỳ lưu trữ nào *ptrtrong vòng lặp đó, hoặc thực sự là bất cứ điều gì ... chỉ lặp lại. wtf, não của tôi mất rồi.
underscore_d

3
@underscore_d Đó là bởi vì nó tối ưu hóa kho lưu trữ trong khi vẫn giữ nguyên giá trị đọc của biến động.
D Krueger

1
Yeah, và nó bãi kết quả đến một không thay đổi edx: Tôi có được điều này:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d

1
Nếu tôi thay đổi hàm để cho phép truyền một volatile unsigned char constbyte điền tùy ý ... thì nó thậm chí không đọc được . Lệnh gọi nội tuyến được tạo volatileFill()chỉ là [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Tại sao trình tối ưu hóa (A) không đọc lại byte điền và (B) bận tâm bảo tồn vòng lặp mà nó không làm gì cả?
underscore_d
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.