五、进阶 | 内核初始化流程(7)
内核初始化。第七部分。
架构特定初始化的结束,差不多了...
这是Linux内核初始化过程的第七部分,涵盖了setup_arch
函数的内部机制,该函数定义在arch/x86/kernel/setup.c。正如你可以从前面的部分了解到的,setup_arch
函数执行一些架构特定的(在我们的情况下是x86_64)初始化工作,比如为内核代码/数据/bss预留内存,早期扫描Desktop Management Interface,早期转储PCI设备等等。如果你已经阅读了前面的部分,你可能记得我们在setup_real_mode
函数处结束了。接下来,当我们设置所有映射页面的memblock限制时,我们可以看到调用了kernel/printk/printk.c中的setup_log_buf
函数。
setup_log_buf
函数设置了内核循环缓冲区,其长度取决于CONFIG_LOG_BUF_SHIFT
配置选项。正如我们可以从CONFIG_LOG_BUF_SHIFT
的文档中读到的,它可以在12
到21
之间。在内部,缓冲区定义为字符数组:
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN);
static char *log_buf = __log_buf;
现在让我们看看setup_log_buf
函数的实现。它首先检查当前缓冲区是否为空(它必须是空的,因为我们刚刚设置它),并检查它是否是早期设置。如果内核日志缓冲区的设置不是早期的,我们调用log_buf_add_cpu
函数,该函数为每个CPU增加缓冲区的大小:
if (log_buf != __log_buf)
return;
if (!early && !new_log_buf_len)
log_buf_add_cpu();
我们不会研究log_buf_add_cpu
函数,因为正如你在setup_arch
中看到的,我们这样调用setup_log_buf
:
setup_log_buf(1);
这里的1
意味着它是早期设置。接下来我们检查new_log_buf_len
变量,这是内核日志缓冲区的更新长度,并使用memblock_virt_alloc
函数为其分配新的空间,或者只是返回。
当内核日志缓冲区准备好后,下一个函数是reserve_initrd
。你可以记得我们在内核初始化的第四部分已经调用了early_reserve_initrd
函数。现在,当我们在init_mem_mapping
函数中重建了直接内存映射时,我们需要将initrd移动到直接映射的内存中。reserve_initrd
函数从定义initrd
的基地址和结束地址开始,并检查initrd
是否由引导加载程序提供。这和我们在early_reserve_initrd
中看到的一样。但是,我们不是通过调用memblock_reserve
函数在memblock
区域预留位置,而是获取直接内存区域的映射大小,并检查initrd
的大小是否不大于这个区域:
mapped_size = memblock_mem_size(max_pfn_mapped);
if (ramdisk_size >= (mapped_size>>1))
panic("initrd too large to handle, "
"disabling initrd (%lld needed, %lld available)\n",
ramdisk_size, mapped_size>>1);
在这里你可以看到我们调用了memblock_mem_size
函数,并将max_pfn_mapped
传递给它,其中max_pfn_mapped
包含最高的直接映射页面帧号。如果你不记得什么是页面帧号
,解释很简单:虚拟地址的前12
位代表物理页面或页面帧的偏移量。如果我们将虚拟地址的12
位右移,我们将丢弃偏移部分,并将得到页面帧号
。在memblock_mem_size
中,我们遍历所有的memblock mem
(未保留)区域,并计算映射页面的大小并将其返回给mapped_size
变量(见上面的代码)。当我们得到直接映射内存的数量后,我们检查initrd
的大小是否不大于映射页面。如果它更大,我们只是调用panic
,这会停止系统并打印著名的内核恐慌消息。接下来我们打印关于initrd
大小的信息。我们可以看到在dmesg
输出中的结果:
[0.000000] RAMDISK: [mem 0x36d20000-0x37687fff]
并使用relocate_initrd
函数将initrd
重新定位到直接映射区域。在relocate_initrd
函数的开始,我们尝试使用memblock_find_in_range
函数找到一个空闲区域:
relocated_ramdisk = memblock_find_in_range(0, PFN_PHYS(max_pfn_mapped), area_size, PAGE_SIZE);
if (!relocated_ramdisk)
panic("Cannot find place for new RAMDISK of size %lld\n",
ramdisk_size);
memblock_find_in_range
函数尝试在给定范围内找到一个空闲区域,在这种情况下是从0
到最大的映射物理地址,大小必须等于initrd
的对齐大小。如果我们没有找到给定大小的区域,我们再次调用panic
。如果一切顺利,我们开始将RAM磁盘重新定位到直接映射内存的下面。
在reserve_initrd
函数的最后,我们使用调用释放了被ramdisk占用的memblock内存:
memblock_free(ramdisk_image, ramdisk_end - ramdisk_image);
在我们将initrd
ramdisk映像重新定位之后,下一个函数是arch/x86/kernel/vsmp_64.c中的vsmp_init
。这个函数初始化了对ScaleMP vSMP
的支持。正如我已经在前面的部分中写过的,这一章不会涵盖与x86_64
初始化无关的部分(例如当前的或ACPI
等)。所以我们现在将跳过这个的实现,并在涵盖并行计算技术的章节中回到它。
下一个函数是arch/x86/kernel/io_delay.c中的io_delay_init
。这个函数允许覆盖默认的I/O延迟0x80
端口。我们已经在进入保护模式前的最后准备中看到了I/O延迟,现在让我们看看io_delay_init
的实现:
void __init io_delay_init(void)
{
if (!io_delay_override)
dmi_check_system(io_delay_0xed_port_dmi_table);
}
这个函数检查io_delay_override
变量,并在io_delay_override
被设置时覆盖I/O延迟端口。我们可以通过将io_delay
选项传递给内核命令行来设置io_delay_override
变量。正如我们可以从Documentation/kernel-parameters.txt中读到的,io_delay
选项是:
io_delay= [X86] I/O delay method
0x80
Standard port 0x80 based delay
0xed
Alternate port 0xed based delay (needed on some systems)
udelay
Simple two microseconds delay
none
No delay
我们可以看到io_delay
命令行参数设置与early_param
宏在arch/x86/kernel/io_delay.c中:
early_param("io_delay", io_delay_param);
更多关于early_param
的信息你可以在前面的部分中读到。所以io_delay_param
函数将设置io_delay_override
变量将在do_early_param函数中被调用。io_delay_param
函数获取io_delay
内核命令行参数的参数,并根据它设置io_delay_type
:
static int __init io_delay_param(char *s)
{
if (!s)
return -EINVAL;
if (!strcmp(s, "0x80"))
io_delay_type = CONFIG_IO_DELAY_TYPE_0X80;
else if (!strcmp(s, "0xed"))
io_delay_type = CONFIG_IO_DELAY_TYPE_0XED;
else if (!strcmp(s, "udelay"))
io_delay_type = CONFIG_IO_DELAY_TYPE_UDELAY;
else if (!strcmp(s, "none"))
io_delay_type = CONFIG_IO_DELAY_TYPE_NONE;
else
return -EINVAL;
io_delay_override = 1;
return 0;
}
接下来的函数是acpi_boot_table_init
、early_acpi_boot_init
和initmem_init
在io_delay_init
之后,但正如我上面写的,我们不会在这一章的“Linux内核初始化过程”中涵盖ACPI相关的内容。
为DMA分配区域
接下来我们需要为直接内存访问分配区域,使用定义在drivers/base/dma-contiguous.c中的dma_contiguous_reserve
函数。DMA
是一种特殊模式,设备在这种模式下与内存通信而不需要CPU。注意我们向dma_contiguous_reserve
函数传递了一个参数——max_pfn_mapped << PAGE_SHIFT
,正如你从这个表达式中理解的,这是保留内存的限制。让我们看看这个函数的实现。它首先定义了以下变量:
phys_addr_t selected_size = 0;
phys_addr_t selected_base = 0;
phys_addr_t selected_limit = limit;
bool fixed = false;
其中第一个代表保留区域的大小(以字节为单位),第二个是保留区域的基地址,第三个是保留区域的结束地址,最后一个fixed
参数显示保留区域的位置。如果fixed
是1
,我们只是使用memblock_reserve
保留区域,如果它是0
,我们使用kmemleak_alloc
分配空间。接下来我们检查size_cmdline
变量,如果它不等于-1
,我们用cma
内核命令行参数的值填充上面你可以看到的所有变量:
if (size_cmdline != -1) {
...
...
...
}
你可以在这个源代码文件中找到早期参数的定义:
early_param("cma", early_cma);
其中cma
是:
cma=nn[MG]@[start[MG][-end[MG]]]
[ARM,X86,KNL]
Sets the size of kernel global memory area for
contiguous memory allocations and optionally the
placement constraint by the physical address range of
memory allocations. A value of 0 disables CMA
altogether. For more information, see
include/linux/dma-contiguous.h
如果我们没有向内核命令行传递cma
选项,size_cmdline
将等于-1
。在这种情况下,我们需要计算保留区域的大小,这取决于以下内核配置选项:
CONFIG_CMA_SIZE_SEL_MBYTES
- 大小以兆字节为单位,默认全局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
函数来保留区域,首先调用:
ret = cma_declare_contiguous(base, size, limit, 0, 0, fixed, res_cma);
函数。cma_declare_contiguous
函数从给定的基地址保留连续区域,大小为给定大小。在我们为DMA
保留了区域之后,下一个函数是memblock_find_dma_reserve
。正如你从它的名字理解的,这个函数计算DMA
区域中保留的页面。这部分不会涵盖CMA
和DMA
的所有细节,因为它们很大。我们将在专门涵盖连续内存分配器和区域的Linux内核内存管理部分中看到更多的细节。
稀疏内存的初始化
下一步是调用函数——x86_init.paging.pagetable_init
。如果你尝试在Linux内核源代码中找到这个函数,最终你会看到以下宏:
#define native_pagetable_init paging_init
它展开如你所见,调用了arch/x86/mm/init_64.c中的paging_init
函数。paging_init
函数初始化稀疏内存和区域大小。首先什么是区域,什么是Sparsemem
。Sparsemem
是Linux内核内存管理器中的一种特殊基础,用于将内存区域分割成不同的内存库,在NUMA系统中。让我们看看paging_init
函数的实现:
void __init paging_init(void)
{
sparse_memory_present_with_active_regions(MAX_NUMNODES);
sparse_init();
node_clear_state(0, N_MEMORY);
if (N_MEMORY != N_NORMAL_MEMORY)
node_clear_state(0, N_NORMAL_MEMORY);
zone_sizes_init();
}
正如你看到的,有sparse_memory_present_with_active_regions
函数的调用,它记录每个NUMA
节点的内存区域到mem_section
结构的数组中,该结构包含指向struct page
数组结构的指针。接下来的sparse_init
函数分配非线性mem_section
和mem_map
。在下一步中,我们清除可移动内存节点的状态并初始化区域大小。每个NUMA
节点被分成许多部分,这些部分被称为——区域
。所以,zone_sizes_init
函数从arch/x86/mm/init.c初始化区域的大小。
再次强调,这部分和接下来的部分不会完整地涵盖这个主题。将会有一个专门的部分关于NUMA
。
vsyscall映射
SparseMem
初始化之后的下一步是设置trampoline_cr4_features
,它必须包含cr4
控制寄存器的内容。首先我们需要检查当前CPU是否支持cr4
寄存器,如果支持,我们将它的内容保存到trampoline_cr4_features
中,这是存储在实模式中的cr4
:
if (boot_cpu_data.cpuid_level >= 0) {
mmu_cr4_features = __read_cr4();
if (trampoline_cr4_features)
*trampoline_cr4_features = mmu_cr4_features;
}
接下来你可以看到的函数是arch/x86/entry/vsyscall/vsyscall_64.c中的map_vsyscall
。这个函数映射vsyscalls
的内存空间,并取决于CONFIG_X86_VSYSCALL_EMULATION
内核配置选项。实际上vsyscall
是一个特殊段,它提供了对某些系统调用(如getcpu
等)的快速访问。让我们看看这个函数的实现:
void __init map_vsyscall(void)
{
extern char __vsyscall_page;
unsigned long physaddr_vsyscall = __pa_symbol(&__vsyscall_page);
if (vsyscall_mode != NONE)
__set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall,
vsyscall_mode == NATIVE
? PAGE_KERNEL_VSYSCALL
: PAGE_KERNEL_VVAR);
BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) !=
(unsigned long)VSYSCALL_ADDR);
}
在map_vsyscall
的开始,我们可以看到定义了两个变量。第一个是外部变量__vsyscall_page
。作为一个外部变量,它在另一个源代码文件中定义。实际上我们可以看到__vsyscall_page
在arch/x86/entry/vsyscall/vsyscall_emu_64.S中的定义。__vsyscall_page
符号指向对vsyscalls
的对齐调用,如gettimeofday
等:
.globl __vsyscall_page
.balign PAGE_SIZE, 0xcc
.type __vsyscall_page, @object
__vsyscall_page:
mov $__NR_gettimeofday, %rax
syscall
ret
.balign 1024, 0xcc
mov $__NR_time, %rax
syscall
ret
...
...
...
第二个变量是physaddr_vsyscall
,它只是存储__vsyscall_page
符号的物理地址。接下来我们检查vsyscall_mode
变量,如果它不等于NONE
,它默认是EMULATE
:
static enum { EMULATE, NATIVE, NONE } vsyscall_mode = EMULATE;
在这之后检查我们可以看到调用__set_fixmap
函数,它调用native_set_fixmap
与相同的参数:
void native_set_fixmap(enum fixed_addresses idx, unsigned long phys, pgprot_t flags)
{
__native_set_fixmap(idx, pfn_pte(phys >> PAGE_SHIFT, flags));
}
void __native_set_fixmap(enum fixed_addresses idx, pte_t pte)
{
unsigned long address = __fix_to_virt(idx);
if (idx >= __end_of_fixed_addresses) {
BUG();
return;
}
set_pte_vaddr(address, pte);
fixmaps_set++;
}
在这里我们可以看到native_set_fixmap
从给定的物理地址(在我们的情况下是__vsyscall_page
符号的物理地址)制作一个Page Table Entry
的值,并调用内部函数__native_set_fixmap
。内部函数获取给定fixed_addresses
索引的虚拟地址(在我们的情况下是VSYSCALL_PAGE
)并检查给定索引是否不大于固定映射地址的结束。在这之后我们设置页面表条目,通过调用set_pte_vaddr
函数,并增加固定映射地址的计数。在map_vsyscall
的最后我们检查VSYSCALL_PAGE
的虚拟地址(它是fixed_addresses
中的第一个索引)是否不大于VSYSCALL_ADDR
,它是-10UL << 20
或ffffffffff600000
,使用BUILD_BUG_ON
宏:
BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) !=
(unsigned long)VSYSCALL_ADDR);
现在vsyscall
区域在fix-mapped
区域中。这就是关于map_vsyscall
的全部,如果你不知道关于固定映射地址的任何信息,你可以阅读Fix-Mapped Addresses and ioremap。我们将在vsyscalls and vdso
部分中看到更多关于vsyscalls
的信息。
获取SMP配置
你可能记得我们在前面的部分中是如何搜索SMP配置的。现在我们需要获取SMP
配置,如果我们找到了它。为此我们检查smp_found_config
变量,我们在smp_scan_config
函数中设置了它,并调用get_smp_config
函数:
if (smp_found_config)
get_smp_config();
get_smp_config
展开为x86_init.mpparse.default_get_smp_config
函数,定义在arch/x86/kernel/mpparse.c。这个函数定义了指向多处理器浮动指针结构——mpf_intel
的指针(你可以在前面的部分中读到关于它的信息)并进行一些检查:
struct mpf_intel *mpf = mpf_found;
if (!mpf)
return;
if (acpi_lapic && early)
return;
在这里我们可以看到在smp_scan_config
函数中找到了多处理器配置,或者如果没有找到就从函数返回。接下来的检查是acpi_lapic
和early
。正如我们做了这些检查,我们开始读取SMP
配置。当我们完成读取后,下一步是prefill_possible_map
函数,它对可能的CPU的cpumask
进行初步填充(你可以在介绍cpumasks中读到更多关于它的信息)。
setup_arch的剩余部分
我们即将结束setup_arch
函数。函数的剩余部分当然很重要,但这些内容的细节将不会被包括在这部分中。我们只是简单地看看这些函数,因为尽管它们很重要,如我上面所写,它们涵盖了与NUMA
、SMP
、ACPI
和APICs
等相关的非通用内核特性。首先,接下来是调用init_apic_mappings
函数。正如我们所理解的,这个函数设置了本地APIC的地址。接下来是x86_io_apic_ops.init
,这个函数初始化I/O APIC。请注意,我们将在关于中断和异常处理的章节中看到所有与APIC
相关的细节。接下来我们通过调用x86_init.resources.reserve_resources
函数,预留标准I/O资源,如DMA
、TIMER
、FPU
等。其次是mcheck_init
函数,它初始化Machine check Exception
,最后一个是register_refined_jiffies
,它注册jiffy(内核中关于计时器的单独章节将会介绍)。
就这样了。最后我们完成了这一部分的大setup_arch
函数。当然,正如我已经多次写过的,我们没有看到这个函数的全部细节,但不用担心。我们将不止一次地从不同的章节回到这个函数,以理解不同的平台依赖部分是如何初始化的。
就这样了,现在我们可以从setup_arch
回到start_kernel
了。
回到main.c
正如我上面写的,我们已经完成了setup_arch
函数,现在我们可以回到init/main.c的start_kernel
函数。你可能记得,或者你自己看到了,start_kernel
函数和setup_arch
一样大。所以,接下来的几个部分将致力于学习这个函数。所以,让我们继续。在setup_arch
之后,我们可以看到调用了mm_init_cpumask
函数。这个函数将cpumask指针设置为内存描述符cpumask
。让我们看看它的实现:
static inline void mm_init_cpumask(struct mm_struct *mm)
{
#ifdef CONFIG_CPUMASK_OFFSTACK
mm->cpu_vm_mask_var = &mm->cpumask_allocation;
#endif
cpumask_clear(mm->cpu_vm_mask_var);
}
正如你在init/main.c中看到的,我们将init进程的内存描述符传递给mm_init_cpumask
,根据CONFIG_CPUMASK_OFFSTACK
配置选项我们清除TLB切换cpumask
。
接下来我们可以看到调用了以下函数:
setup_command_line(command_line);
这个函数接受内核命令行的指针,分配几个缓冲区来存储命令行。我们需要几个缓冲区,因为一个缓冲区用于将来引用和访问命令行,一个用于参数解析。我们将为以下缓冲区分配空间:
saved_command_line
- 将包含启动命令行;initcall_command_line
- 将包含启动命令行。将用于do_initcall_level
;static_command_line
- 将包含命令行用于参数解析。
我们将使用memblock_virt_alloc
函数分配空间。这个函数调用memblock_virt_alloc_try_nid
,如果slub不可用,则使用memblock_reserve
分配启动内存块,或者使用kzalloc_node
(关于这个将在Linux内存管理章节中介绍更多)。memblock_virt_alloc
使用BOOTMEM_LOW_LIMIT
(物理地址(PAGE_OFFSET + 0x1000000)
的值)和BOOTMEM_ALLOC_ACCESSIBLE
(等于当前memblock.current_limit
的值)作为内存区域的最小地址和最大地址。
让我们看看setup_command_line
的实现:
static void __init setup_command_line(char *command_line)
{
saved_command_line =
memblock_virt_alloc(strlen(boot_command_line) + 1, 0);
initcall_command_line =
memblock_virt_alloc(strlen(boot_command_line) + 1, 0);
static_command_line = memblock_virt_alloc(strlen(command_line) + 1, 0);
strcpy(saved_command_line, boot_command_line);
strcpy(static_command_line, command_line);
}
在这里我们可以看到,我们为三个缓冲区分配了空间,这些缓冲区将包含不同目的的内核命令行(见上文)。当我们分配了空间后,我们将boot_command_line
存储在saved_command_line
中,并将command_line
(来自setup_arch
的内核命令行)存储在static_command_line
中。
setup_command_line
之后的下一个函数是setup_nr_cpu_ids
。这个函数根据cpu_possible_mask
中的最后一个位设置nr_cpu_ids
(CPU数量):
void __init setup_nr_cpu_ids(void)
{
nr_cpu_ids = find_last_bit(cpumask_bits(cpu_possible_mask),NR_CPUS) + 1;
}
在这里nr_cpu_ids
代表CPU的数量,NR_CPUS
代表我们可以在配置时设置的最大CPU数量:
实际上我们需要调用这个函数,因为NR_CPUS
可能大于你计算机中实际的CPU数量。在这里我们可以看到我们调用find_last_bit
函数,并向它传递了两个参数:
cpu_possible_mask
位;- CPU的最大数量。
在setup_arch
中我们可以找到prefill_possible_map
函数的调用,该函数计算并写入cpu_possible_mask
中实际的CPU数量。我们调用find_last_bit
函数,它接受地址和要搜索的最大大小,并返回第一个设置位的位号。我们传递了cpu_possible_mask
位和CPU的最大数量。首先find_last_bit
函数将给定的unsigned long
地址分割成words:
words = size / BITS_PER_LONG;
其中BITS_PER_LONG
在x86_64
上是64
。当我们得到给定大小的搜索数据中的字数时,我们需要检查给定大小是否包含部分words:
if (size & (BITS_PER_LONG-1)) {
tmp = (addr[words] & (~0UL >> (BITS_PER_LONG
- (size & (BITS_PER_LONG-1)))));
if (tmp)
goto found;
}
如果它包含部分word,我们对其进行掩码处理并检查它。如果最后一个word不为零,这意味着当前word至少包含一个设置的位。我们跳转到found
标签:
found:
return words * BITS_PER_LONG + __fls(tmp);
在这里你可以看到__fls
函数,它使用bsr
指令返回给定word中的最后一个设置位:
static inline unsigned long __fls(unsigned long word)
{
asm("bsr %1,%0"
: "=r" (word)
: "rm" (word));
return word;
}
bsr
指令扫描给定的操作数以找到第一个设置的位。如果最后一个word不是部分的,我们遍历给定地址中的所有words,试图找到第一个设置的位:
while (words) {
tmp = addr[--words];
if (tmp) {
found:
return words * BITS_PER_LONG + __fls(tmp);
}
}
在这里我们将最后一个word放入tmp
变量中,并检查tmp
是否包含至少一个设置的位。如果找到了一个设置的位,我们返回这个位的编号。如果没有一个字包含设置的位,我们就返回给定的大小:
return size;
在这之后nr_cpu_ids
将包含可用CPU的正确数量。
就这样。
结论
这是关于Linux内核初始化过程的第七部分的结尾。在这部分中,我们最终完成了setup_arch
函数,并返回到start_kernel
函数。在下一部分中,我们将继续学习start_kernel
中的通用内核代码,并继续我们走向第一个init
进程的旅程。
如果你有任何问题或建议,请在评论中告诉我,或者在twitter上联系我。
请注意,英语不是我的第一语言,我对任何不便表示歉意。如果你发现任何错误,请向我发送PR到linux-insides。
链接
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。