进一步初始化指定体系架构

内核初始化 第六部分

仍旧是与系统架构有关的初始化

在之前的章节我们从 arch/x86/kernel/setup.c了解了特定于系统架构的初始化事务(在我们的例子中是 x86_64 架构),并且通过 x86_configure_nx 函数根据对NX bit的支持配置了 _PAGE_NX 标志位。正如我之前写的, setup_arch 函数和 start_kernel 都非常复杂,所以在这个和下个章节我们将继续学习关于系统架构初始化进程的内容。x86_configure_nx 函数的下面是 parse_early_param 函数。这个函数定义在 init/main.c 中并且你可以从它的名字中了解到,这个函数解析内核命令行并且基于给定的参数创建不同的服务 (所有的内核命令行参数你都可以在 Documentation/kernel-parameters.txt 找到)。 你可能记得在最前面的 章节 我们是怎样创建 earlyprintk地。在前面我们用 arch/x86/boot/cmdline.c 里面的 cmdline_find_option__cmdline_find_option, __cmdline_find_option_bool 函数的帮助下寻找内核参数及其值。我们在通用内核部分不依赖于特定的系统架构,在这里我们使用另一种方法。 如果你正在阅读linux内核源代码,你可能注意到这样的调用:

early_param("gbpages", parse_direct_gbpages_on);

early_param 宏需要两个参数:

  • 命令行参数的名称

  • 如果给定的参数通过,函数将被调用

函数定义如下:

#define early_param(str, fn) \
        __setup_param(str, fn, fn, 1)

这个定义可以在 include/linux/init.h 中可以找到. 正如你所看到的, early_param 宏只是调用了 __setup_param 宏:

#define __setup_param(str, unique_id, fn, early)                \
        static const char __setup_str_##unique_id[] __initconst \
                __aligned(1) = str; \
        static struct obs_kernel_param __setup_##unique_id      \
                __used __section(.init.setup)                   \
                __attribute__((aligned((sizeof(long)))))        \
                = { __setup_str_##unique_id, fn, early }

这个宏内部定义了 __setup_str_*_id 变量 (这里的 * 取决于给定的函数名称),然后把给定的命令行参数赋值给这个变量。在下一行中,我们可以看到定义了一个obs_kernel_param 类型的变量 __setup_ * 并对其进行初始化。

obs_kernel_param 结构体定义如下:

这个结构体包含三个字段:

  • 内核参数的名称

  • 根据不同的参数,选取对应的处理函数

  • 决定参数是否为 early 的标记位

注意 __set_param 宏定义有 __section(.init.setup) 属性。这意味着所有 __setup_str_ * 都将被放置在 .init.setup 区段中,此外正如我们在 include/asm-generic/vmlinux.lds.h 中看到的,.init.setup 区段被放置在 __setup_start__setup_end 之间:

现在我们知道了参数是怎样定义的,让我们一起回到 parse_early_param 的实现上来:

parse_early_param 函数内部定义了两个静态变量。首先第一个变量 done 用来检查 parse_early_param 函数是否已经被调用过,第二个变量是用来临时存储内核命令行的。然后我们把 boot_command_line 的值赋值给刚刚定义的临时命令行变量中( tmp_cmdline ) 并且从相同的源代码文件 main.c 中调用 parse_early_options 函数。 parse_early_options函数从 kernel/params.c 中调用 parse_args 函数, parse_args 解析传入的命令行然后调用 do_early_param 函数。 do_early_param 函数 从 __setup_start 循环到 __setup_end ,如果循环中 obs_kernel_param 实例中的 early 字段值为1 ,就调用 obs_kernel_param 中的第二个函数 setup_func。在这之后所有基于早期命令行参数的服务都已经被创建,在 parse_early_param 之后的下一个函数调用是 x86_report_nx 。 正如我在这章开头所写的,我们已经用 x86_configure_nx 函数配置了 NX-bit 位。接下来我们使用 arch/x86/mm/setup_nx.c 中的 x86_report_nx函数打印出关于 NX 的信息。注意x86_report_nx 函数不一定在 x86_configure_nx 函数之后调用,但是一定在 parse_early_param 之后调用。答案很简单: 因为内核支持 noexec 参数,所以我们一定在 parse_early_param 调用并且解析 noexec 参数之后才能调用 x86_report_nx :

我们可以在启动的时候看到:

NX

之后我们可以看到下面函数的调用:

这个函数的定义也在 arch/x86/kernel/setup.c 中,然后这个函数为 setup_data 重新映射内存并保留内存块(你可以阅读之前的 章节 了解关于 setup_data 的更多内容,你也可以在 Linux kernel memory management 中阅读到关于 ioremap and memblock 的更多内容)。

接下来我们来看看下面的条件语句:

acpi_mps_check 函数来自于 arch/x86/kernel/acpi/boot.c ,它的结果取决于 CONFIG_X86_LOCAL_APICCONFIG_x86_MPPARSE 配置选项:

acpi_mps_check 函数检查内置的 MPS 又称 多重处理器规范 表。如果设置了 CONFIG_X86_LOCAL_APIC 但未设置 CONFIG_x86_MPPAARSE ,而且传递给内核的命令行选项中有 acpi=offacpi=noirq 或者 pci=noacpi 参数,那么acpi_mps_check 函数就会输出警告信息。如果 acpi_mps_check 返回了1,这就表示我们禁用了本地 APIC ,而且 setup_clear_cpu_cap 宏清除了当前CPU中的 X86_FEATURE_APIC 位。(你可以阅读 CPU masks 了解关于CPU mask的更多内容)。

早期的PCI转储

接下来我们通过下面的代码来转储 PCI 设备:

变量 pci_early_dump_regs 定义在 arch/x86/pci/common.c 中,他的值取决于内核命令行参数:pci=earlydump 。我们可以在drivers/pci/pci.c 中看到这个参数的定义:

pci_setup 函数取出 pci= 之后的字符串,然后进行解析。这个函数调用 drivers/pci/pci.c 中用 _weak 修饰符定义的 pcibios_setup 函数,并且每种架构都重写了 _weak 修饰过的函数。 例如, x86_64 架构上的该函数版本在 arch/x86/pci/common.c 中:

如果我们设置了 CONFIG_PCI 选项,而且向内核命令行传递了 pci=earlydump 选项,那么 arch/x86/pci/early.c 中的 early_dump_pci_devices 函数将会被调用。这个函数像下面这样来检查pci参数 noearly :

如果条件不成立则返回。每个PCI域可以承载多达 256 条总线,并且每条总线可以承载多达32个设备。那么接下来我们进入下面的循环:

然后我们通过 read_pci_config 函数来读取 pci 配置。

这就是 pci 加载的全部过程了。我们在这里不会深入研究 pci 的细节,不过我们会在 Drivers/PCI 章节看到更多的细节。

内存解析的完成

early_dump_pci_devices 函数后面,有一些与可用内存和e820相关的函数,其中 e820 的相关信息我们在 内核安装的第一步 章节中整理过。

让我们来一起看看上面的代码。正如你所看到的,第一个函数是 e820_reserve_setup_data 。这个函数和我们前面看到的 memblock_x86_reserve_range_setup_data 函数做的事情几乎是相同的,但是这个函数同时还会调用 e820_update_range 函数,向 e820map 中用给定的类型添加新的区域,在我们的例子中,使用的是 E820_RESERVED_KERN 类型。接下来的函数是 finish_e820_parsing,这个函数使用 sanitize_e820_map 函数对 e820map 进行清理。除了这两个函数之外,我们还可以看到一些与 e820 有关的函数。你可以在上面的列表中看到这些函数。e820_add_kernel_range 函数需要内核开始和结束的物理地址:

函数会检查在 e820map 中被标记成 E820RAM.text .data.bss 区段,如果没有这些区段,那么就会输出错误信息。接下来的 trm_bios_range 函数把 e820Map 中的前4096个字节修改为 E820_RESERVED 并且再次调用函数 sanitize_e820_map 清理 e820map。在这之后我们使用 e820_end_of_ram_pfn 函数得到最后一个页帧的编号,每个内存页面都有一个唯一的编号 - 页帧号e820_end_of_ram_pfn 函数调用 e820_end_pfn 函数返回最大的页面帧号:

e820_end_pfn 函数读取特定于系统架构的最大页帧号(对于 x86_64 架构来说 MAX_ARCH_PFN0x400000000 )。在 e820_end_pfn 函数中我们遍历整个 e820 槽,并且检查 e820 中是否有 E820_RAM 或者 E820_PRAM 类型条目,因为我们只能对这些类型计算页面帧号,然后我们得到当前 e820 页面帧的基地址和结束地址,同时对这些地址进行检查:

接下来我们检查在循环中得到的 last_pfnlast_pfn 不得大于特定于系统架构的最大页帧号(在我们的例子中是 x86_64 系统架构),然后输出关于最大页帧号的信息,并且返回 last_pfn。我们可以在 dmesg 的输出中看到 last_pfn :

在这之后,我们计算出了最大的页帧号,我们要计算 max_low_pfn ,这是 低端内存 或者低于第一个4GB中的最大页面帧。如果系统安装了超过4GB的内存RAM,max_low_pfn 将会是e820_end_of_low_ram_pfn 函数的结果,这个函数和 e820_end_of_ram_pfn 相似,但是有4GB限制,换句话说 max_low_pfnmax_pfn 的值是一样的:

接下来我们通过 __va 宏计算 高端内存 (有更高的内存直接映射上界)中的最大页帧号,并且这个宏会根据给定的物理内存返回一个虚拟地址。

桌面管理接口

在处理完不同内存区域和 e820 槽之后,接下来就该收集计算机的相关信息了。我们将用下面的函数收集与 桌面管理接口 有关的所有信息:

首先是定义在 drivers/firmware/dmi_scan.c 中的 dmi_scan_machine 函数。这个函数遍历 System Management BIOS 结构,并从中提取信息。这里有两种方法来访问 SMBIOS 表: 第一种是从 EFI 的配置表获得指向 SMBIOS 表的指针;第二种是扫描 0xF00000x10000 地址之间的物理内存。让我们一起看看第二种方法。dmi_scan_machine 函数通过 dmi_early_remap 函数将 0xf00000x10000 之间的内存重新映射并追加到 early_ioremap 上:

然后迭代所有的 DMI 头部地址,并且查找 _SM_ 字符串:

_SM_ 字符串一定在 000F0000h0x000FFFFF 地址之间。在这里我们用 memcpy_fromio 函数向 buf 里面拷贝16个字节,这个函数和 memcpy 函数的作用是一样的。然后对这个缓冲区( buf ) 执行dmi_smbios3_presentdmi_present 函数。这些函数检查 buf 的前4个字节是否是 __SM__ 字符串,并且获得 SMBIOS 的版本和 _DMI_ 的属性例如 _DMI_ 的结构表长度、结构表的地址等等... 在其中的一个函数完成之后,你就可以在 dmesg 的输出中看到它的运行结果:

dmi_scan_machine 函数的最后,我们取消之前映射的内存:

第二个函数是 - dmi_memdev_walk。和你想的一样,这个函数遍历整个内存设备。让我们一起看看这个函数:

这个函数检查 DMI 是否可用(我们之前在 dmi_scan_machine 函数中得到了这个结果,并且保存在 dmi_available 变量中),然后使用 dmi_walk_earlydmi_alloc 函数收集内存设备的有关信息,其中 dmi_alloc 的定义如下:

定义在 arch/x86/include/asm/setup.h 中的 RESERVE_BRK 函数会在 brk 段中预留给定大小的空间:


均衡多处理(SMP)的配置

接下来的一步是解析 SMP 的配置信息。我们调用 find_smp_config 函数来完成这个任务,这个函数内部调用另一个函数:

在函数的内部,x86_init.mpparse.find_smp_config 函数就是 arch/x86/kernel/mpparse.c 中的 default_find_smp_config 函数。我们调用 default_find_smp_config 函数扫描内存中的一些区域来寻找 SMP 的配置信息,并在找到它们的时候返回:

首先 smp_scan_config 函数内部定义了一些变量:

第一个变量是我们用来扫描 SMP 配置的内存区域的虚拟地址;第二个变量是指向 mpf_intel 结构体的指针。让我们一起试着去理解 mpf_intel 是什么吧。所有的信息都存储在多处理器配置数据结构中。mpf_intel 就是这个结构,看下来像是下面这样:

正如我们在文档中看到的那样 - 系统 BIOS的主要功能之一就是创建MP浮点型指针结构和MP配置表。而且操作系统必须可以访问关于多处理器配置的有关信息, mpf_intel 中存储了多处理器配置表的物理地址(看结构体的第二个变量),然后,smp_scan_config 函数在指定的内存区域中循环查找 MP floating pointer structure 。这个函数还会检查当前字节是否指向 SMP 签名,然后检查签名的校验和,并且检查循环中的 mpf->specification 的值是1还是4(这个值只能是1或者是4):

如果搜索成功,就调用 memblock_reserve 函数保留一定的内存块,并且为多处理器配置表保留物理地址。你可以在 MultiProcessor Specification 中找到相关的文档。你也可以在 SMP 的特定章节阅读更多细节。

其他的早期内存初始化程序

setup_arch 的下一步,我们可以看到 early_alloc_pgt_buf 函数的调用,这个函数在早期阶段分配页表缓冲区。页表缓冲区将被放置在 brk 区段中。让我们一起看看这个功能的实现:

首先这个函数获得页表缓冲区的大小,它的值是 INIT_PGT_BUF_SIZE ,这个值在目前的linux 4.0 内核中是 (6 * PAGE_SIZE)。因为我们已经得到了页表缓冲区的大小,现在我们调用 extend_brk 函数并且传入两个参数: size和align。你可以从他们的名称中猜到,这个函数扩展 brk 区段。正如我们在linux内核链接脚本中看到的,brk 区段在内存中的位置恰好就在 BSS 区段后面:

我们也可以使用 readelf 工具来找到它:

brk area

之后我们用 _pa 宏得到了新的 brk 区段的物理地址,我们计算页表缓冲区的基地址和结束地址。因为我们之前已经创建好了页面缓冲区,所以现在我们使用 reserve_brk 函数为 brk 区段保留内存块:

注意在 reserve_brk 的最后,我们把 _brk_start 赋值为0,因为在这之后我们不会再为 brk 分配内存了,我们需要使用 cleanup_highmap 函数来释放内核映射中越界的内存区域。请记住内核映射是 __START_KERNEL_map_end - _text 或者 level2_kernel_pgt 对内核 _textdatabss 区段的映射。在 clean_high_map 的开始部分我们定义下面这些参数:

现在,因为我们已经定义了内核映射的开始和结束位置,所以我们在循环中遍历所有内核页中间目录条目, 并且清除不在 _textend 区段中的条目:

在这之后,我们使用 memblock_set_current_limit (你可以在linux 内存管理 第二章节 阅读关于 memblock 的更多内容) 函数来为 memblock 分配内存设置一个界限,这个界限可以是 ISA_END_ADDRESS 或者 0x100000 ,然后调用 memblock_x86_fill 函数根据 e820 来填充 memblock 相关信息。你可以在内核初始化的时候看到这个函数运行的结果:

除了 memblock_x86_fill 之外的其他函数还有: early_reserve_e820_mpc_new 函数在 e820map 中为多处理器规格表分配额外的槽, reserve_real_mode - 用于保留从 0x0 到1M的低端内存用作到实模式的跳板(用于重启等...),trim_platform_memory_ranges 函数用于清除掉以 0x20050000, 0x20110000 等地址开头的内存空间。这些内存区域必须被排除在外,因为 Sandy Bridge 会在这些内存区域出现一些问题, trim_low_memory_range 函数用于保留 memblock 中的前4KB页面,init_mem_mapping 函数用于在 PAGE_OFFSET 处重建物理内存的直接映射, early_trap_pf_init 函数用于建立 #PF 处理函数(我们将会在有关中断的章节看到它), setup_real_mode 函数用于建立到 实模式 代码的跳板。

这就是本章的全部内容了。您可能注意到这部分并没有包括 setup_arch 中的所有函数 (如 early_gart_iommu_checkmtrr 的初始化函数等...)。正如我已经说了很多次的, setup_arch 函数很复杂,linux内核也很复杂。这就是为什么我不能包括linux内核中的每一行代码。我认为我们并没有错过重要的东西, 但是你可能会说: 每行代码都很重要。是的, 这没错, 但不管怎样我略过了他们, 因为我认为对于整个linux内核面面俱到是不现实的。无论如何, 我们会经常复习所学的内容, 如果有什么不熟悉的内容, 我们将会深入研究这些内容。

结束语

这里是linux 内核初始化进程第六章节的结尾。在这一章节中,我们再次深入研究了 setup_arch 函数,然而这是个很长的部分,我们目前还没有学习完。的确, setup_arch很复杂,希望下个章节将会是这个函数的最后一个部分。。

如果你有任何的疑问或者建议,你可以留言,也可以直接发消息给我twitter

很抱歉,英语并不是我的母语,非常抱歉给您阅读带来不便,如果你发现文中描述有任何问题,请提交一个 PR 到 linux-insides.

链接

最后更新于