程序计数器是保存当前地址还是下一条指令的地址?

9
作为一名初学者和自学者,我正在学习汇编语言,并且目前正在阅读Allen Hollub的书籍《The C Companion》第三章。我无法理解他在一个虚构的两个字节的演示机器中描述的程序计数器(PC)的说明。以下是第57页上PC的描述。
“PC始终保存当前正在执行的指令的地址。每执行一条指令时,它会自动更新以保存下一条要执行的指令的地址。 ... ... 这里的重要概念是,PC保存的是下一条指令的地址,而不是指令本身。”
我无法理解保存“当前”地址和“下一条”指令地址之间的区别。PC是否同时保存两个连续字节中的两个地址?

1
这在很大程度上取决于CPU的实现方式,有些CPU会在指令执行开始时增加对应程序计数器的内部寄存器,有些则是在结束时。然而,在大多数现代CPU中,两者都不是真的。它们没有一个单独的内部寄存器可以指向并说它是程序计数器,而只是架构状态的概念部分。请参见此答案:https://dev59.com/GK3la4cB1Zd3GeqPScxY#51942782 - Ross Ridge
1
在指令执行之前,必须首先从内存中读取它。读取它将增加指令计数器。这通常只涉及计算调用或跳转位置的偏移量,汇编程序会处理这个细节。 - Hans Passant
2
@HansPassant:这个问题与x86无关。在x86中,IP / EIP / RIP在执行当前指令时逻辑上保存下一条指令的地址。但是书的作者描述的架构并非如此。拥有一个保存当前指令地址的PC是有效的设计。对于OoO /流水线设计,这没有实际区别。对于具有单个物理PC寄存器的简单顺序设计,这意味着指令获取逻辑需要计算下一个PC,否则在执行当前指令时甚至无法获取下一条指令。 - Peter Cordes
@Peter Cordes,这个演示机器松散地基于68000和PDP11。谢谢。 - Bishnu
当我发表那个评论时,我已经写了一半的答案,而马丁则发表了他的答案。最终,我抽出时间完成了我的答案,其中包括一个扩展那个评论的部分。 - Peter Cordes
4个回答

5
他在描述一个带有两个字节的虚拟演示机器中程序计数器或PC,我无法理解。
他正在描述一个简单的CPU,以解释CPU的工作原理。
真正的CPU要复杂得多:
在许多手册(适用于任何类型的CPU)中,您会发现这样的句子:“PC寄存器被推送到堆栈上。”
通常,这意味着从call指令返回后执行的指令的地址被推送到堆栈上。
然而,这样的句子并不是100%正确的:在68k CPU的情况下(见下文),写入的是下一条指令的地址,而不是当前指令加2的地址!
对于大多数CPU来说,相对于PC的jump指令是相对于下一条指令的地址;但是也有反例(例如PowerPC VLE)。 32位x86 CPU(用于大多数台式机/笔记本电脑)
在这样的CPU上,只有call 直接读取EIP寄存器,只有跳转指令才会写入EIP。这足以使该寄存器成为CPU中的一些内部电路,如果有物理EIP寄存器的话,您也不一定知道它的内容。

(您可以将像int3int 0x80这样的int指令视为读取CS:EIP,因为它们必须推送异常帧。但更有意义的是将它们视为触发异常处理机制。

高度可能不同的x86 CPU在内部工作方式上不同,因此EIP“寄存器”的实际内容在不同的CPU中是不同的。(现代高性能实现不会只有一个EIP寄存器,但它们会执行必要的操作以保持幻象,并在需要时推送正确的返回地址。)

(PC相对跳转是相对于下一条指令的地址。)

64位x86 CPU

这些CPU具有直接使用RIP寄存器的指令,例如mov eax,[rip+symbol_offset],以进行静态数据的PC相对加载;使得共享库和ASLR的位置无关代码比32位x86更加高效。在这种情况下,“RIP”是下一条指令的地址。 68k 这些CPU也有可能直接使用PC寄存器的内容。在这种情况下,PC反映了当前指令加2的地址(我在这里不是绝对确定)。
由于这样的指令至少有4个字节长,PC寄存器的值将反映指令“中间”的一个字节的地址。 ARM 在ARM CPU上读取PC时(可以直接读取!)该值通常反映当前指令加8的地址,在某些情况下甚至加12!
(指令长度为4个字节,因此“当前指令加8”表示:向前两条指令的地址!)

2
mov eax,[rip] 载入下一条指令的4个字节。我认为你的意思是 lea rax, [rip],它只是 读取 RIP 而不是间接引用它。32位x86有 call,它将当前的IP/EIP/RIP作为返回地址推送,并且文档中有这样的说明。因此,x86具有PC=next insn。直接读取程序计数器。另请参阅为什么不能直接设置指令指针?,了解更多关于32位ARM如何将PC公开为16个通用寄存器之一的信息。 - Peter Cordes
@PeterCordes 感谢您的评论。我想写“使用RIP寄存器”的意思。我已经更正了。 - Martin Rosenau

3
这些声明可能在指令执行期间和之后讨论了两个不同的时间点。那些[...]中有什么内容?它是否谈到完成一条指令的执行并在增加PC 2个字节/1个指令字后开始获取下一条指令?否则,这本书就有错误了,因为这两个声明(在当前指令执行期间PC指向当前指令与指向下一条指令)是不兼容的

我不明白持有当前地址和下一条指令地址之间的区别

考虑内存中的这些(x86)指令,使用2字节指令来匹配您的书中的ISA(x86指令长度可变,从1到15个字节,包括可选/强制前缀字节)。
 a:  0x66 0x90     nop
 c:  0x66 0x90     nop

每个指令都有自己的地址。我用十六进制数字表示它们的起始地址(这也可以是汇编语法中的符号标签,但这是一个反汇编器输出的模板,类似于objdump -d)。"指令的地址"是其在内存中第一个字节的地址,不管执行它之前/期间/之后体系结构PC将保留什么值。
当第一个nop正在执行时,下一条指令的地址是c。"当前指令"是第一个nop,无论它执行时逻辑上的PC值是多少。
大多数指令实际上不将 PC 作为数据输入。只有相对跳转和基于 PC 的加载/存储需要它。(因此编译器/汇编器需要知道计算相对位移的规则。) MIPS 和 RISC-V 还/或者使用 aupc 指令,将寄存器或立即数加到程序计数器中,并将结果放入另一个寄存器。所以他们有一个基于 PC 的 add,来产生一个可以用作寻址模式的指针。但实际上并没有区别。只要在指令执行期间 PC 的逻辑值有一致的规则,确切的规则并不重要。
  • PC = 当前指令的起始位置(例如,MIPS在逻辑上是这样工作的,而不管实际内部实现如何)。

    MIPS相对分支是相对于PC + 4(即相对于下一条指令,因此在这个目的上它只是文档中的一个怪癖),但MIPS跳转替换PC的低28位,而不是PC+4的低28位(其高位潜在地不同)。另请参见http://www.cim.mcgill.ca/~langer/273/13-datapath1.pdf,其中介绍了MIPS指令获取/执行的逻辑操作。

  • PC = 下一条指令的起始位置(常见,例如x86)

  • PC = 两条指令后的起始位置(例如ARM)

    为什么ARM PC寄存器指向要执行的下下条指令? 简而言之:早期ARM设计中三级取指-译码-执行流水线前端的产物。(32位ARM将程序计数器公开为r15,其中之一为16个“通用”寄存器,因此您实际上可以使用or pc,r0,#4或类似方法进行跳转,以及在任何指令中读取它进行PC相对寻址)。

正如@Ross所说,只有一个简单的非流水线CPU才会有一个单一的物理程序计数器寄存器。(分支预测如何与指令指针交互)。
但是,如果任何指令引发异常(故障),通常需要将故障指令的地址或下一条指令的地址存储在某个地方。这取决于它是什么类型的异常。调试/单步异常会存储下一条指令的地址,因此从异常处理程序返回将进行步进。页面故障将存储故障指令的地址,以便默认操作是重试它。
异常处理规则将与正常PC执行规则分开,因此硬件必须记住指令长度或指令起始地址才能处理异常。它不必高效,因为中断/异常很少发生;CPU在跳转到中断处理程序之前甚至可能需要多个周期。(PC相对寻址模式和call指令的正常操作情况必须高效。)

一种基于PC=当前指令地址的简单物理实现的含义

拥有一个保存当前指令地址的PC是一种有效的设计。

对于超标量流水线设计,特别是乱序执行,这并没有什么真正的区别。流水线需要跟踪每个指令的地址(以及长度,如果可变),因为它可以在一个时钟周期内获取/解码/执行多个指令。它会获取大块数据,并从该块数据中解码出多达n条指令。例如,一些实现可能需要获取块对齐到16字节。(请参阅https://agner.org/optimize/了解各种x86微体系结构如何处理此问题,以及如何优化Pentium、Pentium Pro、Nehalem等处理器的前端获取/解码模式。幸运的是,现代x86 CPU具有解码-uop缓存,对于循环中的获取/解码问题的敏感度要小得多。)

(半相关:x86寄存器:MBR / MDR和指令寄存器现代)

对于一个简单的顺序非流水线CPU,只有一个物理PC寄存器,这意味着指令获取逻辑需要计算下一个PC,否则下一个指令甚至在执行当前指令时都无法获取。

在x86中,IP / EIP / RIP在当前指令执行时逻辑上保留了下一条指令的地址。这是有道理的,因为它起源于8086,该处理器只有约29k个晶体管。它从指令流中预取指令,而当前指令正在执行(预取到一个小的6字节缓冲区中,该缓冲区甚至不足以容纳一个带有额外前缀的完整指令,但能够容纳6个单字节指令)。但是,它甚至直到当前指令完成后才开始解码下一条指令。(即根本没有流水线化,或者如果将预取与执行分开计数,则可以认为是2级。我认为这一情况一直持续到486)

使用可变长度的ISA,指令长度要在解码时才能确定。将PC设置为当前指令的结尾可能更加重要,因为你不能像MIPS那样简单地计算PC+4,或者像你的玩具ISA那样计算PC+2。但是,如果不知道指令长度,也无法向后跳转,因此为了正确处理异常,8086必须跟踪指令的起始位置,或记住指令的长度。


1
8086和80186都是2级流水线:取指令阶段和执行阶段,两者可以在同一周期内同时运行。体系结构寄存器与物理寄存器相同,包括IP。我读过多本书都声称第一个流水线处理器是80486。我不知道他们从哪里得到了这个说法。有很多资料讨论了两级8086流水线和后续设计。我不知道有任何未流水线的微处理器。 - Hadi Brais
1
@HadiBrais:我认为人们认为预取只是显而易见和简单的,甚至不算作流水线,尽管它确实是。我的理解是,真正的根本区别在于即使是解码也要进行流水线处理,因为这样微编码的内部实现就不能以同样的方式工作,其中解码和操作数获取过程可能会使用一些与执行过程相同的加法器。 - Peter Cordes
2
是的,我也这么认为。虽然从技术上讲它是管线阶段。即使现在每本教科书和论文中都谈论代码获取阶段。如果我没记错,80286有四级流水线,因为它引入了受保护模式,并且具有更高的频率,这是有道理的。 - Hadi Brais
1
@PeterCordes 这是我没有提到的缺失行。"...要执行。在我们的演示机器中,所有指令占用正好2个字节,因此PC通常会随着每个指令增加两个。这里的重要概念是...。" 这是我也没有写的最后一行。"当执行指令时,机器首先从指定地址获取实际指令,然后执行它"。你猜对了,正如你所说的两件事不兼容,这帮助了我很多,因为我至少朝着正确的方向思考。 - Bishnu

3

这是一组真实的指令集,但这并不重要,我对这些指令如何工作不感兴趣——它将用于展示问题。

2000: 0b 12        push r11     
2002: 3b 40 21 00  mov #33, r11
2006: 3b 41        pop r11      
2008: 30 41        ret  

如前所述,当涉及到程序计数器时会有时间的概念。

可以将超级简单的处理器、旧的8位处理器和其它类似处理器看作是这种方式,而新的处理器则不同。

当我们输入此代码时,无论是怎样进入这里的,程序计数器都是0x2000。这告诉我们从哪里提取指令,我们必须提取、解码并执行它,然后重复这个过程。

这些是16位指令,即2个字节,处理器从pc指向的指令开始提取,也就是指令的地址。处理器读取0x2000地址(0x0b)处的两个字节,处理器将程序计数器递增为0x2001,并使用该值获取指令的第二个半部分,即地址0x2001(0x12),然后将程序计数器递增为0x2002。因此,在我所描述的这个虚构的处理器中,对于每次提取操作,您都需要使用程序计数器作为地址来获取数据,然后再将程序计数器递增。

before data after
0x2000 0x0b 0x2001
0x2001 0x12 0x2002

现在我们解码指令,程序计数器当前显示为0x2002,我们看到这是一个push r11的指令,因此我们继续执行。

在执行此指令期间,程序计数器保持为0x2002。寄存器r11的值被推送到堆栈中。

现在我们开始获取下一条指令。

before data after
0x2002 0x3b 0x2003
0x2003 0x40 0x2004

当处理器执行指令 (pc == 0x2004) mov #immediate,r11 时,我们需要解码该指令。由于该指令需要一个立即数,因此处理器需要再获取两个字节。

before data after
0x2004 0x21 0x2005
0x2005 0x00 0x2006

它确定现在可以通过将值0x0021写入寄存器r11来执行指令(小端序0x0021 = 33十进制)。 在执行过程中,该指令的程序计数器为0x2006。

下一步

before data after
0x2006 0x3b 0x2007
0x2007 0x41 0x2008

解码并执行一个pop r11指令。

从上面可以看出程序计数器实际上包含至少两个值。在获取指令之前,它包含指令的地址;在获取和解码指令后,在我们开始执行之前,它包含此指令后面一个字节的地址,如果这不是跳转,则为另一个指令。如果这是无条件跳转,那么该字节可能是指令、一些数据或未使用的内存。但我们说它“指向下一条指令”,在这种情况下意味着在执行之前,通常有另一个指令的地址在此指令之后。但是,接下来我们将看到pc可以被指令修改。但在执行结束时,它始终(对于这个类似于许多简单的8位处理器的简单虚构处理器)指向要执行的下一条指令。

最后

before data after
0x2008 0x30 0x2009
0x2009 0x41 0x200A

解码ret指令,这一条指令在该处理器规则下执行时会修改程序计数器。如果调用地址0x2000的指令为0x1000,并且是一个两字节指令,则在获取和解码过程中程序计数器将在地址0x1002处。执行期间,地址0x1002会根据指令集规则存储在某个位置,程序计数器将取值0x2000以调用此子例程。当我们到达ret指令并开始执行它时,程序计数器包含0x200A,但ret指令会将调用后的下一条指令地址(即在调用过程中存储的值)放在程序计数器中,因此在这条指令结束时,程序计数器将包含值0x1002,下一次获取将从该地址进行。

因此,在执行最后一条指令之前,程序计数器指向通常是不分支或跳转或调用的指令的下一条指令。也就是0x200A。但是在执行期间,程序计数器已更改,以使“下一个”指令是将我们带到此处的调用后的指令。

还有一些内容。

c064:   0a 24           jz  $+22        ;abs 0xc07a
c066:   4e 5e           rla.b   r14     

在获取指令之前,程序计数器为0xC064。在获取并解码指令后,程序计数器变为0xC066。该指令表示如果零标志位为零,则跳转到地址0xc07a。因此,如果零标志未设置,则程序计数器保持在0xC066处,并从那里开始执行下一条指令,但如果Z标志被设置,则程序计数器将修改为0xc07a,下一条要执行的指令也会相应地改变。因此,最终程序计数器可能是0xc064、0xc066或0xc07a。

一个指令的“后”是下一条指令的“前”。

无条件跳转

c074:   c2 4d 21 00     mov.b   r13,    &0x0021 
c078:   ee 3f           jmp $-34        ;abs 0xc056

获取0xc07a之前,执行0xc07A之前,执行后为0xc056。

对于这个指令来说,PC至少在一条指令中保持了三个值(如果一次只获取一个字节,则它将保持0xc078、0xc079、0xc07a,并以0xc056结束)。

是的,它可以并且确实会保持多个值,但不会同时保持,每个阶段在执行时都只有一个值。


2

最初,PC(寄存器)保持当前值,但随着时钟信号的变化,它会改为PC(以前的地址+值),并且它将包含相同的值,直到下一个时钟周期,并在添加值后将地址存储在寄存器中。


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