最后对指定体系架构初始化
最后更新于
最后更新于
这是 Linux 内核初始化过程的第七部分,主要解析 文件中 setup_arch
函数内部机制。通过可知,setup_arch
函数执行架构相关(本文以 为例)的初始化工作,包括为内核代码/数据/bss 保留内存,的早期扫描, 设备的早期转储等众多操作。如果您已经阅读了前面的,会记得我们是在 setup_real_mode
函数处结束的。接下来,在我们限制 为所有已映射页后,可以看到 中调用了 setup_log_buf
函数。
setup_log_buf
函数用于设置内核循环缓冲区,其长度取决于 CONFIG_LOG_BUF_SHIFT
配置选项。从文档中可知,CONFIG_LOG_BUF_SHIFT
取值范围在 12
到 21
之间。在内部实现中,该缓冲区定义为字符数组:
现在我们来看 setup_log_buf
函数的实现。它首先会检查当前缓冲区是否为空(因为缓冲区刚刚完成初始化,所以其必然为空),同时还会检查是否为早期初始化阶段。如果内核日志缓冲区的设置不属于早期初始化阶段,则会调用 log_buf_add_cpu
函数,该函数会为每个 CPU 扩展缓冲区的大小:
这里我们暂不深入分析 log_buf_add_cpu
函数,因为正如 setup_arch
中所示,我们是通过以下方式调用 setup_log_buf
的:
其中参数 1
表示当前处于早期初始化阶段。接下来,我们会检查 new_log_buf_len
变量(该变量表示更新后的内核日志缓冲区长度),并通过 memblock_virt_alloc
函数为其分配新的缓冲区空间,否则直接返回。
当内核日志缓冲区准备就绪后,下一个执行的是 reserve_initrd
函数。您可能还记得,在中我们已经调用过 early_reserve_initrd
函数。现在,由于我们已在 init_mem_mapping
函数中重建了直接内存映射,因此需要将移入直接映射内存区域。reserve_initrd
函数首先确定 initrd 的基地址和结束地址,并检查 bootloader 是否提供了 initrd —— 这些操作与我们在 early_reserve_initrd
中看到的完全一致。但不同于之前通过调用 memblock_reserve
在 memblock 区域中保留空间的做法,这里我们会获取直接内存映射区域的映射大小,并通过以下方式确保 initrd 的大小不超过该区域:
接下来,我们会打印关于 initrd
大小的信息。通过 dmesg
命令的输出可以看到如下结果:
随后通过 relocate_initrd
函数将 initrd
重定位到直接映射区域。在 relocate_initrd
函数起始处,我们会尝试使用 memblock_find_in_range
函数来寻找可用内存区域:
memblock_find_in_range
函数会尝试在指定范围内(本例中是从 0
到最大已映射物理地址)寻找可用区域,且该区域大小必须等于经过对齐处理的 initrd
大小。如果未能找到符合要求的区域,系统将再次调用 panic
函数终止运行。若成功找到合适区域,我们将在下一步将 RAM 磁盘重定位至直接映射内存的末端。
在 reserve_initrd
函数的最后阶段,通过调用以下函数释放原 RAM 磁盘占用的 memblock 内存:
其中第一个参数表示保留区域的大小(以字节为单位),第二个参数是保留区域的基地址,第三个参数是保留区域的结束地址,最后一个 fixed
参数用于指定保留区域的放置方式。当 fixed
为 1
时,直接通过 memblock_reserve
保留内存区域;若为 0
,则使用 kmemleak_alloc
动态分配空间。在接下来的步骤中,函数会检查 size_cmdline
变量。若该变量不等于 -1
(表示已通过命令行参数指定大小),则将使用来自 cma
内核命令行参数的值填充上述所有变量:
在该源码文件中可以找到如下早期参数的定义:
其中 cma
表示:
如果未向内核命令行传递 cma
参数,则 size_cmdline
将保持默认值 -1
。此时,系统需要根据以下内核配置选项来计算保留区域的大小:
CONFIG_CMA_SIZE_SEL_MBYTES
- 该选项表示以兆字节(MB)为单位的默认全局连续内存分配器(CMA)区域大小,其计算公式为 CMA_SIZE_MBYTES * SZ_1M
或等价的 CONFIG_CMA_SIZE_MBYTES * 1M
;
CONFIG_CMA_SIZE_SEL_PERCENTAGE
- 总内存的百分比;
CONFIG_CMA_SIZE_SEL_MIN
- 取较小值;
CONFIG_CMA_SIZE_SEL_MAX
- 取较大值;
在计算出保留区域的大小后,系统将通过调用 dma_contiguous_reserve_area
函数来实际保留该内存区域。该函数的执行流程首先会调用以下函数:
cma_declare_contiguous
函数用于从指定的基地址开始保留一块连续的物理内存区域,其大小由参数指定。完成 DMA 区域的内存保留后,接下来会调用 memblock_find_dma_reserve
函数——顾名思义,该函数用于统计 DMA 区域中已保留的页框数量。由于 CMA(连续内存分配器)和 DMA 相关实现较为复杂,本文暂不深入探讨所有细节。我们将在后续专门讲解 Linux 内核内存管理的章节中,详细分析连续内存分配器及其内存区域的实现机制。
接下来将调用 x86_init.paging.pagetable_init
函数。若您在内核源码中追溯该函数的实现,最终会发现以下宏定义:
再次说明,本部分及后续部分不会完整详细地涵盖这一主题。关于 NUMA 将有专门的章节进行讲解。
第二个变量是 physaddr_vsyscall
,它仅存储 __vsyscall_page
符号的物理地址。在接下来的步骤中,我们会检查 vsyscall_mode
变量,如果它不等于 NONE
(默认情况下是 EMULATE
模式):
随后我们会看到调用 __set_fixmap
函数,该函数会以相同的参数调用 native_set_fixmap
:
这里我们可以看到 native_set_fixmap
根据给定的物理地址(在我们的例子中是 __vsyscall_page
符号的物理地址)生成页表项的值,并调用内部函数 __native_set_fixmap
。这个内部函数获取给定 fixed_addresses
索引(在我们的例子中是 VSYSCALL_PAGE
)的虚拟地址,并检查给定的索引不超过 fix-mapped 地址的结束范围。之后我们通过调用 set_pte_vaddr
函数设置页表项,并增加 fix-mapped 地址的计数。在 map_vsyscall
的最后,我们检查 VSYSCALL_PAGE
(这是 fixed_addresses
中的第一个索引)的虚拟地址不超过 VSYSCALL_ADDR
,即 -10UL << 20
或 ffffffffff600000
,这是通过 BUILD_BUG_ON
宏实现的。
至此,我们完成了对 setup_arch
这个庞大函数的分析。虽然如我多次提到的,我们尚未涵盖该函数的全部细节,但不必担心。在后续不同章节中,我们还会多次回顾这个函数,以理解各种平台相关部分是如何初始化的。
现在我们可以从 setup_arch
返回到 start_kernel
函数继续分析了。
在接下来的步骤中,我们会看到以下函数的调用:
该函数接收内核命令行指针,并分配两个缓冲区来存储命令行。我们需要两个缓冲区,因为一个用于将来引用和访问命令行,另一个用于参数解析。我们将为以下缓冲区分配空间:
saved_command_line
- 将保存启动命令行;
initcall_command_line
- 将保存启动命令行,将在 do_initcall_level
中使用;
static_command_line
- 将保存用于参数解析的命令行。
让我们看看 setup_command_line
的实现:
这里我们为三个缓冲区分配了空间,这些缓冲区将存储用于不同目的的内核命令行(如上所述)。完成空间分配后,我们将 boot_command_line
存入 saved_command_line
,并将来自 setup_arch
的 command_line
(内核命令行)存入 static_command_line
。
这里 nr_cpu_ids
表示实际可用的 CPU 数量,而 NR_CPUS
表示在配置时可设置的最大 CPU 数量:
实际上我们需要调用这个函数,因为 NR_CPUS
可能会大于您计算机中实际的 CPU 数量。这里我们可以看到调用了 find_last_bit
函数并传递了两个参数:
cpu_possible_mask
位图;
CPU 的最大数量。
在 setup_arch
中,我们可以找到 prefill_possible_map
函数的调用,它计算实际 CPU 数量并写入 cpu_possible_mask
。find_last_bit
函数接收地址和最大搜索范围作为参数,返回第一个置位(1)的位号。我们传入了 cpu_possible_mask
位图和 CPU 的最大数量。
其中在 x86_64
架构上,BITS_PER_LONG
的值为 64
。当我们获得搜索数据给定大小中的字数后,需要通过以下检查确认给定大小是否包含不完整的字:
如果存在不完整的字,我们将对最后一个字进行掩码处理并检查它。如果最后一个字不为零,则表明当前字至少包含一个置位。此时程序将跳转到 found
标签处继续执行:
这里您可以看到 __fls
函数,它通过 bsr
(Bit Scan Reverse)指令的帮助返回给定字中最后一个置位的位号:
bsr
指令会扫描给定的操作数以查找第一个置位。如果最后一个字不是部分字,我们将遍历给定地址中的所有字,尝试找到第一个置位:
这里我们将最后一个字存入 tmp
变量,并检查 tmp
是否包含至少一个置位。如果找到置位,就返回该位的编号。如果所有字都不包含置位,则直接返回给定的搜索范围大小:
完成这些操作后,nr_cpu_ids
将包含正确的可用 CPU 数量。
至此架构相关初始化部分分析完毕。
这是关于 Linux 内核初始化过程的第七部分的结尾。在本次分析中,我们最终完成了对 setup_arch
函数的研究,并返回到 start_kernel
函数。在下一部分中,我们将继续学习 start_kernel
中的通用内核代码,沿着内核启动路径深入,直到第一个 init
进程的创建。
可以看到,这里我们调用了 memblock_mem_size
函数,并将 max_pfn_mapped
作为参数传入。其中,max_pfn_mapped
存储的是当前直接映射的最高页帧号(Page Frame Number)。若不记得什么是页帧号,这里简单说明:虚拟地址的低 12
位表示物理页(页帧)的偏移量。当我们右移虚拟地址的 12
位时,将丢弃偏移部分,从而得到页帧号。在 memblock_mem_size
函数内部,我们会遍历所有 memblock 的 mem
区域(不包括保留区域),计算已映射页面的总大小,并将结果返回给 mapped_size
变量(参见上文代码)。获取到直接映射内存的总量后,我们会检查 initrd
的大小是否超过已映射的页面范围。如果超出,则直接调用 panic
函数终止系统运行,并打印著名的信息。
在完成 initrd
RAM 磁盘镜像的重定位后,接下来执行的是位于 的 vsmp_init
函数。该函数用于初始化 ScaleMP vSMP
架构支持。如先前章节所述,本文不会涉及与 x86_64
初始化无关的内容(例如当前的 ACPI
等)。因此我们将暂时跳过其具体实现,留待后续讲解并行计算技术时再作探讨。
随后调用的是 中的 io_delay_init
函数。该函数允许覆盖默认的 I/O 延迟端口 0x80
。我们已在中接触过 I/O 延迟的概念,现在让我们深入分析 io_delay_init
的具体实现:
该函数会检查 io_delay_override
变量,若该变量被设置,则覆盖默认的 I/O 延迟端口。我们可以通过向内核命令行传递 io_delay
参数来设置 io_delay_override
变量。根据 文档说明,io_delay
选项是:
我们可以看到,在 文件中,io_delay
命令行参数是通过 early_param
宏进行设置的:
关于 early_param
宏的更多细节,您可以在中查阅。因此,用于设置 io_delay_override
变量的 io_delay_param
函数将会在 函数中被调用。io_delay_param
函数通过解析 io_delay
内核命令行参数,根据传入值设置相应的 io_delay_type
:
在 io_delay_init
之后,接下来执行的函数依次是 acpi_boot_table_init
、early_acpi_boot_init
和 initmem_init
。不过正如前文所述,在当前的「Linux 内核初始化流程」章节中,我们将不涉及与 相关的内容。
下一步我们需要通过 dma_contiguous_reserve
函数为分配专用区域,该函数定义于 。DMA 是一种特殊工作模式,设备无需 CPU 介入即可直接与内存通信。请注意,我们向 dma_contiguous_reserve
函数传递了一个关键参数——max_pfn_mapped << PAGE_SHIFT
。从该表达式可以理解,此参数表示可保留内存的上限地址(将最大页帧号转换为字节地址)。让我们分析该函数的实现,它从定义以下变量开始:
该宏如您所见,会展开调用 中的 paging_init
函数。paging_init
函数负责初始化稀疏内存和内存区域大小。首先需要了解什么是内存区域(zone)以及什么是 Sparsemem
。Sparsemem
是 Linux 内核内存管理器中用于在 系统中将内存区域划分为不同内存组(memory bank)的特殊基础架构。让我们看看 paging_init
函数的实现:
可以看到,这里调用了 sparse_memory_present_with_active_regions
函数,该函数会为每个 NUMA 节点记录内存区域到 mem_section
结构数组中,该结构包含指向 struct page
数组结构的指针。随后的 sparse_init
函数会分配非线性的 mem_section
和 mem_map
。接下来我们会清除可移动内存节点的状态并初始化各内存区域(zone)的大小。每个 NUMA 节点都被划分为多个称为"zone"的部分。因此,来自 的 zone_sizes_init
函数就是用于初始化这些zone的大小的。
在 SparseMem
初始化之后,下一步是设置 trampoline_cr4_features
,它必须包含 cr4
的内容。首先我们需要检查当前 CPU 是否支持 cr4
寄存器,如果支持,就将其内容保存到 trampoline_cr4_features
,这是在实模式下存储 cr4
的地方。
接下来您会看到的是 的 map_vsyscall
函数。这个函数为 映射内存空间,其功能依赖于内核配置选项 CONFIG_X86_VSYSCALL_EMULATION
。实际上,vsyscall
是一个特殊的段,它提供了对某些系统调用(如 getcpu
等)的快速访问。该函数的具体实现如下:
在 map_vsyscall
函数的开头,我们可以看到两个变量的定义。第一个是外部变量 __vsyscall_page
。作为外部变量,它实际上是在其他源文件中定义的。我们可以在 中找到 __vsyscall_page
的定义。__vsyscall_page
符号指向对齐的 vsyscalls 调用,如 gettimeofday
等:
现在,vsyscall
区域已被置于固定映射(fix-mapped)地址区域。关于 map_vsyscall
的内容就是这些。如果您对固定映射地址不熟悉,可以参考一文。我们将在后续关于 vsyscalls
和 vdso
的章节中更详细地探讨 vsyscalls
的实现机制。
您可能还记得我们在前一部分中是如何搜索 配置的。现在,如果找到了 SMP 配置,我们需要获取它。为此,我们检查在 smp_scan_config
函数中设置的 smp_found_config
变量(关于该函数请参阅前一部分),并调用 get_smp_config
函数:
get_smp_config
展开为 x86_init.mpparse.default_get_smp_config
函数,该函数定义于 。这个函数定义了指向多处理器浮点指针结构 mpf_intel
的指针(您可以在前文中阅读相关内容),并执行以下检查:
这里我们可以看到,如果在 smp_scan_config
函数中找到了多处理器配置就继续执行,否则直接返回函数。接下来的检查是 acpi_lapic
和 early
标志。完成这些检查后,我们开始读取 SMP 配置。读取完成后,下一步是调用 prefill_possible_map
函数,该函数会预先填充可能的 CPU 的 cpumask
(更多关于此的内容可参阅 )。
现在我们已经接近 setup_arch
函数的尾声。虽然剩余部分也很重要,但本部分不会详细讨论这些内容。我们将简要浏览这些函数,因为它们主要涉及 NUMA
、SMP
、ACPI
和 APIC
等非通用内核特性。首先调用的是 init_apic_mappings
函数,它负责设置本地 的地址。接着是 x86_io_apic_ops.init
函数,用于初始化 I/O APIC(关于 APIC 的完整细节将在中断和异常处理章节介绍)。随后通过 x86_init.resources.reserve_resources
调用保留标准 I/O 资源(如 DMA
、TIMER
、FPU
等)。然后是初始化机器检查异常的 mcheck_init
函数,最后是注册 的 register_refined_jiffies
(内核定时器将有专门章节讨论)。
如前所述,我们已经完成了对 setup_arch
函数的分析,现在可以回到 中的 start_kernel
函数。您可能记得或已经注意到,start_kernel
函数与 setup_arch
一样庞大,因此接下来的几个部分将专门学习这个函数。
在 setup_arch
之后,我们可以看到 mm_init_cpumask
函数的调用。这个函数将 指针设置到内存描述符的 cpumask
中。让我们看看它的实现:
如您所见,在 中我们将 init 进程的内存描述符传递给 mm_init_cpumask
函数,并根据 CONFIG_CPUMASK_OFFSTACK
配置选项来决定是否清除 并切换 cpumask
。
我们将使用 memblock_virt_alloc
函数分配空间。这个函数调用 memblock_virt_alloc_try_nid
,如果 不可用,则用 memblock_reserve
分配启动内存块,否则使用 kzalloc_node
(更多相关内容将在Linux内存管理章节介绍)。memblock_virt_alloc
使用 BOOTMEM_LOW_LIMIT
(值为 PAGE_OFFSET + 0x1000000
的物理地址)和 BOOTMEM_ALLOC_ACCESSIBLE
(等于当前 memblock.current_limit
的值)作为内存区域的最小地址和最大地址。
在 setup_command_line
之后的下一个函数是 setup_nr_cpu_ids
。该函数根据 cpu_possible_mask
的最后一位来设置 nr_cpu_ids
(CPU 的数量)。关于此概念的更多细节,您可以阅读描述 概念的章节。让我们看看它的实现:
首先,find_last_bit
函数将给定的 unsigned long
地址分割成:
如果您有任何疑问或建议,欢迎在评论区留言,或通过 与我联系。
Please note that English is not my first language, And I am really sorry for any inconvenience. If you find any mistakes please send me PR to .