PAYATHON 2026

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,可以采用以下办法:

  1. 按 av 分批计算,每次只保留一个或少量切片。示例实际访问的是 av=1。如果完整程序也能逐个处理 av,内存占用可以从约 2.24 GB 降至单个切片约 11.75 MB。
  2. 把大数组拆成多个小于 2 GiB 的数组,分别放入不同的 COMMON block。只在同一个大型 COMMON 中改名拆分,仍可能遇到整个 block 偏移过大的问题。
  3. 检查所有数据是否都必须常驻内存。可以按 zone、时间段或其他业务维度分块计算。
  4. 如果大量元素始终为零,可以改用稀疏存储,但访问逻辑也要随之调整。
  5. 如果准备使用编译器专有的动态内存接口,应先查阅对应 FTN95 版本的文档。没有明确文档支持时,不建议依赖未经验证的编译选项或扩展。

还需要检查的事项

调试构建应启用编译器提供的数组边界检查,并确认所有程序单元对 COMMON /DDMON/ 的成员顺序、类型和维度声明完全一致。在旧式 Fortran 项目中,只要一个源文件的声明不同,后续成员的地址就会全部错位。

还要确认以下事项:

  • origzn 和 destzn 始终没有超过声明上界。
  • av 位于 1:MXAV 范围内。
  • JTMPREL:JTMPREL 的下界确实为 1003。
  • 计算数组大小时使用足够宽的整数类型,避免字节数先发生 32 位整数溢出。
  • 数组分配成功后再进行初始化和访问。

根据给出的临界值和异常地址,主因是 FTN95 为超过 2 GiB 的大型静态数组生成了不适用的 32 位地址位移。改用动态分配,或者让每个静态存储对象及其访问偏移保持在 2 GiB 以下,都可以避开这个问题。

备注:内容仅供参考。