PAYATHON 2026

MASM 使用 Irvine32 读取并解析文件时仅输出 +67

排障赵师傅

结论

问题不在文件路径,代码中至少有三处错误会破坏解析或遍历结果:

  1. ParseTempsFromString 的两个参数压栈顺序写反了。
  2. 被调过程已经通过 RET 8 和 RET 4 清理参数,调用方又执行了 ADD ESP,造成栈指针失衡。
  3. WriteTempsReverse 用 ECX 保存循环下标,却在循环中调用 WriteInt 和输出宏。调用结束后,不能假定 ECX 仍保留原值。

+67 并不是从文件中正确解析出的温度,更可能来自错误地址、损坏的数组内容或被改写的寄存器。由于源码并不完整,宏定义也被省略,无法准确追溯 67 的具体来源。不过,当前代码的调用约定和寄存器管理确实有误。

参数压栈顺序写反了

过程按下面的方式读取参数:

MOV ESI, [EBP + 8]     ; fileBuffer
MOV EDI, [EBP + 12]    ; tempArray

x86 参数按从右到左的顺序压栈。这个过程可以理解为:

ParseTempsFromString(fileBuffer, tempArray)

调用时应先压入第二个参数,再压入第一个参数:

PUSH OFFSET tempArray
PUSH OFFSET readBuffer
CALL ParseTempsFromString

原代码的顺序正好相反:

PUSH OFFSET readBuffer
PUSH OFFSET tempArray
CALL ParseTempsFromString

进入过程后,寄存器实际得到的是:

ESI = OFFSET tempArray
EDI = OFFSET readBuffer

这样一来,解析器会把初始化为零的 tempArray 当成字符串。数组的第一个字节就是 0,所以程序通常会立即跳到 ParseEnd,不会解析文件内容。

如果实际运行时仍然输出了一个数,可能是编译运行的代码与贴出的代码不完全一致,也可能还有栈、宏或寄存器方面的问题。

参数被清理了两次

ParseTempsFromString 末尾使用:

RET 8

WriteTempsReverse 末尾使用:

RET 4

这两个过程会自行移除参数,采用的是 stdcall 风格。调用方不能再执行:

ADD ESP, 8
ADD ESP, 4

否则每组参数都会被清理两次。调用代码应改为:

PUSH OFFSET tempArray
PUSH OFFSET readBuffer
CALL ParseTempsFromString       ; RET 8 已清理参数

PUSH OFFSET tempArray
CALL WriteTempsReverse          ; RET 4 已清理参数

调用约定要保持一致,可以选择以下任意一种:

  • 被调过程使用 RET n,调用方不调整 ESP。
  • 被调过程使用普通 RET,调用方通过 ADD ESP, n 清理参数。

两种方式不能混用。

ECX 不适合跨库调用保存循环下标

WriteTempsReverse 使用 ECX 保存当前数组下标:

MOV ECX, [numTempsParsed]
DEC ECX

循环中又调用了:

CALL WriteInt
mDisplayChar DELIMITER

除非相关过程或宏明确保证会保留 ECX,否则调用结束后应认为 EAX、ECX 和 EDX 都可能发生变化。如果第一次输出后 ECX 被改成 0,接下来的 DEC ECX 会把它变成 -1,循环也就只执行一次。

可以在输出前后显式保存下标:

WriteTempsReverse PROC STDCALL
    PUSH EBP
    MOV  EBP, ESP
    PUSH ESI

    MOV  ESI, [EBP + 8]
    MOV  ECX, [numTempsParsed]

    TEST ECX, ECX
    JZ   WriteEnd

    DEC  ECX

WriteLoop:
    MOV  EAX, [ESI + ECX*4]

    ; 输出过程可能修改 ECX,因此显式保存
    PUSH ECX
    CALL WriteInt
    mDisplayChar DELIMITER
    POP  ECX

    DEC  ECX
    JNS  WriteLoop

WriteEnd:
    CALL CrLf

    POP  ESI
    POP  EBP
    RET  4
WriteTempsReverse ENDP

解析循环可以不调用 IsDigit

IsDigit 本身没有问题,但解析器同时使用多个寄存器保存数组下标、符号和累加值。在循环中调用外部过程,会增加寄存器被修改的风险。

这里直接比较字符范围更简单:

CMP AL, '0'
JB  NotDigit
CMP AL, '9'
JA  NotDigit

下面的解析过程与当前数据格式相符:

ParseTempsFromString PROC STDCALL
    PUSH EBP
    MOV  EBP, ESP
    PUSH ESI
    PUSH EDI
    PUSH EBX

    MOV  ESI, [EBP + 8]      ; readBuffer
    MOV  EDI, [EBP + 12]     ; tempArray
    XOR  ECX, ECX            ; 已解析的元素数量

ParseLoop:
SkipSeparators:
    MOV  AL, [ESI]
    CMP  AL, 0
    JE   ParseEnd

    CMP  AL, DELIMITER
    JE   SkipOne
    CMP  AL, ' '
    JE   SkipOne
    CMP  AL, 0Dh
    JE   SkipOne
    CMP  AL, 0Ah
    JE   SkipOne
    JMP  ReadSign

SkipOne:
    INC  ESI
    JMP  SkipSeparators

ReadSign:
    XOR  EDX, EDX            ; 0 = 正数,1 = 负数

    CMP  BYTE PTR [ESI], '-'
    JNE  CheckPlusSign
    MOV  EDX, 1
    INC  ESI
    JMP  RequireDigit

CheckPlusSign:
    CMP  BYTE PTR [ESI], '+'
    JNE  RequireDigit
    INC  ESI

RequireDigit:
    MOV  AL, [ESI]
    CMP  AL, '0'
    JB   InvalidCharacter
    CMP  AL, '9'
    JA   InvalidCharacter

    XOR  EBX, EBX            ; 数值累加器

ReadDigits:
    MOV  AL, [ESI]
    CMP  AL, '0'
    JB   StoreNumber
    CMP  AL, '9'
    JA   StoreNumber

    SUB  AL, '0'
    MOVZX EAX, AL
    IMUL EBX, EBX, 10
    ADD  EBX, EAX

    INC  ESI
    JMP  ReadDigits

StoreNumber:
    MOV  EAX, EBX
    TEST EDX, EDX
    JZ   SaveNumber
    NEG  EAX

SaveNumber:
    MOV  [EDI + ECX*4], EAX
    INC  ECX

    CMP  ECX, TEMPS_PER_DAY
    JB   ParseLoop
    JMP  ParseEnd

InvalidCharacter:
    ; 防止遇到异常字符时停在同一位置反复存储 0
    CMP  BYTE PTR [ESI], 0
    JE   ParseEnd
    INC  ESI
    JMP  ParseLoop

ParseEnd:
    MOV  [numTempsParsed], ECX

    POP  EBX
    POP  EDI
    POP  ESI
    POP  EBP
    RET  8
ParseTempsFromString ENDP

对应的调用方式是:

PUSH OFFSET tempArray
PUSH OFFSET readBuffer
CALL ParseTempsFromString

文件缓冲区还有一个边界问题

当前代码最多允许读取 bufferSize 个字节:

MOV ECX, bufferSize
CALL ReadFromFile

随后又在 readBuffer + bytesRead 处写入结尾的 0:

MOV BYTE PTR [EBX], 0

如果恰好读取了 1024 字节,这个 0 就会写到缓冲区之外。读取时应为字符串结束符预留一个字节:

MOV EAX, fileHandle
MOV EDX, OFFSET readBuffer
MOV ECX, bufferSize - 1
CALL ReadFromFile
JC  FileReadErrorHandler

MOV bytesRead, EAX
MOV BYTE PTR [readBuffer + EAX], 0

示例文件很短,这通常不会直接产生当前的 +67,但这个边界问题仍需修正。

修正后的关键调用代码

; 读取文件
MOV EAX, fileHandle
MOV EDX, OFFSET readBuffer
MOV ECX, bufferSize - 1
CALL ReadFromFile
JC  FileReadErrorHandler

MOV bytesRead, EAX
MOV BYTE PTR [readBuffer + EAX], 0

; ParseTempsFromString(readBuffer, tempArray)
PUSH OFFSET tempArray
PUSH OFFSET readBuffer
CALL ParseTempsFromString
; 不要 ADD ESP, 8

; 输出
mDisplayString OFFSET successMsg
CALL CrLf

PUSH OFFSET tempArray
CALL WriteTempsReverse
; 不要 ADD ESP, 4

调试顺序

修正调用约定后,可以在调试器中依次检查:

  1. ReadFromFile 返回后,EAX 应保存实际读取的字节数。
  2. readBuffer 开头应是 "-3,-2,0,3" 对应的 ASCII 字节。
  3. 进入 ParseTempsFromString 后:
    • [EBP+8] 应等于 OFFSET readBuffer。
    • [EBP+12] 应等于 OFFSET tempArray。
  4. 解析完成后,numTempsParsed 应为 24。
  5. tempArray[0] 应为 -3,tempArray[23] 应为 -1。
  6. 每次调用 WriteInt 后,确认循环下标已经通过栈或其他方式得到保护。

修正参数顺序、栈清理和 ECX 保存方式后,这组输入应按数组下标从 23 到 0 输出完整的反向序列。

备注:内容仅供参考。