6502仿真实现ADC和SBC的正确方法

13

我一直在开发MOS 6502模拟器,但是ADC和SBC似乎无法正常工作。 我正在将AllSuiteA程序加载到模拟内存的0x4000中测试我的模拟器,在test09中,我的当前ADC和SBC实现没有获得正确的标志位。 我尝试了无数次更改它们的算法,但每次都会导致进位标志位和溢出标志位略有差异,这足以影响测试分支/不分支。

我的两个功能都基于这个。

memory [0x10000]是累加器。 它存储在内存范围之外,因此我可以拥有一个单独的寻址switch语句。

以下是我实现其中之一的代码:

case "ADC":
    var t = memory[0x10000] + memory[address] + getFlag(flag_carry);
    (memory[0x10000] & 0x80) != (t & 0x80) ? setFlag(flag_overflow) : clearFlag(flag_overflow);
    signCalc(memory[0x10000]);
    zeroCalc(t);
    
    t > 255 ? setFlag(flag_carry) : clearFlag(flag_carry);
    
    memory[0x10000] = t & 0xFF;
break;

case "SBC":
    var t = memory[0x10000] - memory[address] - (!getFlag(flag_carry));
    (t > 127 || t < -128) ? setFlag(flag_overflow) : clearFlag(flag_overflow);
    
    t >= 0 ? setFlag(flag_carry) : clearFlag(flag_carry);
    signCalc(t);
    zeroCalc(t);
    
    memory[0x10000] = t & 0xFF;
break;

我现在已经没有任何想法了,但是我在这里提供的数据也遇到了同样的问题。 所以这不仅仅是一个实施计划失败了。

对于那些对此感到困惑的人(这也让我困惑了),这里有一个解释和有效的V标志计算方法:https://rogerngo.com/article/20190920_overflow_6502/ - Johncl
4个回答

22

(在下面回答问题时,我忘记了十进制模式——NES的6502缺乏这个模式。但我还是会保留下来,因为对那些编写NES模拟器的人可能有用。)

SBC很容易实现,一旦你的模拟器有了ADC。你所需要做的就是反转参数的位并将其传递给ADC实现。要直观地理解为什么这样可以,请注意反转arg的所有位会产生-arg - 1的二补数,并思考当设置和未设置carry标志时会发生什么。

这是我模拟器中完整的SBC源代码。所有标志也将被正确设置。

static void sbc(uint8_t arg) { adc(~arg); /* -arg - 1 */ }

在实现ADC时最棘手的部分是溢出标志的计算。设置它的条件是结果具有“错误”的符号。由于范围的影响,这只能发生在两种情况下:

  1. 相加的是两个正数,结果为负数。
  2. 相加的是两个负数,结果为正数。

(1)和(2)可以简化为以下条件:

  • 相加的两个数字具有相同的符号,且结果具有不同的符号。

通过一些异或操作的技巧,可以像以下代码中的完整ADC实现一样设置overflow标志:

static void adc(uint8_t arg) {
    unsigned const sum = a + arg + carry;
    carry = sum > 0xFF;
    // The overflow flag is set when the sign of the addends is the same and
    // differs from the sign of the sum
    overflow = ~(a ^ arg) & (a ^ sum) & 0x80;
    zn = a /* (uint8_t) */ = sum;
}

(a ^ arg)如果aarg的符号不同,在符号位位置上会得到0x80。运用~对位进行取反,就能得到aarg符号相同时的0x80值。通俗易懂地说,该条件可表达为:

overflow = <'a' and 'arg' have the same sign> &  
           <the sign of 'a' and 'sum' differs> &  
           <extract sign bit>
ADC指令的实现(以及许多其他指令)也使用了一个技巧,将zeronegative标志存储在一起。

顺便说一下,我的CPU实现(来自NES模拟器)可以在这里找到。搜索“Core instruction logic”将为您提供所有指令(包括非官方指令)的简单实现。

我已经用大量的测试ROM运行过它,没有失败的情况发生(NES模拟器的优点之一就是有很多出色的测试ROM可用),我认为它在这个点上应该几乎没有错误(除了某些极其微小的问题,例如某些情况下涉及开放总线值等)。


1
我喜欢你的SBC实现,但是在你的ADC实现中,我没有看到处理十进制模式的任何内容... - Eight-Bit Guru
@Eight-BitGuru:抱歉,我忘记了。NES中的6502缺少十进制模式(可能是由于专利问题。在内部,它只是一条被切断的单条迹线)。不确定上述方法在十进制模式下是否适用。 - Ulfalizer
2
@Eight-BitGuru:它使用6502核心。 2A03还包含一些其他功能。即使是未记录的指令与独立MOS 6502完全相同(除了由于BCD的删除而导致的一些非常细微的细节)。 您可以在此芯片拍摄的右下方看到6502核心:http://www.visual6502.org/images/RP2A/Nintendo_RP2A03G_die_shot_1a_1600w.jpg - Ulfalizer
完全正确。但是,核心缺少6502的某些功能,因为十进制模式不存在。因此,您的仿真是2A03而不是6502,因此您对于关于在6502上模拟ADC / SBC的问题的回答是不正确的。 - Eight-Bit Guru
1
是的,那是个很好的观点。我想我可以添加一个免责声明来说明它忽略了十进制模式。许多编写6502/2A03模拟器的人可能是为NES模拟器而做的,所以这仍然可能有用。 - Ulfalizer
显示剩余3条评论

16

欢迎,勇敢的冒险者,来到6502“加”和“减”命令的奥秘大厅!许多人在你之前走过这些台阶,但很少有人完成了等待着你的一系列考验。勇敢面对吧!

好了,戏剧效果结束了。简而言之,ADC和SBC是6502指令中最难模拟的,主要是因为它们非常复杂和精密的逻辑。它们处理进位、溢出和十进制模式,并且实际上依赖于可以被视为“隐藏”的伪寄存器工作存储。

更糟糕的是,关于这些指令已经写了很多东西,而现有文献中很大一部分都是错误的。我在2008年解决了这个问题,花费了许多时间进行研究和筛选。下面是我重现的一些C#代码:

case 105: // ADC Immediate
_memTemp = _mem[++_PC.Contents];
_TR.Contents = _AC.Contents + _memTemp + _SR[_BIT0_SR_CARRY];
if (_SR[_BIT3_SR_DECIMAL] == 1)
{
  if (((_AC.Contents ^ _memTemp ^ _TR.Contents) & 0x10) == 0x10)
  {
    _TR.Contents += 0x06;
  }
  if ((_TR.Contents & 0xf0) > 0x90)
  {
    _TR.Contents += 0x60;
  }
}
_SR[_BIT6_SR_OVERFLOW] = ((_AC.Contents ^ _TR.Contents) & (_memTemp ^ _TR.Contents) & 0x80) == 0x80 ? 1 : 0;
_SR[_BIT0_SR_CARRY] = (_TR.Contents & 0x100) == 0x100 ? 1 : 0;
_SR[_BIT1_SR_ZERO] = _TR.Contents == 0 ? 1 : 0;
_SR[_BIT7_SR_NEGATIVE] = _TR[_BIT7_SR_NEGATIVE];
_AC.Contents = _TR.Contents & 0xff;
break;

博客文章的链接已经失效。 - Solra Bizna
2
我已经删除了损坏的链接。 - Eight-Bit Guru
1
@bartlomiej.n 它是一个临时寄存器(TR),用于保存中间值,确定SR标志设置,然后将最终结果提交给累加器。 - Eight-Bit Guru
3
我可能漏掉了什么,但我不确定C#代码片段中的十进制模式处理如何工作。导致添加0x06的条件仅涉及值的第4位(掩码0x10)。因此,例如,如果A = 0x09,立即模式操作数为0x01,并且进位被清除,则TR最初将为0x0A,(0x09 ^ 0x01 ^ 0x0A)& 0x10 == 0x10条件永远不会成立,TR将不会被更正。 - David Simmons

2
下面是我C64、VIC 20和Atari 2600模拟器的6502核心代码片段(http://www.z64k.com),其中实现了十进制模式并通过了Lorenzes、bclark和asap测试。如果您需要解释其中任何部分,请告诉我。我还有旧代码,仍然通过所有测试程序,但将指令和指令的十进制模式拆分为单独的类。如果您更喜欢解密我的旧代码,则可能更容易理解。我的所有模拟器都使用附加的代码来实现ADC、SBC和ARR(未包含ARR完整代码)指令。请保留HTML标签。
public ALU ADC=new ALU(9,1,-1);
public ALU SBC=new ALU(15,-1,0);
public ALU ARR=new ALU(5,1,-1){
    protected void setSB(){AC.ror.execute();SB=AC.value;}
    protected void fixlo(){SB=(SB&0xf0)|((SB+c0)&0x0f);}
    protected void setVC(){V.set(((AC.value^(SB>>1))&0x20)==0x20);C.set((SB&0x40)==0x40);if((P&8)==8){Dhi(hb);}}
};
public class ALU{
protected final int base,s,m,c0,c1,c2;
protected int lb,hb;
public ALU(int base,int s,int m){this.base=base;this.s=s;this.m=m;c0=6*s;c1=0x10*s;c2=c0<<4;}
public void execute(int c){// c= P&1 for ADC and ARR, c=(~P)&1 for SBC, P=status register
    lb=(AC.value&0x0f)+(((DL.value&0x0f)+c)*s);
    hb=(AC.value&0xf0)+((DL.value&0xf0)*s);
    setSB();
    if(((P&8)==8)&&(lb&0x1f)>base){fixlo();}//((P&8)==8)=Decimal mode
    N.set((SB&0x80)==0x80);
    setVC();
    AC.value=SB&0xff;
}
protected void setSB(){SB=hb+lb;Z.set((SB&0xff)==0);}
protected void fixlo(){SB=(hb+c1)|((SB+c0)&0x0f);}
protected void Dhi(int a){if((a&0x1f0)>base<<4){SB+=c2;C.set(s==1);}}
protected void setVC(){V.set(((AC.value^SB)&(AC.value^DL.value^m)&0x80)==0x80);C.set(SB>=(0x100&m));if((P&8)==8){Dhi(SB);}}

}


0
因为我对现有的“这是一些代码,你自己解决吧”的回答并不特别满意,所以回答有些晚了。
二进制模式的ADC和SBC很直接。对于ADC:
1. 计算A + 操作数 + 进位标志; 2. 根据结果设置负数和零标志; 3. 如果产生了不可能的符号结果(例如,两个正数相加得到负数,或者两个负数相加得到正数),设置溢出标志。虽然都没有逻辑意义,但由于有限的范围,这两种情况都是可能的; 4. 如果第7位有进位,设置进位标志。最简单的建议是以16位形式进行算术运算,并在最后检查第8位是否有内容。
对于SBC,只需在执行与上述相同的操作之前对操作数进行补码。
十进制模式的SBC更容易解释。
不需要解释的东西:
1. 计算二进制结果。这是SBC,所以是A + ~operand + carry。 2. 根据二进制结果设置所有标志位。
二进制半字节可以容纳的值的范围是0-15;BCD中使用的值的范围是0-9。BCD值始终为无符号。因此,你是从一个正数中减去另一个正数。
如果A中的值大于或等于被减数,你将得到所需的结果,例如0x8 - 0x2 = 0x6,就像8 - 2 = 6一样。
只有当A中的值小于被减数时,你才会遇到困难,例如0x2 - 0x8 = 0xA(限制在半字节范围内)。
这意味着只有在生成借位时,半字节才需要纠正为BCD,也就是说,当没有进位时,即A + ~operand。
所以,在普通的6502上:
3. 如果进位到第4位,从低四位减去6(或者如果你喜欢的话,加上10),不允许进一步的进位传播*; 4. 如果从第7位进位,再减去0x60,不会进一步影响进位标志。
* 因为当你在原始的8位加法中小于0时,进位已经传播了。所以你会允许相同的进位传播两次。
65C02稍微改变了一些东西,实际上,如果需要修复低四位,它会添加8位值0xfa。对于有效的BCD数字来说,这与添加0xa是相同的,因为0xf的高四位会取消你在低四位加上0xa时应该得到的额外进位。对于无效的原始BCD值来说,情况并不完全相同,因此65C02的SBC将产生不同的最终结果。
十进制模式下的ADC与之类似,但用于检测需要修正的数字的测试略有不同,并且标志位的设置时间也不同。
如果满足以下条件之一,则需要进行修正:
- 最后一个半字节不是有效的BCD值,例如0x5 + 0x5 = 0xA; - 半字节有进位,例如0x9 + 0x9 = 0x2,带进位。
单独使用任何一个测试都不足以满足要求,正如示例所示。通过将结果加上6来完成修正。
除此之外,ADC与SBC的另一个不同之处在于,在修正低半字节但修正高半字节之前,它基于中间结果设置N和V标志位。
补充说明:
65C02与原始的6502不同之处在于,在操作的最后设置NZ,以便它们反映最终的BCD结果。这会额外消耗一个周期。
为了解释已经发布在这里的其他代码: (a ^ result) & (result ^ operand) & 0x80是溢出,如本文所述;只有在结果的符号与原始累加器和操作数的符号不同时,它才为非零,这间接意味着累加器和操作数具有相同的符号。 (a ^ operand ^ result) & 0x10是对第4位进位的测试,因为它在没有进位的情况下计算了该位的原始结果:
- 如果原始输入为0和0,或者1和1,则期望输出为0; - 否则期望输出为1。
...然后检查这是否与实际的最终结果不同。只有在该位位置也有进位时,它才会不同。

我实际使用的十进制模式代码,考虑到被引用的Numeric::模板所做的事情与名称所说的一样,而且我只是存储应该被评估为N或Z的结果,假设有人希望它们作为一种可忽略的惰性评估形式:

SBC

operand_ = ~operand_;
uint8_t result = a_ + operand_ + flags_.carry;

// All flags are set based only on the decimal result.
flags_.zero_result = result;
flags_.carry = Numeric::carried_out<7>(a_, operand_, result);
flags_.negative_result = result;
flags_.overflow = (( (result ^ a_) & (result ^ operand_) ) & 0x80) >> 1;

// General SBC logic:
//
// Because the range of valid numbers starts at 0, any subtraction that should have
// caused decimal carry and which requires a digit fix up will definitely have caused
// binary carry: the subtraction will have crossed zero and gone into negative numbers.
//
// So just test for carry (well, actually borrow, which is !carry).

// The bottom nibble is adjusted if there was borrow into the top nibble;
// on a 6502 additional borrow isn't propagated but on a 65C02 it is.
// This difference affects invalid BCD numbers only — valid numbers will
// never be less than -9 so adding 10 will always generate carry.
if(!Numeric::carried_in<4>(a_, operand_, result)) {
    if constexpr (is_65c02(personality)) {
        result += 0xfa;
    } else {
        result = (result & 0xf0) | ((result + 0xfa) & 0xf);
    }
}

// The top nibble is adjusted only if there was borrow out of the whole byte.
if(!flags_.carry) {
    result += 0xa0;
}

a_ = result;

ADC:

uint8_t result = a_ + operand_ + flags_.carry;
flags_.zero_result = result;
flags_.carry = Numeric::carried_out<7>(a_, operand_, result);

// General ADC logic:
//
// Detecting decimal carry means finding occasions when two digits added together totalled
// more than 9. Within each four-bit window that means testing the digit itself and also
// testing for carry — e.g. 5 + 5 = 0xA, which is detectable only by the value of the final
// digit, but 9 + 9 = 0x18, which is detectable only by spotting the carry.

// Only a single bit of carry can flow from the bottom nibble to the top.
//
// So if that carry already happened, fix up the bottom without permitting another;
// otherwise permit the carry to happen (and check whether carry then rippled out of bit 7).
if(Numeric::carried_in<4>(a_, operand_, result)) {
    result = (result & 0xf0) | ((result + 0x06) & 0x0f);
} else if((result & 0xf) > 0x9) {
    flags_.carry |= result >= 0x100 - 0x6;
    result += 0x06;
}

// 6502 quirk: N and V are set before the full result is computed but
// after the low nibble has been corrected.
flags_.negative_result = result;
flags_.overflow = (( (result ^ a_) & (result ^ operand_) ) & 0x80) >> 1;

// i.e. fix high nibble if there was carry out of bit 7 already, or if the
// top nibble is too large (in which case there will be carry after the fix-up).
flags_.carry |= result >= 0xa0;
if(flags_.carry) {
    result += 0x60;
}

a_ = result;

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接