19.10 浮点环境
浮点环境是与浮点异常和浮点舍入方向相关的状态的统称。
1. #pragma STDC FENV_ACCESS 实参
实参 为 ON 和 OFF 和 DEFAULT 三者之一。注意大写。
当 #pragma STDC FENV_ACCESS ON 的时候,告诉编译器,程序会访问或修改浮点环境,因此某些由编译器进行的可能修改浮点环境的优化会被禁用。
2. 浮点异常
有以下五种浮点异常:
2.1 定义域错误 FE_INVALID
当某个在数学上未定义的运算发生时,产生这个异常。比如:
0.0 / 0.0log(-1.0)(对负数取对数)或sqrt(-1.0)或pow(-1.0, 0.5)(给负数开方)等;asin(2)或acos(2)(众所周知,反正弦函数和反余弦函数的定义域是 )或acosh(0.5)(众所周知,反双曲余弦函数的定义域是 )等;fmod(2, 0.0)(取余运算的第二个操作数为 0)等;INFINITY / INFINITY或INFINITY * 0.0- 任何涉及信号 NaN (Signaling NaN, sNaN) 的操作;
- 某些涉及静默 NaN (Quiet NaN, qNaN) 的操作(如
NAN > 1.0)等;
示例:
#include <stdio.h>
#include <fenv.h>
#include <math.h>
#pragma STDC FENV_ACCESS ON
int main(void) {
double result;
feclearexcept(FE_ALL_EXCEPT);
result = sqrt(-1.0);
result = 0.0 / 0.0;
result = log(-1.0);
result = asin(2);
result = pow(-1, 0.5);
result = INFINITY / INFINITY;
result = INFINITY * 0.0;
if (fetestexcept(FE_INVALID)) {
printf("FE_INVALID (Invalid operation) flag is set.\n");
} else {
printf("FE_INVALID flag is NOT set.\n");
}
printf("Result: %f\n", result);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
在支持 IEC 60559 浮点异常并遵守 FENV_ACCESS 的常见实现中,输出示例为:
FE_INVALID (Invalid operation) flag is set. Result: nan
NaN 的具体拼写和符号由实现决定,例如也可能显示为 -nan。 只保留一条对 result 的赋值操作并运行代码,如果打印出 FE_INVALID (Invalid operation) flag is set.,则产生了 FE_INVALID 异常。
2.2 向上溢出 FE_OVERFLOW
当运算结果的绝对值大于目标浮点类型可表示的最大有限值时,会产生这个异常。常见表现是结果变为 HUGE_VAL、INFINITY 或其负值。
2.3 向下溢出 FE_UNDERFLOW
当非零结果在目标浮点类型中太小,无法以正规化形式表示时,会产生这个异常。结果可能变成次正规数,或在更极端时舍入为零。
2.4 除以零 FE_DIVBYZERO
有限非零数除以零会触发该异常。典型结果是带符号无穷大,例如 1.0 / 0.0 得到 +INFINITY。
2.5 精度损失 FE_INEXACT
当结果不能被目标浮点格式精确表示,必须舍入时,会触发该异常。这是最常见的浮点异常之一。
3. 浮点异常的相关操作
<fenv.h> 提供了标准化的异常标志操作接口,常用函数如下:
int feclearexcept(int excepts);
int fetestexcept(int excepts);
int feraiseexcept(int excepts);
int fegetexceptflag(fexcept_t *p, int excepts);
int fesetexceptflag(const fexcept_t *p, int excepts);2
3
4
5
推荐流程通常是:先清标志、再执行计算、最后检查标志。这样能避免历史异常状态干扰当前判断。
#include <fenv.h>
#include <math.h>
#include <stdio.h>
#pragma STDC FENV_ACCESS ON
int main(void) {
feclearexcept(FE_ALL_EXCEPT);
volatile double x = 1.0e308;
volatile double y = x * x;
(void)y;
if (fetestexcept(FE_OVERFLOW)) {
puts("FE_OVERFLOW detected");
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在支持该浮点异常的常见实现中,输出为:
FE_OVERFLOW detected
4. 浮点舍入方向
浮点数在计算机中是用有限的位数来表示的,不能精确地表示所有实数。当一个真实的数值或计算结果落在两个可表示的浮点数之间时,就必须选择其中一个来近似它,这个过程称为舍入。
关于可表示的浮点数,参见 IEEE 754。
有以下四种浮点舍入方向:
4.1 向零舍入 FE_TOWARDZERO
结果向 0 的方向截断。对正数相当于向下取整,对负数相当于向上取整(靠近 0)。
4.2 向正无穷舍入 FE_UPWARD
结果向 +∞ 方向舍入,得到的值不小于精确结果。区间计算用它求区间的上界。
4.3 向负无穷舍入 FE_DOWNWARD
结果向 -∞ 方向舍入,得到的值不大于精确结果。区间计算用它求区间的下界。
4.4 向最近舍入 FE_TONEAREST
结果舍入到最接近的可表示值;遇到中点时按实现规则处理(常见策略是就近到偶数)。这通常是默认模式。
4.5 浮点舍入方向的相关操作
舍入模式查询与设置函数如下:
int fegetround(void);
int fesetround(int mode);2
fesetround 成功后会影响后续浮点运算。建议把“切换模式”的作用域限制在尽可能小的代码区间,并在结束时恢复原模式。
int old = fegetround();
if (old >= 0) {
if (fesetround(FE_DOWNWARD) == 0) {
/* ... 浮点计算 ... */
(void)fesetround(old);
}
}2
3
4
5
6
7
5. 保存和恢复浮点环境
当函数需要临时修改异常状态或舍入模式时,应显式保存与恢复环境。fenv_t 表示一个完整浮点环境快照,常用接口包括:
int fegetenv(fenv_t *envp);
int fesetenv(const fenv_t *envp);
int feholdexcept(fenv_t *envp);
int feupdateenv(const fenv_t *envp);2
3
4
6. 习题
6.1 巩固
下面程序先清空异常标志,再做三次除法,最后按 FE_INVALID、FE_DIVBYZERO、FE_OVERFLOW、FE_UNDERFLOW、FE_INEXACT 的顺序打印五个标志:
#include <fenv.h>
#include <stdio.h>
#pragma STDC FENV_ACCESS ON
int main(void) {
volatile double one = 1.0;
volatile double zero = 0.0;
volatile double three = 3.0;
feclearexcept(FE_ALL_EXCEPT);
volatile double a = one / three;
volatile double b = one / zero;
volatile double c = zero / zero;
(void)a;
(void)b;
(void)c;
int f = fetestexcept(FE_ALL_EXCEPT);
printf("%d %d %d %d %d\n",
(f & FE_INVALID) != 0,
(f & FE_DIVBYZERO) != 0,
(f & FE_OVERFLOW) != 0,
(f & FE_UNDERFLOW) != 0,
(f & FE_INEXACT) != 0);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(1) [1.2] 先写出预测的五个数字,再编译运行核对。
(2) [3.3] 去掉全部 volatile(六个声明),先预测结果再编译运行,解释为什么此时异常标志可能不再被设置。再加上 -frounding-math 编译一次,看结果是否改变。
(3) [2.2] 编译时会看到 warning: ignoring '#pragma STDC FENV_ACCESS'。说明这条 pragma 想让编译器做什么,GCC 13.2 忽略它之后第 (2) 问的结果为什么会变,以及保留 volatile 为什么能避开这个问题。
验收:第 (1) 问输出 1 1 0 0 1;第 (2) 问去掉 volatile 后可能输出 0 0 0 0 0,加 -frounding-math 仍可能全 0。
写一个程序,依次把舍入方向设为 FE_TONEAREST、FE_DOWNWARD、FE_UPWARD、FE_TOWARDZERO,在每种模式下计算 2.0 / 3.0、-2.0 / 3.0、1.0 / 10.0,各用 %a 打印。
要求:
- 被除数、除数和结果都存成
volatile double; - 进入循环前用
fegetround记下当前模式,循环结束后恢复; fesetround返回值不为 0 时跳过该模式,并在输出里说明。
验收输出:
nearest 0x1.5555555555555p-1 -0x1.5555555555555p-1 0x1.999999999999ap-4
downward 0x1.5555555555555p-1 -0x1.5555555555556p-1 0x1.9999999999999p-4
upward 0x1.5555555555556p-1 -0x1.5555555555555p-1 0x1.999999999999ap-4
towardzero 0x1.5555555555555p-1 -0x1.5555555555555p-1 0x1.9999999999999p-42
3
4
回答两个问题:哪种模式与默认值对正数结果相同、对负数结果不同;1.0 / 10.0 为什么在 FE_UPWARD 下与默认值相同,在 FE_DOWNWARD 下不同。
6.2 编程
实现下面的函数,在隔离的浮点环境里计算 a / b:
/* 成功返回 0。*out 写商,*flags 写本次运算产生的异常标志。 */
int eval_isolated(double a, double b, double *out, int *flags);2
要求:进入时用 feholdexcept 保存环境并屏蔽异常,把舍入方向设为 FE_UPWARD 后计算;算完用 fetestexcept 读出本次的标志;退出前用 feupdateenv 还原调用方的环境。
用下面的驱动测试:
feclearexcept(FE_ALL_EXCEPT);
feraiseexcept(FE_INEXACT); /* 调用方已有的标志 */
fesetround(FE_DOWNWARD); /* 调用方当前的模式 */
eval_isolated(1.0, 0.0, &q, &flags);
printf("inside divbyzero=%d inexact=%d\n", ...);
printf("after divbyzero=%d inexact=%d round=%d\n", ...);2
3
4
5
6
(1) [3.3] 完成函数并运行,验收前两行为:
inside divbyzero=1 inexact=0
after divbyzero=1 inexact=1 round=10242
解释第一个 inexact=0 与第二个 inexact=1 分别来自哪里。
(2) [2.2] 把 feupdateenv 换成直接 return 0(等于不再还原环境),记录 after 行的三个值,说明调用方原本的哪两样状态被覆盖了。
(3) [3.3] 说明 feholdexcept 与 feupdateenv 为什么必须成对使用:只调用前者会留下什么状态,只在前者之后手动 fesetround 回去又漏掉了什么。
区间算术里,一个量用上下两个界表示。写两个函数:
/* 用 FE_DOWNWARD 算出的结果写 *lo,用 FE_UPWARD 算出的结果写 *hi。
* 成功返回 0,无法设置舍入模式时返回负值。 */
int add_bounds(double a, double b, double *lo, double *hi);
int mul_bounds(double a, double b, double *lo, double *hi);2
3
4
(1) [3.4] 实现两个函数。要求进入前保存当前模式,返回前恢复;参与运算的两个操作数先读到 volatile double 对象里。
验收(按 %.17g 打印):
| 调用 | lo | hi |
|---|---|---|
add_bounds(0.1, 0.2, ...) | 0.29999999999999999 | 0.30000000000000004 |
add_bounds(1.0, 2.0, ...) | 3 | 3 |
mul_bounds(1.0 / 3.0, 3.0, ...) | 0.99999999999999989 | 1 |
mul_bounds(0.1, 0.2, ...) | 0.02 | 0.020000000000000004 |
(2) [3.3] 分别从 FE_TONEAREST 和 FE_DOWNWARD 出发调用,确认返回后 fegetround() 与调用前相等。为什么即使调用方看起来不关心舍入模式,也必须恢复?
(3) [4.4] 把两个操作数改成普通局部变量(结果仍通过指针写出),跑第一组数据,记录 lo、hi 并解释与使用 volatile 时的差别。提示:先想清楚编译器对 a + b 这样的表达式能做哪些事。
6.3 综合与挑战
下面的程序里,两个函数各自临时改舍入方向,main 在调用后检查后续运算的结果:
#include <fenv.h>
#include <stdio.h>
static double share_up(double x) {
(void)fesetround(FE_UPWARD);
volatile double vx = x;
volatile double q = vx / 3.0;
return q;
}
static double overlap(double lo1, double hi1, double lo2, double hi2) {
int old = fegetround();
if (old < 0 || fesetround(FE_UPWARD) != 0) {
return 0.0;
}
double lo = lo1 > lo2 ? lo1 : lo2;
double hi = hi1 < hi2 ? hi1 : hi2;
if (hi <= lo) {
return 0.0;
}
volatile double d = hi - lo;
(void)fesetround(old);
return d;
}
static double third(void) {
volatile double one = 1.0;
volatile double three = 3.0;
return one / three;
}
int main(void) {
printf("start round=%d third=%a\n", fegetround(), third());
double a = share_up(1.0);
printf("share_up=%a round=%d third=%a\n", a, fegetround(), third());
(void)fesetround(FE_TONEAREST);
double b = overlap(1.0, 2.0, 3.0, 4.0);
printf("overlap=%f round=%d third=%a\n", b, fegetround(), third());
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
(1) [4.3] 先预测再运行,记录三行输出。third() 的结果在调用前后发生了变化,说明变化发生在哪一次调用之后。
(2) [4.3] 指出两处泄漏各在哪一行。share_up 与 overlap 的修法为什么不同?把两处都修好,要求从任意模式进入、退出后模式不变,正常路径的结果也不变。给出修好后的三行输出。
(3) [5.4] 在 share_up 前加上 #pragma STDC FENV_ACCESS ON,编译,记录警告与输出,说明这条 pragma 在 GCC 13.2 上的实际效果。
(4) [5.4] 另写一段只做常量除法的程序:
if (fesetround(FE_UPWARD) != 0) {
return 1;
}
double q = 2.0 / 3.0;
printf("%.17g\n", q);2
3
4
5
分别编译运行,记录结果。把 2.0 和 3.0 换成 volatile 对象后再跑一次。用这几组结果说明:什么情况下编译器可以把除法在编译期算掉,volatile 在这里改变了什么。