Tại sao printf (“% f”, 0); đưa ra hành vi không xác định?


87

Tuyên bố

printf("%f\n",0.0f);

in 0.

Tuy nhiên, tuyên bố

printf("%f\n",0);

in các giá trị ngẫu nhiên.

Tôi nhận ra rằng tôi đang thể hiện một số loại hành vi không xác định, nhưng tôi không thể tìm ra lý do cụ thể.

Một giá trị dấu phẩy động trong đó tất cả các bit là 0 vẫn là giá trị floatcó giá trị bằng 0.
floatintcó cùng kích thước trên máy của tôi (nếu điều đó thậm chí có liên quan).

Tại sao việc sử dụng một ký tự số nguyên thay vì một ký tự dấu phẩy động lại printfgây ra hành vi này?

PS cùng một hành vi có thể được nhìn thấy nếu tôi sử dụng

int i = 0;
printf("%f\n", i);

37
printfđang mong đợi một double, và bạn đang cho nó một int. floatintcó thể có cùng kích thước trên máy của bạn, nhưng 0.0fthực sự được chuyển đổi thành a doublekhi được đẩy vào danh sách đối số khác nhau (và printfmong đợi điều đó). Nói tóm lại, bạn đang không thực hiện được thỏa thuận cuối cùng printfdựa trên các thông số kỹ thuật mà bạn đang sử dụng và các lập luận bạn đang cung cấp.
WhozCraig,

22
Varargs-functions không tự động chuyển đổi các đối số của hàm thành kiểu của tham số tương ứng, vì chúng không thể. Thông tin cần thiết không có sẵn cho trình biên dịch, không giống như các hàm không phải varargs với một nguyên mẫu.
EOF

3
Oooh ... "biến thể." Tôi chỉ học được một từ mới ...
Mike Robinson


3
Điều tiếp theo cần thử là chuyển một (uint64_t)0thay vì 0và xem liệu bạn có còn nhận được hành vi ngẫu nhiên hay không (giả sử doubleuint64_tcó cùng kích thước và căn chỉnh). Rất có thể đầu ra sẽ vẫn là ngẫu nhiên trên một số nền tảng (ví dụ: x86_64) do các kiểu khác nhau được chuyển vào các thanh ghi khác nhau.
Ian Abbott,

Câu trả lời:


121

Các "%f"định dạng đòi hỏi một đối số kiểu double. Bạn đang đưa ra một đối số kiểu int. Đó là lý do tại sao hành vi không được xác định.

Tiêu chuẩn không đảm bảo rằng tất cả các bit-0 là đại diện hợp lệ của 0.0(mặc dù nó thường là như vậy), hoặc của bất kỳ doublegiá trị nào , hoặc giá trị đó intdoublecó cùng kích thước (hãy nhớ là doublekhông float), hoặc ngay cả khi chúng giống nhau kích thước, chúng được truyền dưới dạng đối số cho một hàm khác nhau theo cách tương tự.

Nó có thể xảy ra "hoạt động" trên hệ thống của bạn. Đó là triệu chứng tồi tệ nhất có thể xảy ra của hành vi không xác định, vì nó gây khó khăn cho việc chẩn đoán lỗi.

N1570 7.21.6.1 đoạn 9:

... Nếu bất kỳ đối số nào không phải là loại chính xác cho đặc tả chuyển đổi tương ứng, thì hành vi đó là không xác định.

Đối số thuộc loại floatđược quảng bá double, đó là lý do tại sao printf("%f\n",0.0f)hoạt động. Đối số của kiểu số nguyên hẹp hơn đối số intđược thăng cấp tới inthoặc tới unsigned int. Các quy tắc khuyến mãi này (được nêu rõ bởi N1570 6.5.2.2 đoạn 6) không giúp ích gì trong trường hợp printf("%f\n", 0).

Lưu ý rằng nếu bạn truyền một hằng số 0cho một hàm không phải thay đổi cần một doubleđối số, thì hành vi được xác định rõ ràng, giả sử rằng nguyên mẫu của hàm được hiển thị. Ví dụ, sqrt(0)(sau #include <math.h>) chuyển đổi ngầm đối số 0từ intthành double- bởi vì trình biên dịch có thể thấy từ khai báo sqrtrằng nó mong đợi một doubleđối số. Nó không có thông tin như vậy cho printf. Các hàm đa dạng như printflà đặc biệt và cần phải cẩn thận hơn khi viết các lệnh gọi đến chúng.


13
Một vài điểm cốt lõi tuyệt vời ở đây. Đầu tiên, nó doublekhông phải là floatvì vậy giả định chiều rộng của OP có thể không (có thể là không) giữ. Thứ hai, giả định rằng số nguyên 0 và số 0 dấu phẩy động có cùng một mẫu bit cũng không phù hợp. Tốt công việc
Lightness Races ở Orbit

2
@LucasTrzesniewski: Được, nhưng tôi không hiểu câu trả lời của mình như thế nào. Tôi đã nói điều đó floatđược thăng cấp doublemà không giải thích tại sao, nhưng đó không phải là điểm chính.
Keith Thompson,

2
@ robertbristow-johnson: Các trình biên dịch không cần phải có các móc đặc biệt printf, mặc dù gcc chẳng hạn, có một số để nó có thể chẩn đoán lỗi ( nếu chuỗi định dạng là một ký tự). Trình biên dịch có thể thấy phần khai báo printffrom <stdio.h>, nó cho biết rằng tham số đầu tiên là a const char*và phần còn lại được chỉ định bởi , .... Không, %flà dành cho double(và floatđược thăng cấp lên double), và %lflà dành cho long double. Tiêu chuẩn C không nói gì về ngăn xếp. Nó chỉ định hành vi của printfchỉ khi nó được gọi chính xác.
Keith Thompson

2
@ robertbristow-johnson: Trong sự ngạc nhiên cũ, "lint" thường thực hiện một số kiểm tra bổ sung mà gcc hiện đang thực hiện. A floatđược chuyển đến printfđược thăng cấp lên double; không có gì kỳ diệu về điều đó, nó chỉ là một quy tắc ngôn ngữ để gọi các hàm khác nhau. printfbản thân nó biết thông qua chuỗi định dạng mà người gọi yêu cầu chuyển tới nó; nếu tuyên bố đó không chính xác, hành vi đó là không xác định.
Keith Thompson,

2
Sửa chữa nhỏ: lchiều dài modifier "không ảnh hưởng đến một sau a, A, e, E, f, F, g, hoặc Gxác định chuyển đổi", các sửa đổi chiều dài cho một long doublechuyển đổi L. (@ robertbristow-johnson cũng có thể quan tâm)
Daniel Fischer

58

Off đầu tiên, như đề cập đến trong một số câu trả lời khác nhưng không, để tâm trí của tôi, nêu ra rõ ràng đủ: Nó không làm việc để cung cấp một số nguyên trong hầu hết các tình huống nơi một chức năng thư viện mất một doublehoặc floattranh cãi. Trình biên dịch sẽ tự động chèn một chuyển đổi. Ví dụ, sqrt(0)được định nghĩa rõ ràng và sẽ hoạt động chính xác sqrt((double)0), và điều này cũng đúng với bất kỳ biểu thức kiểu số nguyên nào khác được sử dụng ở đó.

printfkhác. Nó khác vì nó có một số lượng đối số thay đổi. Nguyên mẫu chức năng của nó là

extern int printf(const char *fmt, ...);

Do đó, khi bạn viết

printf(message, 0);

trình biên dịch không có bất kỳ thông tin nào về kiểu printf mong đợi đối số thứ hai đó là. Nó chỉ có kiểu của biểu thức đối số, nghĩa là intsẽ chạy qua. Do đó, không giống như hầu hết các hàm thư viện, lập trình viên phụ thuộc vào bạn để đảm bảo danh sách đối số phù hợp với mong đợi của chuỗi định dạng.

(Các trình biên dịch hiện đại có thể xem xét một chuỗi định dạng và cho bạn biết rằng bạn có một loại không khớp, nhưng họ sẽ không bắt đầu chèn các chuyển đổi để hoàn thành ý bạn, vì tốt hơn là mã của bạn nên ngắt ngay bây giờ, khi bạn nhận thấy , hơn nhiều năm sau khi được xây dựng lại bằng một trình biên dịch ít hữu ích hơn.)

Bây giờ, nửa còn lại của câu hỏi là: Cho rằng (int) 0 và (float) 0.0, trên hầu hết các hệ thống hiện đại, cả hai đều được biểu diễn dưới dạng 32 bit, tất cả đều bằng 0, tại sao nó không hoạt động một cách tình cờ? Tiêu chuẩn C chỉ nói rằng "điều này không bắt buộc phải hoạt động, bạn tự làm", nhưng hãy để tôi giải thích hai lý do phổ biến nhất khiến nó không hoạt động; điều đó có thể sẽ giúp bạn hiểu tại sao nó không bắt buộc.

Thứ nhất, vì những lý do lịch sử, khi bạn vượt qua một floatthông qua một danh sách đối số biến nó được khuyến khích để double, trong đó, trên hầu hết các hệ thống hiện đại, là 64 bit rộng. Vì vậy, printf("%f", 0)chỉ chuyển 32 bit 0 cho một bộ nhớ mong đợi 64 bit trong số đó.

Lý do thứ hai, quan trọng không kém là các đối số của hàm dấu phẩy động có thể được chuyển vào một nơi khác với các đối số nguyên. Ví dụ: hầu hết các CPU đều có tệp thanh ghi riêng biệt cho số nguyên và giá trị dấu phẩy động, vì vậy có thể là một quy tắc mà các đối số từ 0 đến 4 đi trong các thanh ghi r0 đến r4 nếu chúng là số nguyên, nhưng f0 đến f4 nếu chúng là dấu phẩy động. Vì vậy, hãy printf("%f", 0)tìm trong thanh ghi f1 cho số 0 đó, nhưng nó hoàn toàn không có ở đó.


1
Có bất kỳ kiến ​​trúc nào sử dụng thanh ghi cho các chức năng khác nhau, ngay cả trong số những kiến ​​trúc sử dụng chúng cho các chức năng bình thường? Tôi nghĩ đó là lý do mà các hàm khác nhau được yêu cầu phải được khai báo đúng cách mặc dù các hàm khác [ngoại trừ những hàm có đối số float / short / char] có thể được khai báo bằng ().
Ngẫu nhiên832

3
@ Random832 Ngày nay, sự khác biệt duy nhất giữa quy ước gọi hàm variadic và hàm thông thường là có thể có một số dữ liệu bổ sung được cung cấp cho một variadic, chẳng hạn như số lượng đối số thực được cung cấp. Nếu không, mọi thứ sẽ diễn ra chính xác như một chức năng bình thường. Ví dụ: xem phần 3.2 của x86-64.org/documentation/abi.pdf , trong đó phương pháp điều trị đặc biệt duy nhất dành cho các bệnh dị dạng là một gợi ý được đưa vào AL. (Vâng, phương tiện này thực hiện va_argrất phức tạp hơn nhiều so với trước kia nữa.)
Zwol

@ Random832: Tôi luôn nghĩ lý do là trên một số kiến ​​trúc, các hàm với số lượng và kiểu đối số đã biết có thể được triển khai hiệu quả hơn bằng cách sử dụng các lệnh đặc biệt.
celtschk,

@celtschk Có thể bạn đang nghĩ đến "cửa sổ đăng ký" trên SPARC và IA64, được cho là để đẩy nhanh trường hợp phổ biến của lệnh gọi hàm với một số lượng nhỏ đối số (than ôi, trong thực tế, chúng làm ngược lại). Chúng không yêu cầu trình biên dịch đặc biệt xử lý các lệnh gọi hàm đa dạng, bởi vì số lượng đối số tại bất kỳ một trang web lệnh gọi nào luôn là một hằng số thời gian biên dịch, bất kể callee có phải là hàm đa dạng hay không.
zwol

@zwol: Không, tôi đang nghĩ đến ret nlệnh của 8086, đây nlà một số nguyên được mã hóa cứng, do đó không thể áp dụng cho các hàm khác nhau. Tuy nhiên, tôi không biết liệu có trình biên dịch C nào thực sự tận dụng nó không (trình biên dịch không phải C chắc chắn đã làm).
celtschk

13

Thông thường khi bạn gọi một hàm mong đợi a double, nhưng bạn cung cấp một int, trình biên dịch sẽ tự động chuyển đổi thành a doublecho bạn. Điều đó không xảy ra với printf, bởi vì các loại đối số không được chỉ định trong nguyên mẫu hàm - trình biên dịch không biết rằng một chuyển đổi nên được áp dụng.


4
Ngoài ra, printf() đặc biệt được thiết kế để các đối số của nó có thể thuộc bất kỳ kiểu nào. Bạn phải biết loại nào được mong đợi bởi mỗi phần tử trong chuỗi định dạng và bạn phải cung cấp nó một cách chính xác.
Mike Robinson,

@MikeRobinson: Chà, bất kỳ loại C nguyên thủy nào. Đó là một tập hợp con rất, rất nhỏ của tất cả các loại có thể có.
MSalters

13

Tại sao việc sử dụng một ký tự số nguyên thay vì ký tự float lại gây ra hiện tượng này?

Bởi vì printf()không có tham số đã nhập ngoài tham số const char* formatstringđầu tiên. Nó sử dụng dấu chấm lửng kiểu c ( ...) cho tất cả phần còn lại.

Nó chỉ quyết định cách diễn giải các giá trị được truyền vào đó theo các kiểu định dạng được đưa ra trong chuỗi định dạng.

Bạn sẽ có cùng một loại hành vi không xác định như khi cố gắng

 int i = 0;
 const double* pf = (const double*)(&i);
 printf("%f\n",*pf); // dereferencing the pointer is UB

3
Một số triển khai cụ thể của printfcó thể hoạt động theo cách đó (ngoại trừ các mục được truyền là giá trị, không phải địa chỉ). Tiêu chuẩn C không chỉ định cách thức printf và các hàm khác nhau hoạt động, nó chỉ xác định hành vi của chúng. Đặc biệt không đề cập đến stack frame.
Keith Thompson

Một phân biệt nhỏ: printfmột tham số đã nhập, chuỗi định dạng, thuộc loại const char*. BTW, câu hỏi được gắn thẻ cả C và C ++, và C thực sự phù hợp hơn; Tôi có lẽ sẽ không được sử dụng reinterpret_castlàm ví dụ.
Keith Thompson,

Chỉ là một quan sát thú vị: Cùng một hành vi không xác định và rất có thể do cơ chế giống hệt nhau, nhưng có sự khác biệt nhỏ về chi tiết: Chuyển một int như trong câu hỏi, UB xảy ra trong printf khi cố gắng diễn giải int là double - trong ví dụ của bạn , nó xảy ra đã ngoài khi dereferencing pf ...
Aconcagua

@Aconcagua Đã làm rõ thêm.
πάντα ῥεῖ

Mẫu mã này là UB vì vi phạm bí danh nghiêm ngặt, một vấn đề hoàn toàn khác với những gì câu hỏi đang hỏi. Ví dụ, bạn hoàn toàn bỏ qua khả năng các số nổi được chuyển trong các thanh ghi khác nhau thành các số nguyên.
MM

12

Sử dụng một mis-phù hợp printf()specifier "%f"và gõ (int) 0dẫn đến hành vi không xác định.

Nếu đặc điểm kỹ thuật chuyển đổi không hợp lệ, hành vi đó không được xác định. C11dr §7.21.6.1 9

Nguyên nhân ứng viên của UB.

  1. Nó là UB cho mỗi thông số kỹ thuật và biên dịch là Ornery - 'nuf nói.

  2. doubleintcó kích thước khác nhau.

  3. doubleintcó thể chuyển các giá trị của chúng bằng cách sử dụng các ngăn xếp khác nhau (ngăn xếp chung so với ngăn xếp FPU .)

  4. A double 0.0 có thể không được xác định bởi một mẫu bit 0. (quý hiếm)


10

Đây là một trong những cơ hội tuyệt vời để học hỏi từ các cảnh báo trình biên dịch của bạn.

$ gcc -Wall -Wextra -pedantic fnord.c 
fnord.c: In function ‘main’:
fnord.c:8:2: warning: format ‘%f’ expects argument of type ‘double’, but argument 2 has type ‘int’ [-Wformat=]
  printf("%f\n",0);
  ^

hoặc là

$ clang -Weverything -pedantic fnord.c 
fnord.c:8:16: warning: format specifies type 'double' but the argument has type 'int' [-Wformat]
        printf("%f\n",0);
                ~~    ^
                %d
1 warning generated.

Vì vậy, printfđang tạo ra hành vi không xác định bởi vì bạn đang truyền cho nó một loại đối số không tương thích.


9

Tôi không chắc điều gì khó hiểu.

Chuỗi định dạng của bạn mong đợi một double; thay vào đó bạn cung cấp một int.

Việc hai loại có cùng độ rộng bit hoàn toàn không liên quan, ngoại trừ việc nó có thể giúp bạn tránh nhận được các ngoại lệ vi phạm bộ nhớ cứng từ mã bị hỏng như thế này.


3
@Voo: Rất tiếc, công cụ sửa đổi chuỗi định dạng đó lại được đặt tên, nhưng tôi vẫn không hiểu tại sao bạn lại nghĩ rằng một intsẽ được chấp nhận ở đây.
Các cuộc đua ánh sáng trong quỹ đạo vào

1
@Voo: "(cũng đủ điều kiện là một mẫu float hợp lệ)" Tại sao một intmẫu float hợp lệ lại đủ điều kiện? Phần bổ sung của hai và các mã hóa dấu phẩy động khác nhau hầu như không có điểm chung.
Các cuộc đua ánh sáng trong quỹ đạo vào

2
Thật khó hiểu bởi vì, đối với hầu hết các hàm thư viện, việc cung cấp ký tự số nguyên 0cho một đối số được nhập doublesẽ thực hiện Điều đúng. Đối với người mới bắt đầu, trình biên dịch không thực hiện chuyển đổi tương tự cho các vùng printfđối số được giải quyết bằng %[efg].
zwol

1
@Voo: Nếu bạn quan tâm đến việc điều này có thể xảy ra sai lầm khủng khiếp như thế nào, hãy xem xét rằng trên x86-64 SysV ABI, các đối số dấu phẩy động được truyền trong một tập đăng ký khác với các đối số nguyên.
EOF

1
@LightnessRacesinOrbit Tôi nghĩ luôn luôn thích hợp để thảo luận tại sao một cái gì đó lại là UB, điều này thường liên quan đến việc nói về vĩ độ triển khai được phép và điều gì thực sự xảy ra trong các trường hợp phổ biến.
zwol

4

"%f\n"đảm bảo kết quả có thể dự đoán chỉ khi printf()tham số thứ hai có kiểu double. Tiếp theo, một đối số bổ sung của các hàm khác nhau là đối tượng của quảng cáo đối số mặc định. Các đối số số nguyên nằm dưới sự thăng hạng số nguyên, điều này không bao giờ dẫn đến các giá trị được nhập dấu phẩy động. Và floatcác tham số được thăng hạng lên double.

Đầu tiên: tiêu chuẩn cho phép đối số thứ hai là hoặc floathoặc doublevà không có gì khác.


4

Tại sao nó chính thức là UB bây giờ đã được thảo luận trong một số câu trả lời.

Lý do tại sao bạn nhận được cụ thể hành vi này là phụ thuộc vào nền tảng, nhưng có thể là sau:

  • printfmong đợi các đối số của nó theo truyền vararg tiêu chuẩn. Điều đó có nghĩa là một floatdi chúc là a doublevà bất cứ thứ gì nhỏ hơn một intdi chúc sẽ là một int.
  • Bạn đang chuyển một intnơi mà hàm mong đợi a double. Của bạn intcó thể là 32 bit, double64 bit của bạn . Điều đó có nghĩa là bốn byte ngăn xếp bắt đầu từ vị trí mà đối số được cho là nằm 0, nhưng bốn byte sau có nội dung tùy ý. Đó là những gì được sử dụng để xây dựng giá trị được hiển thị.

0

Nguyên nhân chính của vấn đề "giá trị không xác định" này nằm ở việc đúc con trỏ tại intgiá trị được chuyển đến printfphần tham số biến cho một con trỏ tại doubleloại mà va_argmacro thực hiện.

Điều này gây ra tham chiếu đến một vùng bộ nhớ không được khởi tạo hoàn toàn với giá trị được truyền dưới dạng tham số cho printf, vì doublekích thước vùng đệm bộ nhớ lớn hơn intkích thước.

Do đó, khi con trỏ này được tham chiếu đến, nó sẽ được trả về một giá trị chưa được xác định hoặc tốt hơn là "giá trị" chứa một phần giá trị được truyền dưới dạng tham số printfvà phần còn lại có thể đến từ một vùng đệm ngăn xếp khác hoặc thậm chí là một vùng mã ( tăng ngoại lệ lỗi bộ nhớ), tràn bộ đệm thực .


Nó có thể xem xét các phần cụ thể này của triển khai mã đầy đủ của "printf" và "va_arg" ...

printf

va_list arg;
....
case('%f')
      va_arg ( arg, double ); //va_arg is a macro, and so you can pass it the "type" that will be used for casting the int pointer argument of printf..
.... 


việc triển khai thực trong vprintf (xem xét gnu impl.) của quản lý mã trường hợp tham số giá trị kép là:

if (__ldbl_is_dbl)
{
   args_value[cnt].pa_double = va_arg (ap_save, double);
   ...
}



va_arg

char *p = (double *) &arg + sizeof arg;  //printf parameters area pointer

double i2 = *((double *)p); //casting to double because va_arg(arg, double)
   p += sizeof (double);



người giới thiệu

  1. dự án gnu triển khai glibc của "printf" (vprintf))
  2. ví dụ về mã semplification của printf
  3. ví dụ về mã semplification của va_arg
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.