计算机体系结构基础(第3版)
上QQ阅读APP看书,第一时间看更新

7.1 处理器核初始化

在讨论这个过程之前,先来定义什么叫作初始化。所谓初始化,实际上是将计算机内部的各种寄存器状态从不确定设置为确定,将一些模块状态从无序强制为有序的过程。简单来说,就是通过load/store指令或其他方法将指定寄存器或结构设置为特定数值。

举例来说,在MIPS和LoongArch结构中,都只将0号寄存器的值强制规定为0,而其他的通用寄存器值是没有要求的。在处理器复位后开始运行的时候,这些寄存器的值可能是任意值。如果需要用到寄存器内容,就需要先对其进行赋值,将这个寄存器的内容设置为软件期望的值。这个赋值操作可以是加载立即数,也可以是对内存或者其他特定地址进行load操作,又可以是以其他已初始化的寄存器作为源操作数进行运算得到的结果。

这个过程相对来说比较容易理解,因为是对软件上需要用到的单元进行初始化。而另一种情况看起来就相对隐蔽一些。例如,在现代处理器支持的猜测执行、预取等微结构特性中,可能会利用某些通用寄存器的值或者高速缓存的内容进行猜测。如果整个处理器的状态并没有完全可控,或许会猜测出一些极不合理的值,导致处理器微结构上执行出错而引发死机。这样就需要对一些必要的单元结构进行初始化,防止这种情况发生。

举一个简单的例子。计算机系统中使用约定的ABI(Application Binary Interface,应用程序二进制接口)作为软件接口规范。LoongArch约定使用1号寄存器($r1)作为函数返回指针寄存器(ra,Return Address)。函数返回时,一般使用指令“jirl”。这条指令的格式为“jirl rd,rj,offset”,其中rj与offset表示跳转的目标地址,rd为计算得到的返回地址,为当前PC+4,用于函数调用返回。当不需要保存时,可以指定为$r0,也就是0号寄存器。因此,函数返回时,一般可用“jirl $r0,$r1,0”来实现。这样,一种可行的转移预测优化方法是在指令译码得到“jirl”指令时,立即使用$r1作为跳转地址进行猜测取指,以加速后续的指令执行。

如果程序中没有使用“jirl $r0,$r1,0”,而是采用了诸如“jirl $r0,$r2,0”这样的指令,就会导致这个猜测机制出错。而如果此时$r1的寄存器是一个随机值,就有可能导致取指猜测错误,发出一个对非法地址的猜测请求。如果此时处理器没有对猜测访问通路进行控制或初始化,就可能会发生严重问题,例如猜测访问落入地址空洞而失去响应并导致死机等。

为了防止这个问题,在处理器开始执行之后,一方面需要先对相关的寄存器内容进行初始化,设置为一个正常地址值,另一方面则需要对地址空间进行处理,防止出现一般情况下不可访问的地址空洞。这样即使发生了这种猜测访问,也可以得到响应,避免系统出错或死机。

7.1.1 处理器复位

处理器的第一条指令实际上是由复位信号控制的,但受限于各种其他因素,复位信号并没有对处理器内部的所有部分进行控制,例如TLB、Cache等复杂结构,而是只保证从取指部件到BIOS取指令的通路畅通。如果把CPU比作一个大房间,复位后的房间内部漆黑一片,大门(内存接口)、窗户(IO接口)都是关着的,只有微弱的灯光照亮了通向一扇小门(BIOS接口)的通路。

在LoongArch架构下,处理器复位后工作在直接地址翻译模式下。该模式下的地址为虚实地址直接对应的关系,也就是不经TLB映射,也不经窗口映射。默认情况下,无论是取指访问还是数据访问,都是Uncache模式,也即不经缓存。这样即使硬件不对TLB、Cache两个结构进行初始化,处理器也能正常启动并通过软件在后续的执行中对这些结构进行初始化。尤其是早期的处理器设计由于对资源或时序的考虑,出于简化硬件设计的目标,将很多初始化工作交由软件进行。但现在大部分处理器在硬件上自动处理,从而减轻软件负担,缩短系统启动时间。例如,龙芯3A1000和龙芯3B1500都没有实现硬件初始化功能,只能通过软件对Cache进行初始化。本身Cache的初始化就需要运行在Uncache的空间上,执行效率低下,而且当Cache越来越大时,所需要的执行时间就越来越长。从龙芯3A2000开始,龙芯处理器也实现了TLB、各级Cache等结构的硬件初始化。硬件初始化的时机是在系统复位解除之后、取指访问开始之前,以此来缩短BIOS的启动时间。

LoongArch处理器复位后的第一条指令将固定从地址0x1C000000的位置获取,这个过程是由处理器的执行指针寄存器被硬件复位为0x1C000000而决定的。

对物理地址0x1C000000的取指请求,会被处理器内部预先设定好的片上互连网络路由至某个预先存放着启动程序的存储设备。从第一条指令开始,处理器核会依据软件的设计按序执行。

以龙芯3A5000处理器为例,处理器得到的前几条指令通常如下。左框中为手工编写的代码,右框中为编译器编译生成的汇编代码。其中的stack、_gp为在代码其他地址所定义的标号,编译器编译时能够使用实际的地址对其进行替换。

这几条指令对处理器核的中断处理相关寄存器进行了初始化,并对后续软件将使用的栈地址等进行了初始化。第一条csrxchg指令将例外配置寄存器(0x4偏移)中的比特18:16设置为0,以将除TLB外的所有例外和中断入口设置为同一个(代码中的0x1C001000)。第一条csrwr指令将该例外入口地址(0xC号控制寄存器)设置为0x1C001000,第二条csrwr指令将TLB重填例外的入口地址(0x88号控制寄存器)也设置为0x1C001000。实际上BIOS并没有使用TLB地址映射,一旦出现了TLB重填例外,一定是使用的地址出现了错误。第二条csrxchg指令将模式信息寄存器(0x0号控制寄存器)中的比特2设置为0,以禁用所有的中断。可以看到,对于stack、_gp这些地址的装载所用的la指令,在经过编译器编译之后,最终产生了多条指令与之对应。其中lu12i.w用于将20位立即数符号扩展并装载到寄存器的比特63:12,lu32i.d用于将20位立即数符号扩展并装载到寄存器的比特63:32,lu52i.d用于将12位立即数装载到寄存器的比特63:52,ori用于将12位立即数与寄存器的内容进行或操作。

需要指出的是,处理器复位后先是通过频率为几十兆赫兹(MHz)以下的低速设备取指令,例如SPI或LPC等接口。一拍只能取出1比特(SPI)或4比特(LPC),而一条指令一般需要32比特。对于吉赫兹(GHz)的高性能处理器来说,几千拍才能执行一条指令,相当于在城市空荡荡的大街上只有一个人在行走,这时候的指令很“孤独”。

整个处理器由系统复位到操作系统启动的简要流程如图7.1所示。其中第一列为处理器核初始化过程,第二列为芯片核外部分初始化过程,第三列为设备初始化过程,第四列为内核加载过程,第五列为多核芯片中的从核(Slave Core)独有的启动过程。

图7.1 系统复位到操作系统启动的简要流程图

7.1.2 调试接口初始化

那么,在启动过程中优先初始化的是什么呢?首先是用于调试的接口部分。比如开机时听到的蜂鸣器响声,或者在一些主板上看到的数码管显示,都是最基本的调试用接口。对于龙芯3号处理器来说,最先初始化的结构是芯片内集成的串口控制器。串口控制器作为一个人机交互的界面,可以提供简单方便的调试手段,以此为基础,再进一步对计算机系统中其他更复杂的部分进行管理。

对串口的初始化操作实际上是处理器对串口执行一连串约定好的IO操作。在X86结构下,IO地址空间与内存地址空间相互独立,IO操作与访存操作是通过不同的指令实现的。MIPS和LoongArch等结构并不显式区分IO和内存地址,而是采用全局编址,使用地址空间将IO和内存隐式分离,并通过地址空间或TLB映射对访问方式进行定序及缓存等的控制。只有理解IO与内存访问的区别,才能很好地理解计算机启动中的各种初始化过程。

内存空间对应的是存储器,存储器不会发生存储内容的自行更新。也就是说,如果处理器核向存储单元A中写入了0x5a5a的数值,除非有其他的主控设备(例如其他的处理器核或是其他的设备DMA)对它也进行写入操作,否则这个0x5a5a的数值是不会发生变化的。

IO空间一般对应的是控制寄存器或状态寄存器,是受IO设备的工作状态影响的。此时,写入的数据与读出的数据可能会不一致,而多次读出的数据也可能不一致,其读出数据是受具体设备状态影响的。例如,对串口的线路状态寄存器(寄存器偏移0x5)的读取在不同的情况下会产生不同的返回值。该寄存器定义如表7.1所示。

表7.1 串口线路状态寄存器定义

可以看到这个寄存器里的各个数据位都与当时的设备状态相关。例如当程序等待外部输入数据时,就需要查询这个寄存器的第0位,以确定是否收到数据,再从FIFO寄存器中读取实际的数据。在这个轮询的过程中,寄存器的第0位根据串口的工作状态由0变成1。

更有意思的是,这个寄存器的某些位在读操作之后会产生自动清除的效果,例如第7位(错误表示位)在一次读操作之后会自动清零。

从这个寄存器上可以看到IO访问与内存访问的一些区别。IO寄存器的行为与具体的设备紧密相关,每种IO设备都有各自不同的寄存器说明,需要按照其规定的访问方式进行读写,而不像内存可以进行随意的读写操作。

前面提到,在LoongArch结构下,IO地址空间与内存地址空间统一编址,那么IO操作和内存操作的差异如何体现呢?处理器上运行的指令使用虚拟地址,虚拟地址通过地址映射规则与物理地址相关联。基本的虚拟地址属性首先区分为经缓存(Cache)与不经缓存(Uncache)两种。对于内存操作,现代高性能通用处理器都采用Cache方式进行访问,以提升访存性能。Cache在离处理器更近的位置上利用访存局部性原理进行缓存,以加速重复访存或者其他规则访存(通过预取等手段)。对于存储器来说,在Cache中进行缓存是没有问题的,因为存储器所存储的内容不会自行修改(但可能会被其他核或设备所修改,这个问题可以通过缓存一致性协议解决)。但是对于IO设备来说,因为其寄存器状态是随着工作状态的变化而变化的,如果缓存在Cache中,那么处理器核将无法得到状态的更新,所以一般情况下不能对IO地址空间进行Cache访问,需要使用Uncache访问。使用Uncache访问对IO进行操作还有另一个作用,就是可以严格控制读写的访问顺序,不会因为预取类的操作导致寄存器状态的丢失。例如前面提到的线路状态寄存器的第7位(ERROR),一旦被预取的读操作所访问就会自动清除,而这个预取操作本身有可能会因为错误执行而被流水线取消,这样就导致这个错误状态的丢失,无法被软件观察到。

理解了IO操作与内存访问操作的区别,串口初始化的过程就显得非常简单。串口初始化程序仅仅是对串口的通信速率及一些控制方法进行设置,以使其很方便地通过一个串口交互主机进行字符的读写交互。

串口初始化的汇编代码和说明如下。对于串口设备各个寄存器的具体含义,感兴趣的读者可以在相关处理器的用户手册上查找。


LEAF(initserial)
li        a0, GS3_UART_BASE      #加载串口设备基地址
li        t1, 128                #线路控制寄存器, 写入0x80(128)表示后续的寄存器访问为分频
sb.b       t1, a0, 3              #寄存器访问
li        t1, 0x12
sb.b       t1, a0, 0          #配置串口波特率分频, 当串口控制器输入频率为33MHz, 并将串口通信
li        t1, 0x0            #速率设置为115200时, 分频方式为33000000/16/0x12=114583
sb.b       t1, a0, 1          #由于串口通信有固定的起始格式, 能够容忍传输两端一定的速率差异,
                         #只要将传输两端的速率保持在一定的范围之内就可以保证传输的正确性


li        t1, 3
sb.b       t1, a0, 3          #设置传输字符宽度为8, 同时将后续的寄存器访问设置为正常寄存器
li        t1, 0
sb.b       t1, a0, 1          #不使用中断模式
li        t1, 71
sb        t1, a0, 2
jirl       ra
END(initserial)

这里有一个值得注意的地方,串口设备使用相同的地址映射了两套功能完全不同的寄存器,通过线路控制寄存器的最高位(就是串口寄存器中偏移为3的寄存器的最高位)进行切换。因为其中一套寄存器主要用于串口波特率的设置,只需要在初始化时进行访问,在正常工作状态下完全不用再次读写,所以能够将其访问地址与另一套正常工作用的寄存器相复用来节省地址空间。表7.2中是两组不同寄存器的定义。

表7.2 串口的部分地址复用寄存器

在初始化时,代码中先将0x3偏移寄存器的最高位设置为1,以访问分频设置寄存器,按照与连接设备协商好的波特率和字符宽度,将初始化信息写入配置寄存器中。然后退出分频寄存器的访问模式,进入正常工作模式。

在使用时,串口的对端是一个同样的串口,两个串口的发送端和接收端分别对连,通过双向的字符通信来实现被调试机的字符输出和字符输入功能。

在正常工作模式下,当CPU需要通过串口对外发送和接收字符时,执行的两个函数分别如下。


字符输出
LEAF(tgt_putchar)
dli   a1, GS3_UART_BASE            # 加载串口设备基地址
1:             
ld.bu  a2, a1, 0x5                  # 读取线路状态寄存器中的发送FIFO空标志
andi   a2, a2, 0x20                 
                                      # FIFO非空时等待
beqz   a2, 1b                       # FIFO空时将通过a0传入的字符写入数据寄存器
st.b   a0, a1, 0
jirl   zero, ra, 0
END(tgt_putchar)
______________________________________________________________________________________
字符输入
LEAF(tgt_getchar)
dli   a0, GS3_UART_BASE            # 加载串口设备基地址
1:
ld.bu  a1, a0, 0x5                  # 读取线路状态寄存器中的接收FIFO有效标志                              
andi   a1, a1, 0x1
                                      # 接收FIFO为空时等待
beqz   a1, 1b                       # FIFO非空时将数据读出并放在a0寄存器中返回
ld.b   a0, a0, 0
jirl   zero, ra, 0
END(tgt_getchar)

可以看到,串口通过数据FIFO作为软件数据接口,并通过线路状态寄存器中的特定位来表示串口设备的工作状态。串口驱动函数通过观察状态确定是否能够进行数据的输入输出交互。

对于字符输出,串口控制器实现的功能是将发送FIFO中的数据转换为协议的格式并按位通过tx引脚向外发送,再按照发送FIFO的空满状态设置对应的状态寄存器。对于字符输入,串口控制器实现的功能是将在rx引脚上收到的信号通过协议格式进行解析,将解析得到的字符写入接收FIFO,并按照接收FIFO的空满状态设置对应的状态寄存器。

串口是一个功能非常简单的设备,通过硬件提供底层支持,软件进行配合驱动来实现整个字符输入输出功能。再上到应用层面,还需要更多的软件参与。例如,当通过上位机的minicom或其他的串口工具对被调试机进行字符输入时,我们看到自己输入的字符立即显示在minicom界面上,看起来就像是键盘输入给了minicom,minicom显示后通过串口进行发送,但其真正的过程却更为复杂:

1)用户在上位机的minicom界面中敲击键盘,输入字符A;

2)上位机的内核通过其键盘驱动获得字符A;

3)上位机的内核将字符A交给minicom进程;

4)minicom进程调用串口驱动发送字符A;

5)内核中的串口驱动将字符A通过串口发送给被调试机;

6)被调试机的软件发现串口接收FIFO状态非空并接收字符A;

7)被调试机将接收的字符A通过发送函数写入串口发送FIFO;

8)被调试机的串口将发送FIFO中的字符A发送给上位机;

9)上位机发现串口接收FIFO状态非空并接收字符A;

10)上位机将接收的字符A交给minicom进程,minicom将其显示在界面上。

从CPU对串口的初始化过程可以看出,当load与store指令访问IO设备时,与访问内存“直来直去”的行为是完全不同的。

7.1.3 TLB初始化

接下来对TLB进行初始化。TLB作为一个地址映射的管理模块,主要负责操作系统里用户进程地址空间的管理,用以支持多用户多任务并发。然而在处理器启动的过程中,处理器核处于特权态,整个BIOS都工作在统一的地址空间里,并不需要对用户地址空间进行过多干预。此时TLB的作用更多是地址转换,以映射更大的地址空间供程序使用。下面具体来看看TLB在这一过程中的作用。

LoongArch结构采用了分段和分页两种不同的地址映射机制。分段机制将大段的地址空间与物理地址进行映射,具体的映射方法在BIOS下使用窗口机制进行配置,主要供系统软件使用。而分页机制通过TLB起作用,主要由操作系统管理,供用户程序使用。

BIOS一般映射两段,其中0x90000000_00000000开始的地址空间被映射为经缓存的地址,0x80000000_00000000开始的地址空间被映射为不经缓存的地址。根据地址空间的转换规则,这两段转换为物理地址时直接抹除地址的高位,分别对应整个物理地址空间,仅仅在是否经过Cache缓存上有所区别。

由于分段机制是通过不同的虚拟地址来映射全部的物理地址空间,并不适合用作用户程序的空间隔离和保护,也不适合需要更灵活地址空间映射方法的场合。这些场景下就需要利用TLB机制。早期的处理器或者比较简单的处理器中没有实现硬件初始化TLB的逻辑,在使用之前需要使用软件对TLB进行初始化。TLB的初始化主要是将全部表项初始化为无效项。

初始化为无效项就是将TLB的每项逐一清空,以免程序中使用的地址被未初始化的TLB表项所错误映射。在没有硬件复位TLB逻辑的处理器里,启动时TLB里可能会包含一些残留的或者随机的内容,这部分内容可能会导致TLB映射空间的错误命中。因此在未实现硬件复位TLB的处理器中,需要对整个TLB进行初始化操作。

可以利用正常的TLB表项写入指令,例如LoongArch中的TLBWR指令,通过一个循环将TLB中的表项一项一项地写为无效。也可以利用更高效的指令来将所有表项直接写为无效,例如LoongArch中的INVTLB 0指令。

以下是使用TLBWR指令来进行TLB初始化的相关代码及相应说明。具体的TLB结构和原理可以参考第3章的介绍。通过下面这段代码可以看到,初始化的过程实际上就是将整个TLB表项清0的过程。需要特别说明的是,在LoongArch架构中,实际上并不需要使用这样的指令来完成这个过程,而可以直接使用INVTLB 0,$r0,$r0这一条指令,由硬件完成类似的循环清空操作。


LEAF(CPU_TLBClear)
dli     a3, 0                                        # 循环变量
dli     a0,(1<<31)|(12 << 24)                    # 设置页大小为4K,31位为1表示无效
li      a2, 64                                       # TLB表项数
1:
csrwr   a0, 0x10                                     # 将表项写入编号为0x10的TLBIDX寄存器
addi.d  a0, 1                                        # 增加TLBIDX中的索引号
addi.d  a3, 1                                        # 增加循环变量
tlbwr                                                # 写TLB表项
bne     a3, a2, 1b
jirl    zero, ra, 0
END(CPU_TLBClear)

前面提到过,越来越多的处理器已经实现了在芯片复位时由硬件进行TLB表项的初始化,这样在BIOS代码中可以不用再使用类似的软件初始化流程,比如从龙芯3A2000开始的桌面或服务器用的处理器就不再需要软件初始化,这能够减少所需的启动时间。但是在一些嵌入式类的处理器上还是需要上面提到的软件初始化流程。

7.1.4 Cache初始化

Cache在处理器内的作用在前面的章节已经介绍过了,Cache的引入能够减小处理器执行和访存延迟之间的性能差异,即缓解存储墙的问题。引入Cache结构,能够大大提高处理器的整体运行效率。

在系统复位之后,Cache同样也处于一个未经初始化的状态,也就是说Cache里面可能包含残留的或随机的数据,如果不经初始化,对于Cache空间的访问也可能会导致错误的命中。

不同的处理器可能包含不同的Cache层次,各级Cache的容量也可能各不相同。例如龙芯3A1000处理器包含私有一级指令Cache、私有一级数据Cache和共享二级Cache两个层次,而龙芯3A5000处理器则包含私有一级指令Cache、私有一级数据Cache、私有二级替换Cache和共享三级Cache三个层次。在进行Cache初始化时要考虑所有需要的层次。

Cache的组织结构主要包含标签(Tag)和数据(Data)两个部分,Tag用于保存Cache块状态、Cache块地址等信息,Data则保存数据内容。大多数情况下对Cache的初始化就是对Tag的初始化,只要将其中的Cache块状态设置为无效,其他部分的随机数据就不会产生影响。

龙芯3A5000中一级数据Cache的组织如图7.2所示。其中Tag上的cs位为0表示该Cache块为无效状态,对该Cache的初始化操作就是使用Cache指令将Tag写为0。对应的ECC位会在Tag写入时自动生成,不需要专门处理。

图7.2 龙芯3A5000的一级数据Cache组织

不同Cache层次中Tag的组织结构可能会略有区别,初始化程序也会稍有不同,在此不一一列举。以下仅以龙芯3A处理器内部的一级指令Cache的初始化为例进行说明。


LEAF(godson2_cache_init)
li        a2,(1<<14)               #64KB/4路,为Index的实际数量
li        a0, 0x0                  # a0表示当前的index
1:
CACOP     0x0, a0, 0x0             # 对4路Cache分别进行写TAG操作
CACOP     0x0, a0, 0x1
CACOP     0x0, a0, 0x2
CACOP     0x0, a0, 0x3
addi.d    a0, a0, 0x40             # 每个Cache行大小为64字节
bne       a0, a2, 1b
jr        ra
END(godson2_cache_init)

CACOP为LoongArch指令集中定义的Cache指令,其定义为CACOP code,rj,si12。其中code表示操作的对象和操作的类型,0x0表示对一级指令Cache进行初始化操作(StoreTag),将指定Cache行的Tag写为0。rj用于表示指定的Cache行,si12在这个操作中表示不同的Cache路数。

需要特别指出的是,上述程序中的Cache指令为特权态指令,只有运行在特权态时,处理器才可以执行Cache指令,这样可以避免用户程序利用某些Cache指令对Cache结构进行破坏。处理器在复位完成之后就处于最高特权态中,完成各项初始化。在加载操作系统执行之后,在操作系统中才会使用用户态对程序的运行加以限制,以防止不同用户进程之间的相互干扰。

在完成所有Cache层次的初始化之后,就可以跳转到Cache空间开始执行。此后程序的运行效率将会有数十倍的提升。以取指为例,在使用Cache访问之前,需要以指令宽度为单位(龙芯3A5000中为4字节)进行取指操作,在使用Cache访问之后,取指将以Cache行为单位(龙芯3A5000中为64字节),大大提升了取指的效率。

既然Cache的使用能够大大提高程序运行效率,为什么不首先对Cache进行初始化呢?在跳转到Cache空间执行后,程序运行效率大大提升,随之而来的是处理器内各种复杂猜测机制的使用。例如对取数操作的猜测执行可能导致一个落在TLB映射空间的访存操作,如果此时TLB尚未完成初始化,就可能会导致TLB异常的发生,而TLB异常处理机制的缺失又会导致系统的崩溃。

实际上,在跳转到Cache空间执行前,BIOS中还会对一些处理器具体实现中的细节或功能进行初始化,在保证执行基本安全的状态下,才会跳转到Cache空间执行。这些初始化包括对各种地址窗口的设置、对一些特殊寄存器的初始化等。感兴趣的读者可以自行阅读相关的BIOS实现代码,在此不再赘述。

得益于摩尔定律的持续生效,片上Cache的容量越来越大,由此却带来了初始化时间越来越长的问题。但同时,在拥有越来越多可用片上资源的情况下,TLB、Cache等结构的初始化也更多地开始使用硬件自动完成,软件需要在这些初始化上耗费的时间也越来越少。例如从龙芯3A2000开始,片上集成的TLB、各级Cache都已经在复位之后由专用的复位电路进行初始化,不再由低效的Uncache程序来完成,大大缩短了系统启动时间。

完成Cache空间的初始化并跳转至Cache空间运行也标志着处理器的核心部分,或者说体系结构相关的初始化部分已经基本完成。接下来将对计算机系统所使用的内存总线和IO总线等外围部分进行初始化。

如果把CPU比作一个大房间,完成对TLB、Cache等的初始化后,房间内已是灯火通明,但大门(内存接口)和窗口(IO接口)还是紧闭的。