FTN95 64位访问冲突:地址2071E05A0异常
结论
问题的直接原因不是普通的数组越界,也不能简单解释为 64 位指针丢失高位。znzndaav 的静态尺寸超过了有符号 32 位偏移量的上限 2^31 字节。FTN95 为这个大型显式形状数组生成寻址指令时使用了 32 位位移。该位移经过符号扩展后,程序访问的地址比正确地址低了 0x100000000,即 4 GiB。
错误地址与正确地址的差值如下:
正确地址:0x00000003071E05A0
错误地址:0x00000002071E05A0
差值: 0x0000000100000000
可行的处理方式是把大数组改为 ALLOCATABLE,通过描述符在 heap 上分配。如果必须保留 Fortran 77 结构,就需要拆分数组或分批处理,使单个静态数组及其寻址偏移保持在 2 GiB 以下。
为什么在 MXAV=183 时开始出错
znzndaav 的元素数为:
MXAV × 1714 × 1714 × 1
假设默认 REAL 占 4 字节,数组占用空间为:
MXAV × 1714 × 1714 × 4
两个临界值分别是:
182 × 1714 × 1714 × 4 = 2,138,715,488 字节
183 × 1714 × 1714 × 4 = 2,150,466,672 字节
2^31 = 2,147,483,648 字节
MXAV=182 时,数组大小仍略低于 2^31;增加到 MXAV=183 后,数组大小刚好超过这个上限。问题正是在这里出现的。
异常中的指令是:
addss XMM11,[85b401b4++R14]
其中 0x85b401b4 已超过 0x7fffffff。x86-64 指令中的 32 位 displacement 通常会进行符号扩展。编译器如果直接把这个大偏移编码为有符号 32 位位移,实际有效地址就会比预期地址少 2^32,即 4 GiB。因此,0x3071E05A0 变成了 0x2071E05A0。
现有复现结果表明,这可能是 FTN95 计算超大静态对象地址时的限制或代码生成问题。在 Silverfrost 给出正式说明之前,还无法确定缺陷具体位于哪个内部模块。
这是不是 stack overflow
未必。原程序中的 COMMON /DDMON/ 通常位于静态存储区,不属于普通的过程调用栈。简化程序中的大型局部数组存放在哪里,则可能受编译器选项和具体实现影响。
更准确地说,问题包含以下几点:
- 数组跨过了 2 GiB 的静态寻址边界。
- FTN95 生成的地址计算出现了 32 位位移符号扩展问题。
- 改为动态分配后,编译器通过数组描述符和运行时基地址访问数组,从而避开大型静态偏移。
- 可分配内存仍受物理内存、虚拟地址空间和操作系统限制,使用 heap 并不意味着可以无限分配。
32 位版本能够运行,不能说明这个数组定义在 64 位版本中也没有问题。32 位和 64 位后端可能采用不同的存储布局和寻址代码,而且 32 位进程通常也无法可靠容纳超过 2 GiB 的单个数组。
推荐解决方案:使用 ALLOCATABLE
ALLOCATABLE 从 Fortran 90 开始提供,无须一次性把整个程序改写成现代 Fortran。可以先处理占用空间最大的数组。
PROGRAM ML14ERROR
INTEGER :: origzn, destzn, alloc_status
INTEGER,PARAMETER :: MXZMA = 1713, LXTZN = 1714, MXAV = 191
INTEGER,PARAMETER :: JTMPREL = 1003, av = 1
REAL(KIND=2) :: RANDOM@
REAL,ALLOCATABLE :: znzndaav(:,:,:,:)
ALLOCATE(znzndaav(1:MXAV,LXTZN,LXTZN,JTMPREL:JTMPREL), &
STAT=alloc_status)
IF (alloc_status /= 0) THEN
WRITE(6,*) 'Allocation failed, status = ', alloc_status
STOP
END IF
DO origzn=1,LXTZN
DO destzn=1,LXTZN
znzndaav(av,origzn,destzn,JTMPREL) = RANDOM@()
END DO
END DO
DO origzn=1,MXZMA
DO destzn=1,MXZMA
znzndaav(av,origzn,LXTZN,JTMPREL) = &
znzndaav(av,origzn,LXTZN,JTMPREL) + &
znzndaav(av,origzn,destzn,JTMPREL)
END DO
END DO
DEALLOCATE(znzndaav)
WRITE(6,*) 'No errors'
END PROGRAM
这里必须检查 STAT=。超过 2 GiB 的动态分配仍可能因可用内存不足而失败。如果不检查分配结果就继续访问数组,程序依然会出错。
如何改造 COMMON block
ALLOCATABLE 数组不能原样放进旧式 COMMON block。更稳妥的做法是把它放入 module:
MODULE DDMON_STORAGE
IMPLICIT NONE
REAL, ALLOCATABLE :: znzndaav(:,:,:,:)
END MODULE DDMON_STORAGE
需要使用该数组的程序单元通过 USE 访问:
PROGRAM ML14ERROR
USE DDMON_STORAGE
IMPLICIT NONE
INTEGER, PARAMETER :: MXAV = 191
INTEGER, PARAMETER :: LXTZN = 1714
INTEGER, PARAMETER :: JTMPREL = 1003
INTEGER :: alloc_status
ALLOCATE(znzndaav(1:MXAV,LXTZN,LXTZN,JTMPREL:JTMPREL), &
STAT=alloc_status)
IF (alloc_status /= 0) THEN
WRITE(6,*) 'Allocation failed, status = ', alloc_status
STOP
END IF
! 原有计算代码
DEALLOCATE(znzndaav)
END PROGRAM ML14ERROR
迁移时,所有原先声明 /DDMON/ 的程序单元都必须保持一致。不能让一部分文件继续使用旧 COMMON 布局,另一部分文件改用 module,否则可能引入更难排查的存储布局错误。
必须保留 Fortran 77 时怎么办
如果暂时无法使用 ALLOCATABLE,可以采用以下办法:
- 按
av分批计算,每次只保留一个或少量切片。示例实际访问的是av=1。如果完整程序也能逐个处理av,内存占用可以从约 2.24 GB 降至单个切片约 11.75 MB。 - 把大数组拆成多个小于 2 GiB 的数组,分别放入不同的
COMMONblock。只在同一个大型COMMON中改名拆分,仍可能遇到整个 block 偏移过大的问题。 - 检查所有数据是否都必须常驻内存。可以按 zone、时间段或其他业务维度分块计算。
- 如果大量元素始终为零,可以改用稀疏存储,但访问逻辑也要随之调整。
- 如果准备使用编译器专有的动态内存接口,应先查阅对应 FTN95 版本的文档。没有明确文档支持时,不建议依赖未经验证的编译选项或扩展。
还需要检查的事项
调试构建应启用编译器提供的数组边界检查,并确认所有程序单元对 COMMON /DDMON/ 的成员顺序、类型和维度声明完全一致。在旧式 Fortran 项目中,只要一个源文件的声明不同,后续成员的地址就会全部错位。
还要确认以下事项:
origzn和destzn始终没有超过声明上界。av位于1:MXAV范围内。JTMPREL:JTMPREL的下界确实为1003。- 计算数组大小时使用足够宽的整数类型,避免字节数先发生 32 位整数溢出。
- 数组分配成功后再进行初始化和访问。
根据给出的临界值和异常地址,主因是 FTN95 为超过 2 GiB 的大型静态数组生成了不适用的 32 位地址位移。改用动态分配,或者让每个静态存储对象及其访问偏移保持在 2 GiB 以下,都可以避开这个问题。
备注:内容仅供参考。
