1
1
mirror of https://github.com/foxsen/archbase.git synced 2026-02-03 02:14:40 +08:00
Files
archbase/17-boot.Rmd
Zhang Fuxin fa64d10378 修复表7.2
2021-10-30 09:20:34 +08:00

658 lines
56 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 计算机启动过程分析
前面章节主要从计算机硬件的角度对构成计算机系统的各个主要部分进行了介绍。为了描述计算机硬件系统各部分之间的相互关系本章将对计算机从开机到点亮屏幕接收键盘输入再到引导并启动整个操作系统的具体过程进行探讨。与本书其他章节一样本章基于LoongArch架构进行介绍具体则以龙芯3号处理器的启动过程为例。
无论采用何种指令系统的处理器复位后的第一条指令都会从一个预先定义的特定地址取回。处理器的执行就从这条指令开始。处理器的启动过程实际上就是一个特定程序的执行过程。这个程序我们称之为固件又称为BIOSBasic Input Output System基本输入输出系统。对于LoongArch处理器复位后的第一条指令将固定从地址0x1C000000的位置获取。这个地址需要对应一个能够给处理器核提供指令的设备这个设备以前是各种ROM现在通常是闪存Flash。从获取第一条指令开始计算机系统的启动过程也就开始了。
为了使计算机达到一个最终可控和可用的状态,在启动过程中,需要对包括处理器核、内存、外设等在内的各个部分分别进行初始化,再对必要的外设进行驱动管理。本章的后续内容将对这些具体工作进行讨论。
## 处理器核初始化
在讨论这个过程之前先来定义什么叫作初始化。所谓初始化实际上是将计算机内部的各种寄存器状态从不确定设置为确定将一些模块状态从无序强制为有序的过程。简单来说就是通过load/store指令或其他方法将指定寄存器或结构设置为特定数值。
举例来说在MIPS和LoongArch结构中都只将0号寄存器的值强制规定为0而其他的通用寄存器值是没有要求的。在处理器复位后开始运行的时候这些寄存器的值可能是任意值。如果需要用到寄存器内容就需要先对其进行赋值将这个寄存器的内容设置为软件期望的值。这个赋值操作可以是加载立即数也可以是对内存或者其他特定地址进行load操作又可以是以其他已初始化的寄存器作为源操作数进行运算得到的结果。
这个过程相对来说比较容易理解,因为是对软件上需要用到的单元进行初始化。而另一种情况看起来就相对隐蔽一些。例如,在现代处理器支持的猜测执行、预取等微结构特性中,可能会利用某些通用寄存器的值或者高速缓存的内容进行猜测。如果整个处理器的状态并没有完全可控,或许会猜测出一些极不合理的值,导致处理器微结构上执行出错而引发死机。这样就需要对一些必要的单元结构进行初始化,防止这种情况发生。
举一个简单的例子。计算机系统中使用约定的ABIApplication Binary Interface应用程序二进制接口作为软件接口规范。LoongArch约定使用1号寄存器r1作为函数返回指针寄存器raReturn 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的寄存器是一个随机值就有可能导致取指猜测错误发出一个对非法地址的猜测请求。如果此时处理器没有对猜测访问通路进行控制或初始化就可能会发生严重问题例如猜测访问落入地址空洞而失去响应并导致死机等。
为了防止这个问题,在处理器开始执行之后,一方面需要先对相关的寄存器内容进行初始化,设置为一个正常地址值,另一方面则需要对地址空间进行处理,防止出现一般情况下不可访问的地址空洞。这样即使发生了这种猜测访问,也可以得到响应,避免系统出错或死机。
### 处理器复位 {#sec-cpu-reset}
处理器的第一条指令实际上是由复位信号控制的但受限于各种其他因素复位信号并没有对处理器内部的所有部分进行控制例如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为在代码其他地址所定义的标号编译器编译时能够使用实际的地址对其进行替换。
:::: {.cols data-latex=""}
::: {.col .width48 data-latex="{0.48\textwidth}"}
```
dli t0, (0x7 << 16)
csrxchg zero, t0, 0x4
dli t0, 0x1c001000
csrwr t0, 0xc
dli t0, 0x1c001000
csrwr t0, 0x88
dli t0, (1 << 2)
csrxchg zero, t0, 0x0
la sp, stack
la gp, _gp
```
:::
::: {.col .width4 data-latex="{0.04\textwidth}"}
```
```
:::
::: {.col .width48 data-latex="{0.48\textwidth}"}
```
lu12i.w $r12, 0x70
csrxchg $r0, $r12, 0x4
lu12i.w $r12, 0x1c001
csrwr $r12, 0xc
lui12i.w $r12, 0x1c001
csrwr $r12, 0x88
ori $r12, $r0, 0x4
csrxchg $r0, $r12, 0x0
lu12i.w $r3, 0x90400
lu32i.d $r3, 0
lu52i.d $r3, $r3, 0x900
lu12i.w $r2, 0x90020
ori $r2, $r2, 0x900
lu32i.d $r2, 0
lu52i.d $r2, $r2, 0x900
```
:::
::::
这几条指令对处理器核的中断处理相关寄存器进行了初始化并对后续软件将使用的栈地址等进行了初始化。第一条csrxchg指令将例外配置寄存器0x4偏移中的比特1816设置为0以将除TLB外的所有例外和中断入口设置为同一个代码中的0x1C001000。第一条csrwr指令将该例外入口地址0xC号控制寄存器设置为0x1C001000第二条csrwr指令将TLB重填例外的入口地址0x88号控制寄存器也设置为0x1C001000。实际上BIOS并没有使用TLB地址映射一旦出现了TLB重填例外一定是使用的地址出现了错误。第二条csrxchg指令将模式信息寄存器0x0号控制寄存器中的比特2设置为0以禁用所有的中断。可以看到对于stack、_gp这些地址的装载所用的la指令在经过编译器编译之后最终产生了多条指令与之对应。其中lu12i.w用于将20位立即数符号扩展并装载到寄存器的比特6312lu32i.d用于将20位立即数符号扩展并装载到寄存器的比特6332lu52i.d用于将12位立即数装载到寄存器的比特6352ori用于将12位立即数与寄存器的内容进行或操作。
需要指出的是处理器复位后先是通过频率为几十兆赫兹MHz以下的低速设备取指令例如SPI或LPC等接口。一拍只能取出1比特SPI或4比特LPC而一条指令一般需要32比特。对于吉赫兹GHz的高性能处理器来说几千拍才能执行一条指令相当于在城市空荡荡的大街上只有一个人在行走这时候的指令很“孤独”。
```{r boot-flow, echo=FALSE, fig.align='center', fig.cap='系统复位到操作系统启动的简要流程图', out.width='100%'}
knitr::include_graphics("images/chapter7/boot_flow.png")
```
整个处理器由系统复位到操作系统启动的简要流程如图\@ref(fig:boot-flow)所示。其中第一列为处理器核初始化过程第二列为芯片核外部分初始化过程第三列为设备初始化过程第四列为内核加载过程第五列为多核芯片中的从核Slave Core独有的启动过程。
### 调试接口初始化
那么在启动过程中优先初始化的是什么呢首先是用于调试的接口部分。比如开机时听到的蜂鸣器响声或者在一些主板上看到的数码管显示都是最基本的调试用接口。对于龙芯3号处理器来说最先初始化的结构是芯片内集成的串口控制器。串口控制器作为一个人机交互的界面可以提供简单方便的调试手段以此为基础再进一步对计算机系统中其他更复杂的部分进行管理。
对串口的初始化操作实际上是处理器对串口执行一连串约定好的IO操作。在X86结构下IO地址空间与内存地址空间相互独立IO操作与访存操作是通过不同的指令实现的。MIPS和LoongArch等结构并不显式区分IO和内存地址而是采用全局编址使用地址空间将IO和内存隐式分离并通过地址空间或TLB映射对访问方式进行定序及缓存等的控制。只有理解IO与内存访问的区别才能很好地理解计算机启动中的各种初始化过程。
内存空间对应的是存储器存储器不会发生存储内容的自行更新。也就是说如果处理器核向存储单元A中写入了0x5a5a的数值除非有其他的主控设备例如其他的处理器核或是其他的设备DMA对它也进行写入操作否则这个0x5a5a的数值是不会发生变化的。
IO空间一般对应的是控制寄存器或状态寄存器是受IO设备的工作状态影响的。此时写入的数据与读出的数据可能会不一致而多次读出的数据也可能不一致其读出数据是受具体设备状态影响的。例如对串口的线路状态寄存器寄存器偏移0x5的读取在不同的情况下会产生不同的返回值。该寄存器定义如表\@ref(tab:serial-status)所示。
```{r serial-status, echo = FALSE, message=FALSE, tab.cap='串口线路状态寄存器定义'}
autonum <- run_autonum(seq_id = "tab", bkm = "serial-status", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/serial_status.csv') %>%
flextable() %>%
set_caption(caption="串口线路状态寄存器定义", autonum = autonum) %>%
theme_box() %>%
autofit()
```
可以看到这个寄存器里的各个数据位都与当时的设备状态相关。例如当程序等待外部输入数据时就需要查询这个寄存器的第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
#线路控制寄存器写入0x80128表示后续的寄存器访问为分频寄存器访问
li t1, 128
sb.b t1, a0, 3
# 配置串口波特率分频当串口控制器输入频率为33MHz将串口通讯速率设置在115200
# 时分频方式为33,000,000 / 16 / 0x12 = 114583。 由于串口通信有固定的起始格式,
# 能够容忍传输两端一定的速率差异,只要将传输两端的速率保持在一定的范围之内就可
# 以保证传输的正确性
li t1, 0x12
sb.b t1, a0, 0
li t1, 0x0
sb.b t1, a0, 1
# 设置传输字符宽度为8同时设置后续访问常规寄存器
li t1, 3
sb.b t1, a0, 3
# 不使用中断模式
li t1, 0
sb.b t1, a0, 1
li t1, 71
sb t1, a0, 2
jirl ra
nop
END(initserial)
```
这里有一个值得注意的地方串口设备使用相同的地址映射了两套功能完全不同的寄存器通过线路控制寄存器的最高位就是串口寄存器中偏移为3的寄存器的最高位进行切换。因为其中一套寄存器主要用于串口波特率的设置只需要在初始化时进行访问在正常工作状态下完全不用再次读写所以能够将其访问地址与另一套正常工作用的寄存器相复用来节省地址空间。表\@ref(tab:reg-multiplex)中是两组不同寄存器的定义。
```{r reg-multiplex, echo = FALSE, message=FALSE, tab.cap='串口的部分地址复用寄存器'}
autonum <- run_autonum(seq_id = "tab", bkm = "reg-multiplex", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/reg_multiplex.csv') %>%
flextable() %>%
set_caption(caption="串口的部分地址复用寄存器", autonum = autonum) %>%
merge_h() %>%
colformat_md(md_extensions = "+hard_line_breaks") %>%
width(j=1, width=0.6) %>%
width(j=2:3, width=2.7) %>%
theme_box()
```
在初始化时代码中先将0x3偏移寄存器的最高位设置为1以访问分频设置寄存器按照与连接设备协商好的波特率和字符宽度将初始化信息写入配置寄存器中。然后退出分频寄存器的访问模式进入正常工作模式。
在使用时,串口的对端是一个同样的串口,两个串口的发送端和接收端分别对连,通过双向的字符通信来实现被调试机的字符输出和字符输入功能。
在正常工作模式下当CPU需要通过串口对外发送和接收字符时执行的两个函数分别如下
```
字符输出
LEAF(tgt_putchar)
# 加载串口设备基地址
dli a1, GS3_UART_BASE
1:
# 读取线路状态寄存器中的发送FIFO空标志
ld.bu a2, a1, 0x5
andi a2, a2, 0x20
# FIFO非空时等待
beqz a2, 1b
# 写入字符
st.b a0, a1, 0
jirl zero, ra, 0
END(tgt_putchar)
```
```
字符输入
LEAF(tgt_getchar)
#加载串口设备基地址
dli a0, GS3_UART_BASE
1:
#读取线路状态寄存器中的接收FIFO有效标志
ld.bu a1, a0, 0x5
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界面上看起来就像是键盘输入给了minicomminicom显示后通过串口进行发送但其真正的过程却更为复杂
1用户在上位机的minicom界面中敲击键盘输入字符A
2上位机的内核通过其键盘驱动获得字符A
3上位机的内核将字符A交给minicom进程
4minicom进程调用串口驱动发送字符A
5内核中的串口驱动将字符A通过串口发送给被调试机
6被调试机的软件发现串口接收FIFO状态非空并接收字符A
7被调试机将接收的字符A通过发送函数写入串口发送FIFO
8被调试机的串口将发送FIFO中的字符A发送给上位机
9上位机发现串口接收FIFO状态非空并接收字符A
10上位机将接收的字符A交给minicom进程minicom将其显示在界面上。
从CPU对串口的初始化过程可以看出当Load与Store指令访问IO设备时与访问内存“直来直去”的行为是完全不同的。
### 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
# 设置页大小为4K31位为1表示无效
dli a0, (1<<31) | (12 << 24)
# TLB表项数
li a2, 64
1:
# 将表项写入编号为0x10的TLBIDX寄存器
csrwr a0, 0x10
增加TLBIDX中的索引号
addi.d a0, 1
# 增加循环变量
addi.d a3, 1
# 写TLB表项
tlbwr
bne a3, a2, 1b
jirl zero, ra, 0
END(CPU_TLBClear)
```
前面提到过越来越多的处理器已经实现了在芯片复位时由硬件进行TLB表项的初始化这样在BIOS代码中可以不用再使用类似的软件初始化流程比如从龙芯3A2000开始的桌面或服务器用的处理器就不再需要软件初始化这能够减少所需的启动时间。但是在一些嵌入式类的处理器上还是需要上面提到的软件初始化流程。
### 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的组织如图\@ref(fig:l1-dcache)所示。
```{r l1-dcache, echo=FALSE, fig.align='center', fig.cap="龙芯3A5000的一级数据Cache组织", out.width='100%'}
knitr::include_graphics("images/chapter7/l1_dcache.png")
```
其中Tag上的cs位为0表示该Cache块为无效状态对该Cache的初始化操作就是使用Cache指令将Tag写为0。对应的ECC位会在Tag写入时自动生成不需要专门处理。
不同Cache层次中Tag的组织结构可能会略有区别初始化程序也会稍有不同在此不一一列举。以下仅以龙芯3A处理器内部的一级指令Cache的初始化为例进行说明。
```
LEAF(godson2_cache_init)
# 64KB/4路为Index的实际数量
li a2, (1<<14)
# a0表示当前的index
li a0, 0x0
1:
# 对4路Cache分别进行写TAG操作
CACOP 0x0, a0, 0x0
CACOP 0x0, a0, 0x1
CACOP 0x0, a0, 0x2
CACOP 0x0, a0, 0x3
# 每个cache行大小为64字节
addi.d a0, a0, 0x40
bne a0, a2, 1b
jirl ra
nop
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接口还是紧闭的。
## 总线接口初始化
在使用同一款处理器的不同系统上TLB、Cache这些体系结构紧密相关的芯片组成部分的初始化方法是基本一致的不需要进行特别的改动。与此不同的是内存和IO设备的具体组成在计算机系统中则可以比较灵活地搭配不同系统间的差异可能会比较大。
在计算机系统硬件设计中内存可以使用不同的总线类型例如DDR、DDR2、DDR3或者DDR4。在主板上使用时也可以灵活地增加或者减小内存容量甚至改变内存条种类例如将UDIMMUnbuffered DIMM非缓冲型内存模组改为RDIMMRegistered DIMM寄存型内存模组
IO总线的情况也比较类似在计算机系统硬件设计中可以搭配不同的桥片也可以在主板上根据实际情况改变某些接口上连接的设备例如增加PCIE网卡或者增加硬盘数量等。
这些不同的配置情况要求在计算机系统启动时能够进行有针对性的初始化。前面提到过初始化就是将某些寄存器通过load/store指令或是其他的方法设置为期望数值的过程。以下代码段就是龙芯3A处理器上对内存控制器进行初始化的程序片段。
```
ddr2_config:
# a2、s0为调用该程序时传入的参数它们的和表示初始化参数在Flash中的基地址
add.d a2, a2, s0
# t1用于表示内存参数的个数
dli t1, DDR_PARAM_NUM
# t8为调用该程序时传入的参数用于表示内存控制器的寄存器基地址
addi.d v0, t8, 0x0
1:
# 可以看到初始化的过程就是从Flash中取数再写入内存控制器中寄存器的过程
ld.d a1, 0x0(a2)
st.d a1, 0x0(v0)
addi.d t1, t1, -1
addi.d a2, a2, 0x8
addi.d v0, v0, 0x8
bnez t1, 1b
```
### 内存初始化
内存是计算机系统的重要组成部分。冯·诺依曼结构下计算机运行时的程序和数据都被保存在内存之中。相对复位时用于取指的ROM或是Flash器件来说内存的读写性能大幅提高。以SPI接口的Flash为例即使不考虑传输效率的损失当SPI接口运行在50MHz时其带宽最高也只有50MHz×2b=100Mb/s而一个DDR3-1600的接口在内存宽度为64位时其带宽可以达到最高1600MHz×64b=102400Mb/s。由此可见内存的使用对系统性能的重要程度。
越来越多的处理器已经集成内存控制器。因为内存的使用和设置与外接内存芯片的种类、配置相关,所以在计算机系统启动的过程中需要先获取内存的配置信息,再根据该信息对内存控制器进行配置。正如上一章对内存总线的介绍,这些信息包含内存条的类型、容量、行列地址数、运行频率等。
获取这些信息是程序通过I2C总线对外部内存条的SPD芯片进行读操作来完成的。SPD芯片也相当于一个Flash芯片专门用于存储内存条的配置信息。
如上面的程序片段所示对内存的初始化实际上就是根据内存配置信息对内存控制器进行初始化。与Cache初始化类似的是内存初始化并不涉及其存储的初始数据。与Cache又有所不同的地方在于Cache有专门的硬件控制位来表示Cache块是否有效而内存却并不需要这样的硬件控制位。内存的使用完全是由软件控制的软件知道其访问的每一个地址是否存在有效的数据。而Cache是一个硬件加速部件大多数情况下软件并不需要真正知道而且也不希望知道其存在Cache的硬件控制位正是为了掩盖内存访问延迟保证访存操作的正确性。因此内存初始化仅仅需要对内存控制器进行初始化并通过控制器对内存的状态进行初始化。在初始化完成以后如果是休眠唤醒程序可以使用内存中已有的数据来恢复系统状态如果是普通开机则程序可以完全不关心内存数据而随意使用。
内存控制器的初始化包括两个相对独立的部分,一是根据内存的行地址、列地址等对内存地址映射进行配置,二是根据协议对内存信号调整的支持方式对内存读写信号相关的寄存器进行训练,以保证传输时的数据完整性。
在内存初始化完成后,可能还需要根据内存的大小对系统可用的物理地址空间进行相应的调整和设置。
### IO总线初始化
前面提到受外围桥片搭配及可插拔设备变化的影响系统每次启动时需要对IO总线进行有针对性的初始化操作。
对于龙芯3A5000处理器对应的IO总线主要为HyperTransport总线。在IO总线初始化时做了三件事一是对IO总线的访问地址空间进行设置划定设备的配置访问空间、IO访问空间和Memory访问空间二是对IO设备的DMA访问空间进行规定对处理器能接收的DMA内存地址进行设置三是对HyperTransport总线进行升频从复位时1.0模式的200MHz升频到了3.0模式的2.0GHz并将总线宽度由8位升至16位。
完成了这三件事对IO总线的初始化就基本完成了。接着还将设置一些和桥片特性相关的配置寄存器以使桥片正常工作。
IO总线初始化的主要目的是将非通用的软硬件设置都预先配置好将与桥片特性相关的部分与后面的标准驱动加载程序完全分离出来以保证接下来的通用设备管理程序的运行。
如果把CPU比作一个大房间至此房间灯火通明门窗均已打开但门窗外还是漆黑一片。
完成内存与IO总线初始化后BIOS中基本硬件初始化的功能目标已经达到。但是为了加载操作系统还必须对系统中的一些设备进行配置和驱动。操作系统所需要的存储空间比较大通常无法保存在Flash这样的存储设备中一般保存在硬盘中并在使用时加载或者也可以通过网口、U盘等设备进行加载。为此就需要使用更复杂的软件协议来驱动系统中的各种设备以达到加载操作系统的最终目标。
在此之前的程序运行基本没有使用内存进行存数取数操作程序也是存放在Flash空间之中的只不过是经过了Cache的缓存加速。在此之后的程序使用的复杂数据结构和算法才会对内存进行大量的读写操作。为了进一步提高程序的运行效率先将程序复制到内存中再跳转到内存空间开始执行。
此后还需要对处理器的运行频率进行测量对BIOS中的计时函数进行校准以便在需要等待的位置进行精确的时间同步。在经过对各种软件结构必要的初始化之后BIOS将开始一个比较通用的设备枚举和驱动加载的过程。下一节将对这个标准的设备枚举和加载过程进行专门的说明。
## 设备的探测及驱动加载
PCI总线于20世纪90年代初提出发展到现在已经逐渐被PCIE等高速接口所替代但其软件配置结构却基本没有发生变化包括HyperTransport、PCIE等新一代高速总线都兼容PCI协议的软件框架。
在PCI软件框架下系统可以灵活地支持设备的自动识别和驱动的自动加载。下面对PCI的软件框架进行简要说明。
在PCI协议下IO的系统空间分为三个部分配置空间、IO空间和Memory空间。配置空间存储设备的基本信息主要用于设备的探测和发现IO空间比较小用于少量的设备寄存器访问Memory空间可映射的区域较大可以方便地映射设备所需要的大块物理地址空间。
对于X86架构来说IO空间的访问需要使用IO指令操作Memory空间的访问则需要使用通常的load/store指令操作。而对于MIPS或者LoongArch这种把设备和存储空间统一编址的体系结构来说IO空间和Memory空间没有太大区别都使用load/store指令操作。IO空间与Memory空间的区别仅在于所在的地址段不同对于某些设备的Memory访问可能可以采用更长的单次访问请求。例如对于IO空间可以限制为仅能使用字访问而对于Memory空间则可以任意地使用字、双字甚至更长的Cache行访问。
配置空间的地址偏移由总线号、设备号、功能号和寄存器号的组合得到,通过对这个组合的全部枚举,可以很方便地检测到系统中存在的所有设备。
以HyperTransport总线为例配置访问分为两种类型即Type0和Type1其区别在于基地址和对总线号的支持。如图\@ref(fig:bus-access-type)所示,只需要在图中总线号、设备号、功能号的位置上进行枚举,就可以遍历整个总线,检测到哪个地址上存在设备。
```{r bus-access-type, echo=FALSE, fig.align='center', fig.cap="HyperTransport总线配置访问的两种类型", out.width='100%'}
knitr::include_graphics("images/chapter7/bus_access_type.png")
```
通过这种方式,即使在某次上电前总线上的设备发生了变化,也可以在这个枚举的过程中被探测到。而每个设备都拥有唯一的识别号,即图\@ref(fig:config-reg)中的设备号和厂商号,通过加载这些识别号对应的驱动,就完成了设备的自动识别和驱动的自动加载。
图\@ref(fig:config-reg)为标准的设备配置空间寄存器分布。对于所有设备这个空间的分布都是一致的以保证PCI协议对其进行统一的检索。
```{r config-reg, echo=FALSE, fig.align='center', fig.cap="标准的设备配置空间寄存器分布", out.width='100%'}
knitr::include_graphics("images/chapter7/config_reg.png")
```
图\@ref(fig:config-reg)中的厂商识别号Vendor ID与设备识别号Device ID的组合是唯一的由专门的组织进行管理。每一个提供PCI设备的厂商都应该拥有唯一的厂商识别号以在设备枚举时正确地找到其对应的驱动程序。例如英特尔的厂商识别号为0x8086龙芯的厂商识别号为0x0014。设备识别号对于每一个设备提供商的设备来说应该是唯一的。这两个识别号的组合就可以在系统中唯一地指明正确的驱动程序。
除了通过厂商识别号与设备识别号对设备进行识别并加载驱动程序之外还可以通过设备配置空间寄存器中的类别代码Class Code对一些通用的设备进行识别并加载通用驱动。例如USB接口所使用的OHCIOpen Host Controller Interface用于USB2.0 Full Speed或其他接口、EHCIEnhanced Host Controller Interface用于USB2.0 High Speed、XHCIeXtensible Host Controller Interface用于USB3.0SATA接口所使用的AHCIAdvance Host Controller Interface用于SATA接口等。这一类通用接口控制器符合OHCI、EHCI、XHCI或AHCI规范所规定的标准接口定义和操作方法类似于处理器的指令集定义只要符合相应的规范即使真实的设备不同也能够运行标准的驱动程序。
所谓驱动程序就是一组函数包含用于初始化设备、关闭设备或是使用设备的各种相关操作。还是以最简单的串口设备为例如果在设备枚举时找到了一个PCI串口设备它的驱动程序里面可能包含哪些函数呢首先是初始化函数在找到设备后首先执行一次初始化函数以使设备到达可用状态。然后是发送数据函数和接收数据函数。在Linux内核中系统通过调用读写函数接口实现真正的设备操作。在发送数据函数和接收数据函数中需要将设备发送数据和接收数据的方法与读写函数的接口相配合这样在系统调用串口写函数时能够通过串口发送数据调用串口读函数时能够得到串口接收到的数据。此外还有中断处理函数当串口中断发生时让中断能够进入正确的处理函数通过读取正确的中断状态寄存器找到中断发生的原因再进行对应的处理。
当然为了实现所有设备的共同工作还需要其他PCI协议特性的支持。
首先就是对于设备所需IO空间和Memory空间的灵活设置。从图\@ref(fig:config-reg)可以看到在配置空间中并没有设备本身功能上所使用的寄存器。这些寄存器实际上是由可配置的IO空间或Memory空间来索引的。
图\@ref(fig:config-reg)的配置空间中存在6组独立的基址寄存器Base Address Registers简称BAR。这些BAR一方面用于告诉软件该设备所需要的地址空间类型及其大小另一方面用于接收软件给其配置的基地址。
BAR的寄存器定义如图\@ref(fig:bar-reg)所示其最低位表示该BAR是IO空间还是Memory空间。BAR中间有一部分只读位为0正是这些0的个数表示该BAR所映射空间的大小也就是说BAR所映射的空间为2的幂次方大小。BAR的高位是可写位用来存储软件设置的基地址。
```{r bar-reg, echo=FALSE, fig.align='center', fig.cap="BAR的寄存器定义", out.width='100%'}
knitr::include_graphics("images/chapter7/bar_reg.png")
```
在这种情况下对一个BAR的基地址配置方式首先是确定BAR所映射空间的大小再分配一个合适的空间给其高位基地址赋值。确定BAR空间大小的方法也很巧妙只要给这个寄存器先写入全1的值再读出来观察0的个数即可得到。
对PCI设备的探测和驱动加载是一个递归调用过程大致算法如下
1将初始总线号、初始设备号、初始功能号设为0。
2使用当前的总线号、设备号、功能号组成一个配置空间地址这个地址的构成如图\@ref(fig:bus-access-type)所示使用该地址访问其0号寄存器检查其设备号。
3如果读出全1或全0表示无设备。
4如果该设备为有效设备检查每个BAR所需的空间大小并收集相关信息。
5检测其是否为一个多功能设备如果是则将功能号加1再重复扫描执行第2步。
6如果该设备为桥设备则给该桥配置一个新的总线号再使用该总线号从设备号0、功能号0开始递归调用执行第2步。
7如果设备号非31则设备号加1继续执行第2步如果设备号为31且总线号为0表示扫描结束如果总线号非0则退回上一层递归调用。
通过这个递归调用就可以得到整个PCI总线上的所有设备及其所需要的所有空间信息。有了这些信息就可以使用排序的方法对所有的空间从大到小进行分配。最后利用分配的基地址和设备的ID信息加载相应的驱动就能够正常使用该设备。
下面是从龙芯3A处理器PCI初始化代码中抽取出的程序片段。通过这个片段可以比较清楚地看到整个软件处理过程。
```
void _pci_businit(int init)
{
……
/* 这里的pci_roots用于表示系统中有多少个根节点通常的计算机系统中都为1 */
for (i=0,pb=pci_head;i<pci_roots;i++,pb=pb->next) {
_pci_scan_dev(pb, i, 0, init);
}
……
/* 对地址窗口等进行配置 */
_setup_pcibuses(init);
}
static void _pci_scan_dev(struct pci_pdevice *dev, int bus, int device, int initialise)
{
/* 对本级总线扫描所有32个设备位置判断是否存在设备 */
for (;device<32; device++) {
_pci_query_dev(dev,bus,device,initialize);
}
}
static void _pci_query_dev(struct pci_device *dev, int bus, int device, int initialise)
{
……
misc = _pci_conf_read(tag, PCI_BHLC_REG);
/* 检测是否为多功能设备 */
if(PCI_HDRTYPE_MULTIFN(misc)){
for(function=0;function<8;function++){
tag = _pci_make_tag(bus,device,function);
id = _pci_conf_read(tag, PCI_ID_REG);
if(id==0 || id==0xFFFFFFFF){
continue;
}
_pci_query_dev_func(dev,tag,initialise);
}
} else {
_pci_query_dev_func(dev,tag,initialise);
}
}
void _pci_query_dev_func(struct pci_device *dev, pcitag tag, int initialise)
{
……
/* 读取配置头上的设备类别 */
class = _pci_conf_read(tag, PCI_CLASS_REG);
/* 读取配置头上的厂商ID和设备ID */
id = _pci_conf_read(tag, PCI_ID_REG);
……
/* 如果是桥设备,需要递归处理下级总线 */
if(PCI_ISCLASS(class,PCI_CLASS_BRIDGE,PCI_SUBCLASS_BRIDGE_PCI)){
/* 开始递归调用 */
……
pd->bridge.pribus_num = bus;
pd->bridge.secbus_num = ++_pci_nbus;
……
/* 收集整个下级总线所需要的资源信息 */
_pci_scan_dev(pd, pd->bridge.secbus_num, 0, initialise);
……
/*收集下级总线mem/IO空间信息*/
} else {
……
/*收集本设备mem/IO空间信息*/
}
}
```
假设Memory空间的起始地址为0x40000000在设备扫描过程中发现了USB控制器、显示控制器和网络控制器三个设备对于Memory空间的需求如表\@ref(tab:space-requirement)所示。
```{r space-requirement, echo = FALSE, message=FALSE, tab.cap='三个设备的空间需求'}
autonum <- run_autonum(seq_id = "tab", bkm = "space-requirement", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/space_requirement.csv') %>%
flextable() %>%
set_caption(caption="三个设备的空间需求", autonum = autonum) %>%
merge_v(j=1:2) %>%
theme_box() %>%
autofit()
```
在得到以上信息后软件对各个设备的空间需求进行排序并依次从Memory空间的起始地址开始分配最终得到的设备地址空间分布如表\@ref(tab:space-allocation)所示。
```{r space-allocation, echo = FALSE, message=FALSE, tab.cap='三个设备的地址空间分布'}
autonum <- run_autonum(seq_id = "tab", bkm = "space-allocation", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/space_allocation.csv') %>%
flextable() %>%
set_caption(caption="三个设备的地址空间分布", autonum = autonum) %>%
merge_v(j=1:2) %>%
theme_box() %>%
autofit()
```
经过这样的设备探测和驱动加载过程,可以将键盘、显卡、硬盘或者网卡等设备驱动起来,在这些设备上加载预存的操作系统,就完成了整个系统的正常启动。
如果把CPU比作一个大房间至此房间内灯火通明门窗均已打开门窗外四通八达。CPU及相关硬件处于就绪状态。
## 多核启动过程
上面几节主要讨论了从处理器核初始化、总线初始化、外设初始化到操作系统加载的启动过程。启动过程中多处理器核间的相互配合将在本节进行讨论。
实现不同处理器核之间相互同步与通信的一种手段是核间中断与通信信箱机制。在龙芯3号处理器中为每个处理器核实现了一组核间通信寄存器包括一组中断寄存器和一组信箱寄存器。这组核间通信寄存器也属于IO寄存器的一种。实际上信箱寄存器完全可以通过在内存中分配一块地址空间实现这样CPU访问延迟更短。而专门使用寄存器实现的信箱寄存器更多是为了在内存还没有初始化前就让不同的核间能够有效通信。
### 初始化时的多核协同
在BIOS启动过程中为了简化处理流程实际上并没有用到中断寄存器对于各种外设也没有使用中断机制都是依靠处理器的轮询来实现与其他设备的协同工作。
为了简化多核计算机系统的启动过程,我们将多核处理器中的一个核定为主核,其他核定为从核。主核除了对本处理器核进行初始化之外,还要负责对各种总线及外设进行初始化;而从核只需要对本处理器核的私有部件进行初始化,之后在启动过程中就可以保持空闲状态,直至进入操作系统再由主核进行调度。
从核需要初始化的工作包括哪些部分呢首先是从核私有的部分。所谓私有就是其他处理器核无法直接操纵的部件例如核内的私有寄存器、TLB、私有Cache等这些器件只能由每个核自己进行初始化而无法由其他核代为进行。其次还有为了加速整个启动过程由主核分配给从核做的工作例如当共享Cache的初始化操作非常耗时的时候可以将整个共享Cache分为多个部分由不同的核负责某一块共享Cache的初始化通过并行处理的方式进行加速。
主核的启动过程与前三节介绍的内容基本是一致的。但在一些重要的节点上则需要与从核进行同步与通信或者说通知从核系统已经到达了某种状态。为了实现这种通知机制可以将信箱寄存器中不同的位定义为不同的含义一旦主核完成了某些初始化阶段就可以给信箱寄存器写入相应的值。例如将信箱寄存器的第0位定义为“串口初始化完成”标志第1位定义为“共享Cache初始化完成”标志第2位定义为“内存初始化完成”标志。
在主核完成串口的初始化后可以向自己的信箱寄存器写入0x1。从核在第一次使用串口之前需要查询主核的信箱寄存器如果第0位为0则等待并轮询如果非0则表示串口已经初始化完成可以使用。
在主核完成了共享Cache的初始化后向自已的信箱寄存器写入0x3。而从核在初始化自己的私有Cache之后还不能直接跳转到Cache空间执行必须等待信号以确信主核已将全部的共享Cache初始化完成然后再开始Cache执行才是安全的。
在主核完成了内存初始化后其他核才能使用内存进行数据的读写操作。那么从核在第一次用到内存之前就必须等待表示内存初始化完成的0x7标志。
### 操作系统启动时的多核唤醒
当从核完成了自身的初始化之后如果没有其他工作需要进行就会跳转到一段等待唤醒的程序。在这个等待程序里从核定时查询自己的信箱寄存器。如果为0则表示没有得到唤醒标志。如果非0则表示主核开始唤醒从核此时从核还需要从其他几个信箱寄存器里得到唤醒程序的目标地址以及执行时的参数。然后从核将跳转到目标地址开始执行。
以下为龙芯3A5000的BIOS中从核等待唤醒的相关代码。
```
slave_main:
# NODE0_CORE0_BUF0为0号核的信箱寄存器地址其他核的信箱寄存器地址与之
# 相关,在此根据主核的核号,确定主核信箱寄存器的实际地址
dli t2, NODE0_CORE0_BUF0
dli t3, BOOTCORE_ID
sll.d t3, 8
or t2, t2, t3
# 等待主核写入初始化完成标志
wait_scache_allover:
ld.w t4, t2, FN_OFF
dli t5, SYSTEM_INIT_OK
bne t4, t5, wait_scache_allover
# 对每个核各自的信箱寄存器进行初始化
bl clear_mailbox
waitforinit:
li a0, 0x1000
idle1000:
addiu a0, -1
bnez a0, idle1000
# t2为各个核的信箱寄存器地址轮询等待
# 通过读取低32位确定是否写入再读取64位得到完整地址
ld.w t1, t2, FN_OFF
beqz t1, waitforinit
# 从信箱寄存器中的其他地方取回相关启动参数
ld.d t1, t2, FN_OFF
ld.d sp, t2, SP_OFF
ld.d gp, t2, GP_OFF
ld.d a1, t2, A1_OFF
# 转至唤醒地址,开始执行
move ra, t1
jirl zero, ra, 0x0
```
在操作系统中,主核在各种数据结构准备好的情况下就可以开始依次唤醒每一个从核。唤醒的过程也是串行的,主核唤醒从核之后也会进入一个等待过程,直到从核执行完毕再通知主核,再唤醒一个新的从核,如此往复,直至系统中所有的处理器核都被唤醒并交由操作系统管理。
### 核间同步与通信
操作系统启动之前利用信箱寄存器进行了大量的多核同步与通信操作但在操作系统启动之后除了休眠唤醒一类的操作却基本不会用到信箱寄存器。Linux内核中只需要使用核间中断就可以完成各种核间的同步与通信操作。
核间中断也是利用一组IO寄存器实现的。通过将目标核的核间中断寄存器置1来产生一个中断信号使目标核进入中断处理。中断处理的具体内容则是通过内存进行交互的。内核中为每个核维护一个队列内存中的一个数据结构当一个核想要中断其他核时它将需要处理的内容加入目标核的这个队列然后再向目标核发出核间中断设置其核间中断寄存器。当目标核被中断之后开始处理其核间通信队列如果其间还收到了更多的核间中断请求也会一并处理。
为什么Linux内核中的核间中断处理不通过信箱寄存器进行呢首先信箱寄存器只有一组也就是说如果通过信箱寄存器发送通信消息在这个消息没被处理之前是不能有其他核再向其发出新的核间中断的。这样无疑会导致核间中断发送方的阻塞。另外核间中断寄存器实际上是IO寄存器前面我们提到对于IO寄存器的访问是通过不经缓存这种严格访问序的方式进行的相比于Cache访问方式不经缓存读写效率极其低下本身延迟开销很大还可能会导致流水线的停顿。因此在实际的内核中只有类似休眠唤醒这种特定的同步操作才会利用信箱寄存器进行其他的同步通信操作则是利用内存传递信息并利用核间中断寄存器产生中断的方式共同完成的。
## 本章小结
本章的目的是使读者了解最基本的计算机软硬件协同。计算机系统从上电复位到引导操作系统启动的基本过程是从处理器核的初始化开始,经过芯片的各种接口总线的初始化,再到各种外围设备的初始化,最终完成了操作系统引导的准备工作。整个启动过程的大部分工作是串行的。对于多核处理器,启动过程中还会穿插着一些多核协同的处理工作。
系统启动的整个过程中,计算机系统在软件的控制下由无序到有序,所有的组成部分都由程序管理,按照程序的执行发挥各自的功能,最终将系统的控制权安全交到操作系统手中,完成整个启动过程。
## 习题
1. 什么情况下需要对Cache进行初始化LoongArch中Cache初始化过程中所使用的Cache指令Index Store Tag的作用是什么
2. Cache初始化和内存初始化的目的有什么不同系统如何识别内存的更换
3. 从HyperTransport配置地址空间的划分上计算整个系统能够支持的总线数量、设备数量及功能数量。
4. 根据PCI地址空间命中方法及BAR的配置方式给出地址空间命中公式。
5. 多核唤醒时,如果采用核间中断方式,从核的唤醒流程是怎样的?
6. 在一台Linux机器上通过“lspci -v”命令查看该机器的设备列表并列举其中三个设备的总线号、设备号和功能号通过其地址空间信息写出该设备BAR的实际内容。
\newpage