五、进阶 | 内核初始化流程(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的文档中读到的,它可以在1221之间。在内部,缓冲区定义为字符数组:

#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);

在我们将initrdramdisk映像重新定位之后,下一个函数是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_initearly_acpi_boot_initinitmem_initio_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参数显示保留区域的位置。如果fixed1,我们只是使用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_1MCONFIG_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区域中保留的页面。这部分不会涵盖CMADMA的所有细节,因为它们很大。我们将在专门涵盖连续内存分配器和区域的Linux内核内存管理部分中看到更多的细节。

稀疏内存的初始化

下一步是调用函数——x86_init.paging.pagetable_init。如果你尝试在Linux内核源代码中找到这个函数,最终你会看到以下宏:

#define native_pagetable_init        paging_init

它展开如你所见,调用了arch/x86/mm/init_64.c中的paging_init函数。paging_init函数初始化稀疏内存和区域大小。首先什么是区域,什么是SparsememSparsemem是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_sectionmem_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_pagearch/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 << 20ffffffffff600000,使用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_lapicearly。正如我们做了这些检查,我们开始读取SMP配置。当我们完成读取后,下一步是prefill_possible_map函数,它对可能的CPU的cpumask进行初步填充(你可以在介绍cpumasks中读到更多关于它的信息)。

setup_arch的剩余部分

我们即将结束setup_arch函数。函数的剩余部分当然很重要,但这些内容的细节将不会被包括在这部分中。我们只是简单地看看这些函数,因为尽管它们很重要,如我上面所写,它们涵盖了与NUMASMPACPIAPICs等相关的非通用内核特性。首先,接下来是调用init_apic_mappings函数。正如我们所理解的,这个函数设置了本地APIC的地址。接下来是x86_io_apic_ops.init,这个函数初始化I/O APIC。请注意,我们将在关于中断和异常处理的章节中看到所有与APIC相关的细节。接下来我们通过调用x86_init.resources.reserve_resources函数,预留标准I/O资源,如DMATIMERFPU等。其次是mcheck_init函数,它初始化Machine check Exception,最后一个是register_refined_jiffies,它注册jiffy(内核中关于计时器的单独章节将会介绍)。

就这样了。最后我们完成了这一部分的大setup_arch函数。当然,正如我已经多次写过的,我们没有看到这个函数的全部细节,但不用担心。我们将不止一次地从不同的章节回到这个函数,以理解不同的平台依赖部分是如何初始化的。

就这样了,现在我们可以从setup_arch回到start_kernel了。

回到main.c

正如我上面写的,我们已经完成了setup_arch函数,现在我们可以回到init/main.cstart_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数量:

alt

实际上我们需要调用这个函数,因为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_LONGx86_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嵌入式必考必会 文章被收录于专栏

&quot;《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。

全部评论

相关推荐

qq乃乃好喝到咩噗茶:院校后面加上211标签,放大加粗,招呼语也写上211
点赞 评论 收藏
分享
求面试求offer啊啊啊啊:1600一个月?
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客企业服务