Motorola 68000:栈传参过滤字符串并返回长度
结论
任务理解无误:按照原顺序遍历 A。如果字符在 B 中出现过,就跳过,否则写入 C。A 中的重复字符不会被去重,只要该字符不在 B 中,每次出现都会保留。
原始的嵌套扫描版本逻辑正确。位图版本在给定条件下也可以正常工作,栈偏移和位索引的计算都没有问题。它把最坏情况下的复杂度从 O(|A| × |B|) 降到了 O(|A| + |B|),需要额外使用 32 字节临时空间。
代码还有几处可以调整:
- 将部分
CLR.W Dn改为MOVEQ #0,Dn,以免寄存器的高 16 位残留旧值。 - 使用
LINK/UNLK建立栈帧,这样局部空间发生变化时,参数偏移仍保持不变。 - 明确要求 C 的容量至少为
strlen(A) + 1,否则可能发生越界。 - 只有调用约定允许子程序破坏相关寄存器时,才可以不保存寄存器。
CMPM.B每次仍只比较一个字节,不属于并行比较。
示例字符串的结果应为:
C = "He"
length = 2
原始版本是否正确
原始版本实现的是稳定过滤:
C = [A 中所有不属于字符集合 B 的字节]
这不是数学意义上的集合差,因为 A 中的重复字符不会被删除。例如:
A = "banana"
B = "bn"
C = "aaa"
调用前依次压入 &C、&B、&A,随后 BSR 压入返回地址。子程序入口处的栈布局如下:
0(A7) return address
4(A7) &A
8(A7) &B
12(A7) &C
因此,原始代码中的参数偏移是正确的。
MOVE.B 本身会更新条件码,可以删掉后面的 TST.B:
MOVE.B (A0)+,D1
BEQ.S Finish
扫描 B 时同样可以这样写:
MOVE.B (A3)+,D2
BEQ.S NotFound
这是精简原始版本最直接的方式。
位图版本的正确性
256 个可能的字节值需要 256 bit,即 32 bytes。对于字符值 ch:
byte offset = ch >> 3
bit number = ch & 7
代码只显式计算了 ch >> 3。这样写没有问题,因为 68000 对内存操作数执行动态 BTST、BSET 时,只会使用位编号的低 3 位,相当于自动执行 ch & 7:
MOVE.W D1,D2
LSR.W #3,D2
BSET D1,(A7,D2.W)
分配 32 字节位图后,栈布局变为:
0(A7)..31(A7) bitmap
32(A7) return address
36(A7) &A
40(A7) &B
44(A7) &C
所以编辑版中的 36(A7)、40(A7) 和 44(A7) 也是正确的。
直接使用不断变化的 A7 作为局部数据基址,维护起来比较麻烦。以后只要增加一次寄存器保存操作,所有参数偏移都要重新计算。使用 A6 建立栈帧会更清楚。
建议的位图实现
下面的代码保留原有调用方式,并使用 A6 固定参数位置。该版本返回 16 位长度,因此 C 的最大有效长度不能超过 65535。
; Caller:
; PEA.L StringC
; PEA.L StringB
; PEA.L StringA
; BSR FilterLen_Bitmap
; ADDA.L #12,A7
;
; Entry stack layout:
; 4(A7) = &A
; 8(A7) = &B
; 12(A7) = &C
;
; OUT:
; D0.W = length of C
;
; MOD:
; D0-D2, A0-A2
;
; PRECONDITIONS:
; A and B are zero-terminated.
; C has room for strlen(A) + 1 bytes.
FilterLen_Bitmap:
LINK A6,#-32 ; 32-byte local bitmap
; After LINK:
; 8(A6) = &A
; 12(A6) = &B
; 16(A6) = &C
; -32(A6)..-1(A6) = bitmap
; Clear bitmap
LEA -32(A6),A2
MOVEQ #7,D2
ClearBitmap:
CLR.L (A2)+
DBF D2,ClearBitmap
; Build bitmap from B
LEA -32(A6),A2
MOVEA.L 12(A6),A0
MOVEQ #0,D1
BuildBitmap:
MOVE.B (A0)+,D1
BEQ.S StartFilter
MOVE.W D1,D2
LSR.W #3,D2
BSET D1,0(A2,D2.W)
BRA.S BuildBitmap
; Filter A into C
StartFilter:
MOVEA.L 8(A6),A0
MOVEA.L 16(A6),A1
MOVEQ #0,D0 ; result length
MOVEQ #0,D1
NextA:
MOVE.B (A0)+,D1
BEQ.S FilterDone
MOVE.W D1,D2
LSR.W #3,D2
BTST D1,0(A2,D2.W)
BNE.S NextA ; character occurs in B
MOVE.B D1,(A1)+
ADDQ.W #1,D0
BRA.S NextA
FilterDone:
CLR.B (A1)
UNLK A6
RTS
MOVEQ #0,D0 会清除整个寄存器。原代码中的 CLR.W D0 只清除低 16 位。调用者只读取 D0.W 时,这不会影响结果;如果把 D0 当作 32 位返回值使用,其高 16 位可能仍是旧数据。
编辑版中的 CLR.W D1 同样不会清除高 16 位。当前代码只使用 D1 的低字节作为字符值,并采用 .W 索引,所以暂时不会出错,但完整清零更稳妥。
是否需要容量检查
接口没有传入 C 的容量时,子程序无法判断下一次写入是否会越界。可以把“caller guarantees size”作为底层接口约定,但应明确写出:
capacity(C) >= strlen(A) + 1
其中的 +1 用于存放字符串终止符。
如果输入来自不可信来源,可以将接口改为:
FilterLen_Bitmap(&A, &B, &C, capacity)
每次写入字符前检查:
length < capacity - 1
还应提前约定空间不足时的处理方式,例如:
- 返回负数表示空间不足;
- 通过状态码报告错误,并通过另一个输出参数返回实际长度;
- 截断 C,同时保证末尾写入
0。
当 capacity == 0 时,程序连终止符都不能写,需要单独处理。
CMPM.B 是否合适
CMPM.B 不适合这里,“字节并行比较”这一说法也不准确。CMPM.B (Ax)+,(Ay)+ 每次只比较一个字节,然后分别递增两个地址寄存器。
当前算法需要固定 A 中的一个字符,同时不断递增 B 的扫描指针。普通的 CMP.B 更符合这种访问模式:
CMP.B D2,D1
BEQ.S NextCharA
CMPM.B 更适合两个连续序列同步前进的比较,例如逐字节判断两个字符串是否相同。
Word/long 操作能否加速过滤
只保证对齐还不够。过滤时必须逐个判断字节是否属于 B,输出位置又取决于前面保留了多少字符,因此不能简单改成:
MOVE.L
CMP.L
来一次处理 4 个字符。
MOVE.L 适合用来清空 32 字节位图,因为这部分确实属于连续且无条件的内存操作。如果想一次处理 A 中的多个字节,还要加入零字节检测、逐字节位图查询和压缩写入逻辑。在普通 68000 上,这种实现复杂得多,处理短字符串时也未必更快。
是否拆分 CharInString
在嵌套扫描版本中,将 B 的扫描过程提取为子程序,可以让结构更清楚,但每处理一个 A 字符就要调用一次,还可能反复压栈和恢复寄存器。字符串较短,或者作业强调子程序设计时,这种写法可以接受。追求速度时,更适合把内部循环留在过滤子程序中。
位图版本已经将 membership test 缩减为一次 BTST,继续拆分成子程序的收益不大。
还需明确的调用约定
代码会修改 D0-D2 和 A0-A2。是否需要保存这些寄存器取决于程序采用的 calling convention,而不是处理器本身。
如果调用约定要求子程序保存其中某些寄存器,应在建立栈帧后保存,并在返回前恢复,例如:
MOVEM.L D1-D2/A0-A2,-(A7)
...
MOVEM.L (A7)+,D1-D2/A0-A2
D0 用于存放返回值,通常不能在返回前恢复。哪些寄存器属于 caller-saved,哪些属于 callee-saved,应在接口注释中注明。
另外,只有目标位于短分支范围内时才能使用 BSR.S。如果无法确定距离,写成 BSR FilterLen_Bitmap,由汇编器选择合适的编码会更稳妥。
备注:内容仅供参考。
