-
Notifications
You must be signed in to change notification settings - Fork 300
/
17-boot.Rmd
657 lines (459 loc) · 55.7 KB
/
17-boot.Rmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
# 计算机启动过程分析
前面章节主要从计算机硬件的角度对构成计算机系统的各个主要部分进行了介绍。为了描述计算机硬件系统各部分之间的相互关系,本章将对计算机从开机到点亮屏幕,接收键盘输入,再到引导并启动整个操作系统的具体过程进行探讨。与本书其他章节一样,本章基于LoongArch架构进行介绍,具体则以龙芯3号处理器的启动过程为例。
无论采用何种指令系统的处理器,复位后的第一条指令都会从一个预先定义的特定地址取回。处理器的执行就从这条指令开始。处理器的启动过程,实际上就是一个特定程序的执行过程。这个程序我们称之为固件,又称为BIOS(Basic Input Output System,基本输入输出系统)。对于LoongArch,处理器复位后的第一条指令将固定从地址0x1C000000的位置获取。这个地址需要对应一个能够给处理器核提供指令的设备,这个设备以前是各种ROM,现在通常是闪存(Flash)。从获取第一条指令开始,计算机系统的启动过程也就开始了。
为了使计算机达到一个最终可控和可用的状态,在启动过程中,需要对包括处理器核、内存、外设等在内的各个部分分别进行初始化,再对必要的外设进行驱动管理。本章的后续内容将对这些具体工作进行讨论。
## 处理器核初始化
在讨论这个过程之前,先来定义什么叫作初始化。所谓初始化,实际上是将计算机内部的各种寄存器状态从不确定设置为确定,将一些模块状态从无序强制为有序的过程。简单来说,就是通过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的寄存器是一个随机值,就有可能导致取指猜测错误,发出一个对非法地址的猜测请求。如果此时处理器没有对猜测访问通路进行控制或初始化,就可能会发生严重问题,例如猜测访问落入地址空洞而失去响应并导致死机等。
为了防止这个问题,在处理器开始执行之后,一方面需要先对相关的寄存器内容进行初始化,设置为一个正常地址值,另一方面则需要对地址空间进行处理,防止出现一般情况下不可访问的地址空洞。这样即使发生了这种猜测访问,也可以得到响应,避免系统出错或死机。
### 处理器复位 {#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偏移)中的比特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)的高性能处理器来说,几千拍才能执行一条指令,相当于在城市空荡荡的大街上只有一个人在行走,这时候的指令很“孤独”。
```{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
#线路控制寄存器,写入0x80(128)表示后续的寄存器访问为分频寄存器访问
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界面上,看起来就像是键盘输入给了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设备时,与访问内存“直来直去”的行为是完全不同的。
### 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
# 设置页大小为4K,31位为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。在主板上使用时,也可以灵活地增加或者减小内存容量,甚至改变内存条种类,例如将UDIMM(Unbuffered DIMM,非缓冲型内存模组)改为RDIMM(Registered 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接口所使用的OHCI(Open Host Controller Interface,用于USB2.0 Full Speed或其他接口)、EHCI(Enhanced Host Controller Interface,用于USB2.0 High Speed)、XHCI(eXtensible Host Controller Interface,用于USB3.0),SATA接口所使用的AHCI(Advance 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