深入理解Linux网络内幕(七)——组件初始化的内核基础架构


前言

要完全了解内核组件,不但要知道给定的一组函数在做什么,还要知道这些函数何时被启用以及由谁启用。子系统的初始化就是内核根据其模型所处理的基本任务之一。这种基础架构值得研究,有助于了解网络协议栈的关键组件是如何初始化的(包括NIC设备驱动程序在内)。

引导期间的内核选项

Linux允许用户把内核配置选项传给引导记录,然后引导记录再把选项传给内核。有经验的用户可借此机制在引导期间微调内核。引导阶段如下图所示

在这里插入图片描述

parse_args调用两次,负责引导期间配置输入数据。

parse_args是一个函数,用于解析输入字符串,而输入的字符串内是一些参数,其形式为变量名称 = 值,寻找特定关键字,并启用适当的处理函数。加载模块时。也会用到parse_args,借以解析命令列参数(如果有的话)。

我们不需要知道parse_args实现解析行为的细节,但是了解内核组件如何为关键字注册处理函数以及处理函数如何启用相当有趣。为了有个清晰的概念,我们需要了解:

  • 内核组件如何注册关键字,而当引导字符串中出现该关键字时,相关联的处理函数会被执行。

  • 内核如何解析关键字与处理函数之间的关联性。我们将高度概括内核如何解析输入字符串。

  • 网络设备子系统如何使用这些选项。

所有的解析代码都在kernel/params.c

注册关键字

内核组件可以利用定义在include/linux/init.h中的__setup宏,注册关键字和相关联的处理函数。语法如下:

__setup(string, function_handler)

string是关键字,而function_handler是相关联的处理函数。此例只显示了指示内核当输入的引导期间字符串中包括string时,就执行function_handlerstring必须以 =字符作结束,以使parse_args的解析能轻松一点。任何跟在 =之后的文本都将作为输入值传给function_handler

下面是取自net/core/dev.c的例子,其中netdev_boot_setup注册成了netdev=关键字的处理函数

__setup("netdev=", "netdev_boot_setup");

同一个处理函数可以与几个不同关键字向关联。例如,net/ethernet/eth.cether=关键字注册了同一个处理函数netdev_boot_setup

当一段代码编译成模块时,__setup宏会忽略(即定义为空操作)。可以查看一下include/linux/init.h__setup宏定义,如何根据头文件中init.h的代码是否为模块而变动。

start_kernel两次调用parse_args以解析引导配置字符串的原因在于,引导期间选项实际上是分成两类,而每一次调用都是针对其中一类。

  • 默认选项

    • 多数选项都属于这一类。这些选项都是用__setup宏定义的,而且是由parse_args第二次调用时处理。
  • 初期选项

    • 内核引导期间,有些选项必须比其他选项更早处理。内核提供了early_param宏以声明这些选项替代__setup。然后,这些选项会由parse_early_params负责。early_param__setup的唯一差别就是early_param会设置一个特殊标识,使内核区分这两种情况。此标识是obs_kernel_param数据结构一部分,在后面init.setup内存节区一节会对此进行说明。

到了内核2.6版本,引导期间的选项处理有了一些改变,但是并非所有内核代码都据此做了更新。最新的改变之前,仅仅是用__setup宏,因此,等着更新的旧代码现在都使用__obsolete_setup宏。当用户把__obsolete_setup宏所声明的选项传给内核时,内核会输出警告信息。支持其废弃状态,并且提供声明该宏的文件和源代码的所在处。

下图总结了各种宏之间的关系

在这里插入图片描述

这些都是包裹函数,内含通用的函数__setup_param。注意,传给__setup的输入函数会被放到.init.setup内存节区中。后面会进行详细分析。

两遍分析

因为早先的几种版本的内核都以不同的方式处理引导期间选项,而且并非所有选项都已转换为新的模型,所以内核需要处理两种模型。当新型基础架构无法是被某个关键字时,就会使用就型基础架构予以处理。如果旧的基础架构也不能识别,则该关键字和其值就会传给init进程,init内核线程结束时会通过run_init_process予以启用,如下图所示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cIhWz8Tm-1668170693851)(file://C:\Users\g700382\AppData\Roaming\marktext\images\2022-11-07-19-44-58-image.png)]

该关键字及其值不是添加到arg参数列表,就是添加到envp环境变量列表。

前面我们提到,为了使初期选项以必要次序处理,引导字符串的解析以及处理函数的调用是分成两遍进行的,如下图所示

在这里插入图片描述

  1. 第一遍只看必须在初期处理的较高优先级的选项,由一个特殊的标识识别。

  2. 第二遍则会负责所有的其他选项。多数选项都属于这一类。遵循旧模型的所有选项都会在这一遍处理。

第二遍会先检查那些选项中是否匹配根据新基础架构实现的选项。这些选项会存储在kernel_param数据结构结构内,由module_param宏填入。module_param宏也会确保所有这些数据结构都会被放进特定的内核节区__param,由指针__start__param__stop__param限定。

识别其中一个选项时,相关联的参数被引导字符串所提供的值初始化。如果没有匹配这个选项,unknown_bootoption就会试图了解该选项是否应该由旧的处理函数处理。

旧的和新的选项会放在两个不同的内存区域:

__setup_start···_setup_end

后面就会介绍这个区域会在引导阶段结束时释放掉:一旦内核引导了,这些选项就不需要了。用户无法在运行期间看到或修改这些选项。

__start__param···__stop__param

这个区域不会被释放。其内容会输出到/sys使得这些选项可以展露给用户。

此外还需要注意,所有旧的选项,无论其是否设置了early标识,都会放入__setup__start···__setup_end内存区域。

.init.setup内存节区

前面介绍的__setup宏的两个输入数据会放入一个定义在include/linux/init.h中的obs_kernel_param类型的数据结构内。

struct obs_kernel_param
{
    const char *str;
    int (*setup_func)(char*);
    int early;
};

str是关键字,setup_func是处理例程,而early标识必须要先处理的选项。

__setup_param宏会把所有的ons_kernel_param实例放入一个专用的内存区域内。这样做主要有两个原因:

  • 比较容易遍历所有实例,例如,在根据str关键字进行查询时。在进行关键字查询时,我们就会知道内核如何使用这两个指针__setup_start__setup_end,分别指向刚才所提到的区域的开端和尾端。

  • 当这些数据结构不再有用时,内核可以快速释放所有数据结构。

使用引导选项配置网络设备

根据前几节所说的,我们来说明网络代码如何使用引导选项。

前面提到过,ether=netdev=关键字都注册为使用同一个处理函数netdev_boot_setup。当此处理函数被启用处理输入参数(也就是匹配关键词的字符串)时,就会把结果存储至定义在include/linux/netdevice.h中类型为netdev_boot_setup的数据结构内。处理函数和数据结构类型恰巧共享同一名称,因此要注意别混淆两者。

struct netdev_boot_setup
{
    char name[IFNAMSIZ];
    struct ifmap map;
}

name是设备名,而定义在include/linux/if.h中的ifmap是存储输入配置值的数据结构:

struct ifmap
{
    unsigned long mem_start;
    unsigned long mem_end;
    unsigned short base_addr;
    unsigned char irq;
    unsigned char dmal
    unsigned char port;
    /*剩余三个字节*/
};

同一个关键字可以在引导字符串中出现多次(针对不同设备),如下所示:

LILO:linux ether = 5, 0x260, eth0 ether=15, 0x300, eth1

然而,能以此种机制在引导期间配置设备的最大数目是NETDEV_BOOT_SETUP_MAX,也就是用于存储配置值的静态数组dev_boot_setup的大小:

static struct netdev_boot_setup dev_boot_setup{NETDEV_BOOT_SETUP_MAX};

netdev_boot_setup相当简单:就是从字符串中抽取输入参数,填入一个ifmap结构,然后用netdev_boot_setup_addifmap结构添加到dev_boot_setup数组中。

引导阶段结束时,网络代码可以使用netdev_boot_setup_check函数检查给定接口是否与引导期间配置相关联。对数组dev_boot_setup的查询基于设备名称dev->name

int netdev_boot_setup_check(struct net_device *dev)
{
    struct netdev_boot_setup *s = dev_boot_setup;
    int i;
    for(i=0; i< NETDEV_BOOT_SETUP_MAX; i++)
    {
        if(s[i].name[0] != '\0' && s[i].name[0] != ' ' &&
            !strncmp(dev->name, s[i].name, strlen(s[i].name)))
        {
            dev->irq             = s[i].map.irq;
            dev->base_addr       = s[i].map.base_addr;
            dev->mem_start       = s[i].map.mem_start;
            dev->mem_end         = s[i].map.mem_end;
            return 1;
        }
    }
    return 0;
}

有特殊能力、功能或限制的设备,如果除了需要ether=netdev=这些基本关键字外还需要其他参数,也可以自行定义其自用的关键字和处理函数(PLIP就是其中一个这么做的驱动程序)。

模块初始化代码

后面的内容都与模块化有关,因此一些基本概念必须搞清楚。

内核代码可以和主要映像做静态链接,也可以在需要时以模块形式动态加载。并非所有内核组件都适合编译成模块。驱动程序以及基本功能的扩充这类内核组件,就是通常都编译成模块的典型范例。可以参考《Linux设备驱动程序》一书,书中详细描述了模块的优缺点,以及内核在需要时将其动态加载以及不需要时将其予以卸载的机制。

每个模块都必须提供两个特殊函数,称为init_modulecleanup_module。第一个函数还在模块加载时被调用,以初始化模块。删除模块时,内核会调用第二个函数,以释放由模块由于其用途所分配的任何资源。

内核提供两个宏module_initmodule_exit,允许开发人员为这两个函数任意命名。下面是范例取自driver/net/3c59x.c Ethernet驱动程序:

module_init(vortex_init);
module_exit(vortex_cleanup);

在内存最优化一节,我们将看到这两个宏是如何定义的,以及它们的定义如何根据内核配置而改变。内核的大多数程序都会使用这两个宏,但是有些模块依然使用旧的默认名称init_modulecleanup_module

首先来看看旧版本内核模块初始化代码是如何编写的,然后再了解基于一组新的宏的当前内核模型的工作方式。

旧模型:条件式代码

无论内核组件是编译成模块还是和内核静态链接,都必须做初始化工作。因此,内核组件的初始化代码必须通过条件式指令使编译器区分这两种情况。对于旧模型而言,这迫使开发人员到处使用类似#ifdef的条件式指令。

以下是取自内核2.2.14版本drivers/net/3c59x.c驱动程序的范例:注意#ifdef MODULE#if defined (MODULE)用了多次。

...
#if defined(MODULE) && LINUX_VERSION_CODE > 0x20115
MODULE_AUTHOR("Donald Becker <becker@cesdis.gsfc.nasa.driver>");
MODULE_DECRIPTION("3Com 3c590/3c900 series Vortex/Boomerang drivr");
MODULE_PARM(debug,"i");
...
#ifdef MODULE
int init_module(void)
{
    ...
}
#else
int tc59x_probe(struct device *dev)
{
    ....
}
#endif
....
static int vortex_scan(struct device *dev, struct pci_id_info pci_tbl[])
{
    ....
#if defined(CONFIG_PCI) || (defined(MODULE) && !defined(NO_PCI))
    ....
#ifdef MODULE
    if(compaq_ioaddr){
        vortex_probel(0, 0, dev, compaq_ioaddr, compaq_irq, 
                        compaq_deice_id, cards_found++);
        dev =0;
    }
#endif
    return cards_found ? 0 : -ENODEV;
}
...
#ifdef MODULE
void cleanup_module(void)
{
    ...
}
#endif

以上范例显示了在旧模型下程序员如何根据代码是否编译成模块或静态链接至内核映像,而让一些指定的事情有不同的结果:

初始化代码的执行不同

上述范例表明,只有当驱动程序编译成模块时,才会定义cleanup_module函数(因此也才会使用此函数)。

一些代码片段可以引入模块或从模块排除

例如,只有当驱动程序被编译成模块时,vortex_scan才会调用vortex_probel

这种模型使得源代码难以领会,因此也难以调试。此外,每个模块都会重复相同的逻辑。

新模式:宏卷标

现在我们拿2.6版本内核中相同文件的同一部分和前一节的范例做比较:

static char version[] __devinitdata = DRV_NAME "...";
static struct vorlex_chip_info{
    ...
}vortex_info_tbl[] __devijitdata = {
    {"3c590 Vortex 10Mbps"},
    .... ... ...
} 
static int __init vortex_init(void)
{
    ....
}
module_init(vortex_init);
module_exit(vortex_cleanup);

可以看到,#ifdef指令已不再必需了。

为了可以消除条件式代码引起的混乱,是程序具有更高的可读性,内核开发人员引入了一组宏,现在模块开发人员可以使用这组宏编写更为清晰的初始化代码(大多数确定程序都是使用这些宏的绝佳对象)。在这个范例中只有用到其中一些宏而已:__init、__exit以及__devinitdata

这些宏允许内核在后台决定每个模块要把那些代码引入到内核映像中,由于不需要那些代码排除在外,以及那些代码只在初始化期间执行等等。这使得每个程序员的负担减轻,不用在每各模块内重复相同的逻辑。

显然,这些宏允许程序员替代前一节范例所示的旧式条件式指令。这些宏必须提供至少下列两项服务:

  • 定义内核组件开启时必须执行的函数,无论这些组件是因为与内核静态连接,或是因为于执行期间以模块形式而加载。

  • 定义一些初始化函数之间的次序,强制内核组件之间遵循相互依赖性。

优化宏卷标

Linux内核使用各种不同的宏为函数和数据结构标记特殊属性:例如,标记一个初始化函数。这些宏大多数都定义在include/linux/init.h中,而有些宏会通过链接器把带有共同属性的代码或数据结构放到特定专用的内存区域。如此一来,内核就会用最简单的方式轻易管好全部具有共同属性的对象类(函数或数据结构)。

如下图所示为一些内存节区:左侧是指针名称。限定每个区域节区(有意义时)的开端和尾端。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uxdWZvW2-1668170693852)(file://C:\Users\g700382\AppData\Roaming\marktext\images\2022-11-10-10-15-46-image.png)]

右侧是宏的名字,用于把数据和代码放入相关的节区内。由于实在太多,这张图不便引入所有与内存节区相关的宏。

下表列出了一些用于标记函数和数据结构的宏并附加描述。

在这里插入图片描述

在这里插入图片描述

在深入说明这些宏之前,要先强调下列几点:

  • 大多数宏都是成对出现的:一个(或一组)负责初始化,而另一个姊妹宏(或一组姊妹宏)就是负责删除。例如,__exit__init的姊妹宏,而__exitcalls__initcall的姊妹宏,等等

  • 宏用于两种情况(非此即彼):一种是当函数要被执行时(也就是__initcall__exitcall);另一种是函数或数据结构要放入内存节区(也就是__init__exit)。

  • 同一个函数可以标记一个以上的宏。例如,下列片段支持pci_proc_init必须在引导期间执行__initcall,而且执行之后就可以释放了(__init):

static int __init pci_proc_init(void)
{
    .....
}
__initcall(pci_proc_init)

设备初始化函数使用的初始化宏

引导期间初始化函数

大多数初始化函数都有两个有趣的属性:

  • 当所有内核组件都已初始化之时,必须在引导期间执行。

  • 一旦被执行后,就不再需要这些函数了。

下面一节会描述引导期间运行初始化函数所用的机制,把这些属性以及模块之间的优先级考虑进来。在“内存最优化”一节会说明如何使用巧妙的标记,在链接期间或运行期间使不再需要的函数和数据结构可以被释放掉。

xxx_initcall宏

内核引导的早期阶段有下列两项主要的初始化工作:

  • 各种必须以特定次序完成初始化的关键子系统和加强子系统。例如,PCI层初始化之前,内核无法对PCI设备驱动程序做初始化。

  • 其他不需要以严格次序完成初始化的内核组件:一组具有相同优先级的函数可以按任何次序执行。

参考下图

在这里插入图片描述

第一部分由do_initcalls之前的代码负责。而第二部分是调用该图中靠近do_basic_setup尾端附近的do_initcalls完成。第二部分的初始化函数根据其角色和优先级进行分类。内核会逐个执行这些初始化函数,由最高优先级类的那些函数(core_initcall)开始执行。这些函数调用时必须使用的地址都放在.initcallN.init内存节区中,由xxx_initcall宏中的其中之一来标记。

用来存储标记xxx_initcall宏函数地址的区域,由起始地址(__initcall_start)和结尾地址(__initcall_end)限定。下列摘录自do_initcalls的程序片段中可以发现,就是把函数地址一个接一个从该区域中取出,然后执行其所指的函数:

static void __init do_initcalls(void)
{
    initcall_t *call;
    int count = preempt_count();
    for( call = __initcall_start; call < __initcall_end; call++)
    {
        ...
        (*call)();
        ...
    }
    flush_scheduled_work();
}

do_initcalls所调用的函数不应该改变抢占状态或关闭IRQ。因此每个函数执行之后,do_initcalls就会检查该函数是否做了任何修改,然后必要时调整抢占和IRQ状态。

xxx_initcall函数有可能会让一些工作进入调度,使其稍后发生。也就说,由这些函数所处理的任务会以异步方式在未知时刻终止。调用flush_scheduled_work就是为了使do_initcalls在返回前等待异步任务完成。

注意,do_initcalls本身就以__init标记:因为此函数只会在引导阶段在do_basic_setup内使用一次,一旦do_basic_setup完成后,内核就可以丢弃do_initcalls

__exitcall__initcall配对宏。这个宏很少直接使用,而是通过定义为其别名的其他宏,如module_exit

__initcall和__exitcall函数范例:模块

module_initmodule_exit宏分别标记那些在模块初始化以及卸载时执行的函数(如果内建在内核就是引导期间,如果是个别加载就是执行期间)。

这使得模块成为__initcall__exitcall宏的最佳对象:按刚才所说,下列来自include/linux/init.h中的module_initmodule_exit宏的定义应该也不会令人诧异了:

#ifndef MODULE
... ....
#define module_init(x) __initcall(x)
#define module_exit(x) __exitcall(x)
#else
.... ...
#endif

module_init针对与内核静态链接的代码,被定义为__initcall的别名:其输入函数被归类为引导期间初始化函数。

module_exit也遵循相同规则:当代码内建于内核时,module_exit就变成关闭函数。目前,当系统关机时,关闭函数并不会被调用,但代码已经存在,想做就可以做。

旧代码

引进xxx_initcall这组宏之前,只有一个宏可标记初始化函数:__initcall。只用一个宏引发严重的局限性:只通过宏标记函数,无法强制执行次序。在很多情况下,由于模块之间的依赖性以及其他元素,使得这种限制无法接受。因此__initcall的使用无法拓展到所有初始化函数。

设备驱动程序过去时常用到__initcall。为了与那些尚未更新为新模型的代码兼容,此宏依然存在,但只定义为device_initcall的别名。

当前模型依然存在的另一项限制,就是无法向初始化函数提供参数。然而,这一限制看起来并不重要。

内存最优化

与用户空间代码及数据不同的是,内核代码及数据会永久驻留在主存中,所以,采用各种可能的方式减少内存浪费是很重要的。初始化代码就是内存最优化的理想对象。因其固有的特性,大多数初始化函数只会被执行一次,或者根本不执行,这取决于内核的配置。例如:

  • 当相关联的模块加载时,module_init函数只会执行一次。当模块与内核静态链接时,在引导期间,module_init函数执行后就可将其释放掉。
  • 当相关联的模块与内核静态链接时,module_exit函数根本不会执行。因此,就此而言,没必要把module_exit函数引入内核映象中(也就是这类函数可以在链接期间就被丢弃掉)。
    第一种情况时执行期间最优化,而第二种是链接期间最优化。代码及数据只在引导期间用一次,而后就不再需要了,从那时以后就被放在内存节区中。一旦内核完成初始化阶段,就可丢弃整个内存区域,做法是调用free_init_men

__init和__exit宏

内核早期阶段所执行的初始化函数都会标记为__init宏。
大多数module_init输入函数都会标记此宏。
如定义所示,__init宏把输入函数放到.text.init内存节区:

#define __init __attribute__ ((__section__(".text.init")))

这个节区是free_initmem在运行期间释放的内存节区之一。
__exit__init的配对宏。用于关闭模块的函数都放在.text.exit节区中。对内建与内核的模块而言,这个节区可以在链接期间直接丢弃掉。然而,有些体系架构会在运行期间予以丢弃,以应付交叉使用。注意,对个别加载的模块而言,当内核不再支持模块卸载时,同一个节区也可以在加载时予以删除(有一个内核选项可让用户无法卸载模块)

xxx_initcall和__exitcall节区

内核用于放置指向xxx_initcall__exitcall宏所标记函数的地址存储区也将被丢弃掉:

  • xxx_initcall节区在运行期间被free_initmem丢弃掉。
  • __exitcall函数使用的.text.exit节区则会在链接期间被丢弃掉,因为在系统关机时啮合不会立即调用__exitcall函数(也就是没有使用do_initcalls的机制)。

其他最优化

在这里插入图片描述

动态宏的定义

我们知道传给module_init宏的函数都用诸如__initcall之类的宏标记。因为大多数内核组件可以编译成模块或与内核静态链接,而选择之后,会改变这些宏的定义,以应用前一节所介绍的内存最优化的工作。
正如在include/linux/init.h所见的一样,会根据下列符号是否定义在包含include/linux/init.h的文件的范围内而改变:
CONFIG_MODULE
当内核支持可加载模块时就会定义(“Loadable module support”配置选项)。
MODULE
当该文件所属的内核组件编译为模块时就会定义。
CONFIG_HOTPLUG
当内核编译为支持“Support for hot-pluggable devices”时就会定义。
虽然MODULE对不同文件可以有不同的值,但其他两个符号的属性为全内核通用,因此不是有设置就是没设置,整个内核一致。
从NIC的驱动程序初始化的角度出发,我们最感兴趣的是__init、__exit、__initcall以及__exitcall。把迄今为止所讨论的一切做个总结,如下图显示了几个宏基于符号MODULECONFIG_HOTPLUG是否定义在节省内存的效力。由图可见,当内核不支持可加载模块和热插拔时,与两种选项都支持时相比可利用的空间就很多很多:限制越多,就越能得到最优化。
在这里插入图片描述

我们逐一说明图中1~6的意义。
把模块编译成内核的一部分时,就可做下列最优化:

  1. module_exit函数绝不会被用到,所以将其标记为__exit之后,程序员就可确保在链接期间这些函数将不会被引入映象中。
  2. module_init函数只会在引导期间执行一次,所及标记为__init之后,程序员就可确保它们在执行后就被丢弃。
  3. module_init(fn)变成__initcall(fn)的别名,如此就能确保fn会被do_initcalls执行。
  4. module_exit(fn)变成__exitcall(fn)的别名。这会把输入函数的地址放到.exitcall.exit内存节区中,使得内核在关机期间可以更为i轻易地予以执行,但时该节区实际会在链接期间被丢弃。
  5. 无论MODULE是否定义,当内核不支持热插拔时,设备就无法从正在运行中地系统中删除。因此,remove函数绝不能被PCI层调用,从而得以初始化为NULL指针。这是由__devexit_p宏指出的。
  6. 当内核不支持热插拔或模块时,该模块就不再需要驱动程序用于初始化pci_driver_remove函数。这是由__devexit宏指出的。注意,当支持模块时就是不如此了。因为用户可以加载或卸载模块,内核就需要remove函数。

通过/proc文件系统调整

就本节而言,与/proc中的文件无关

涉及的函数和变量

在这里插入图片描述
在这里插入图片描述

涉及的文件和目录

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jacky~~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值