0%

Linux 内核设计与实现

第三章 进程管理

主要讲了进程相关。什么是进程?进程描述及结构,如何创建进程?

3.1进程

  • 进程是什么?

    进程是执行期的程序以及它所使用的资源的总称。

  • 线程是什么?

    进程中的活动对象,执行单位。每个线程都有独立的程序计数器,进程栈和一组进程寄存器,共享进程的资源。内核调度的对象是线程。对linux来说,线程是一种特殊的进程。

3.2 进程描述符和任务结构

内核把进程的列表存放在任务列表的双向循环链表中。表中的每一项都是task_struct,进程描述符,用来管理一个进程,包含管理进程的所有信息。包含的数据能够描述一个正在执行的程序。如进程打开的文件,状态,地址空间等等。
通过slab分配器分配task_struct结构。一般放到栈的最后。

在内核中 一般用宏current查找到正在运行进程的进程描述符。

3.3 进程的状态

运行 可中断 不可中断 trace追踪 停止。

内核使用set_task_state(task,state)函数来调整进程的状态。

3.4 进程的家族数

所有的linux进程,都是init进程的后代。linux中每个进程必有一个父进程和若干个子进程。其关系置于进程描述符中,包含一个指向父进程的task_struct的parent指针,和称为children 的子进程链表

3.5 进程的创建

unix采用独特的进程创建方式,使用两个系统调用创建进程,读取可执行文件。frok()拷贝当前进程创建一个子进程,此时子进程和父进程的区别只在于pid和某些资源与统计量。然后执行exec函数负责读取可执行文件将其装入地址空间开始运行。

frok()使用了一种写时拷贝的技术。不复制整个进程地址空间,而是父子共享。在需要写入的时候,才复制,从而使各个进程各自用于各自的拷贝。( 疑惑?父进程写入的时候子进程也拷贝吗?)

  1. 首先调用dup_task_struct为新进程创建内核栈,thread_info和task_struct。这些值和当前进程的值一致,且此时进程描述符也是完全相同的。
  2. 检查有没有超出用户最大的的进程资源数量。

第四章 进程调度

绝大多数可以在内核之旅的读书笔记上看,这里只做自己不理解的比较细节的问题的解答,及上面没有的补充。

  • 进程也可以分类,io消耗型和处理器消耗型,但这两者并不互斥。

  • 两种调度优先级,一种内核的nice值,越小优先级越大,另一种是实时优先级,实时优先级数值越高优先级越高,这两个是互斥的,有nice则无实时优先级。有实时优先级说明是实时进程,实时进程的优先级都高于普通进程。nice值的是普通进程。

    在 Linux 系统中,实时进程是指具有严格时间约束、优先级高于普通进程的特殊进程,其设计目标是确保在指定时间内完成任务,避免因调度延迟导致功能失效。

  • 首先我们知道CFS是个什么,它是linux的调度器的一个模块,一个调度器类,只调度属于自己范畴的进程,CFS是一个针对普通进程的调度器类。对于进程调度,设计为分配给进程一个处理器的使用比例而非固定时间片。这个使用比列是在调度周期里的。调度周期是指 CFS 调度器将 CPU 时间分配给所有可运行进程的总时间长度。在一个调度周期内,每个进程按其权重获得相应的 CPU 时间片。 目标延迟是指 CFS 调度器保证的、任意进程等待 CPU 的最大时间上限。当系统中进程数较少时,调度周期会收敛到目标延迟,确保低负载下的响应性 为了防止进程数量趋于无限,所有任务都获得的使用比和时间都趋于零,会有一个最小粒度,用来保证任务进程能获得的时间片的底线。最小粒度是 CFS 调度器中控制时间片下限的关键参数,它通过限制单个进程的最小执行时间,平衡了系统的公平性与切换开销。

  • 什么是虚拟实时?

    虚拟实时是 CFS 调度器为每个进程维护的一个虚拟运行时间,它将物理 CPU 时间根据进程优先级(权重)进行了归一化,CFS使用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。对于vruntime的更新逻辑不太明白,还有加权计算,留待深入了解时再来补写.

    使用update_curr来实时更新当前运行进程的虚拟时间(这个是由系统定时器周期性调用的.)

    • 根据当前进程的物理执行时间权重,计算并累加其 vruntime。
    • 公式:Δvruntime = (物理时间 × NICE_0_LOAD) / 进程权重
  • 调度时靠红黑树,用一个最最子节点的缓存来选择下一个要调度的进程,选择拥有最小vruntime的进程

  • 调度器入口是Schedule(),通常都会和一个具体的调度类相关联,也就是说,它会找到一个最高优先级的调度类,调度类要有自己的可运行队列.

第七章 中断处理

  1. 什么是中断?

    中断机制是硬件在需要的时候向CPU发出信号,cpu暂停正在进行的工作,来处理硬件请求的一种机制。

    详细点的就是硬件向中断控制器的输入引脚发送一个信号,中断控制器会给cpu发送一个电信号,cpu检测到这个信号,就会停止当前的工作,转而处理中断,此后处理器会通知操作系统已经产生中断,这样,操作系统就可以对这个中断进行处理。

  2. 中断处理程序

    在相应特定在中断的时候,内核会执行一个函数,该函数叫做中断处理函数。产生中断的每一个设备都有一个相应的中断处理函数。中断处理程序运行在中断上下文中(处理器相应中断所处的特殊执行环境)

    中断上下文与进程上下文的对比

    特性 中断上下文 进程上下文
    所属主体 内核(与 CPU 绑定) 具体用户进程或内核线程
    栈空间 内核固定大小栈(CPU 专属) 进程的用户栈或内核栈(动态大小)
    阻塞操作 不允许(会导致系统问题) 允许(如等待 I/O、睡眠)
    执行时间 必须极短(毫秒级内) 可长可短(取决于进程任务)
    内存访问 仅能访问内核空间 可访问用户空间和内核空间
    典型操作 设备数据读取、中断标志清除 进程调度、系统调用、内存管理等
  3. 中断类型

    中断一般分为异步中断(一般由硬件引起)和同步中断(一般由处理器本身引起)。

    异步中断:CPU处理中断的时间过长,所以先将硬件复位,使硬件可以继续自己的工作,然后在适当时候处理中断请求中耗时的部分。

    举个例子:网卡的工作原理

    1. 网卡收到数据包后,向CPU发出中断信号,请求处理接收到的数据包
    2. CPU将收到的数据包拷贝到内存后,即通知网卡继续工作 (上半部)
    3. 至于数据包拷贝至内存后的处理会在适当的时候进行 (下半部)

    这样做避免了处理数据包时间过长导致网卡接收数据包速度变慢。

    同步中断:CPU处理完中断请求的所有工作后才反馈硬件

    举个例子:系统异常处理(比如运算中的除0操作)

    1. 应用程序出现异常后,需要内核来处理
    2. 内核调用相应的异常处理函数来处理异常
    3. 处理完后终了应用程序或者给出message

    同步中断应该处理能很快完成的一种中断

  4. 中断上半部和下半部

    上半部只在硬件中断上下文中运行,实时性要求极高,必须立即执行。特点是执行时间短,只处理最紧急,以避免阻塞其他。执行期间禁止同级或更低优先级的中断,但是可能允许更高优先级的中断。

    下半部在非中断上下运行,比如进程上下文,允许延迟执行,可处理耗时任务。处理上半部未完成的较为复杂的任务,如对网络卡接收到的数据包进行详细解析、处理和存储等。

  5. 中断相关函数

    实现一个中断,主要需要三个函数

    • 注册中断的函数
    • 释放中断的函数
    • 中断处理程序的声明

    5.1 注册中断的函数

    位置: <linux/interrupt.h> include/linux/interrupt.h

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /*
    * irg - 表示要分配的中断号
    * handler - 实际的中断处理程序
    * flags - 标志位,表示此中断的具有特性
    * name - 中断设备名称的ASCII 表示,这些会被/proc/irq和/proc/interrupts文件使用
    * dev - 用于共享中断线,多个中断程序共享一个中断线时(共用一个中断号),依靠dev来区别各个中断程序
    * 返回值:
    * 执行成功:0
    * 执行失败:非0
    */
    /* 分配一条给定的中断线*/
    int request_irq(unsigned int irq,
    irq_handler_t handler,
    unsigned long flags,
    const char* name,
    void *dev)

    5.2 释放中断的函数

    定义比较简单:

    1
    void free_irq(unsigned int irq, void *dev)

    如果不是共享中断线,则直接删除irq对应的中断线。

    如果是共享中断线,则判断此中断处理程序是否中断线上的最后一个中断处理程序,

    是最后一个中断处理程序 -> 删除中断线和中断处理程序

    不是最后一个中断处理程序 -> 删除中断处理程序

    5.3 中断处理程序的声明

    声明格式如下:

    1
    2
    3
    4
    5
    6
    7
    8
    /* 
    * 中断处理程序的声明
    * @irp - 中断处理程序(即request_irq()中handler)关联的中断号
    * @dev - 与 request_irq()中的dev一样,表示一个设备的结构体
    * 返回值:
    * irqreturn_t - 执行成功:IRQ_HANDLED 执行失败:IRQ_NONE
    */
    static irqreturn_t intr_handler(int, irq, void *dev)
  6. 中断处理机制

    中断处理的过程主要涉及函数:

    • do_IRQ 与体系结构有关,对所接收的中断进行应答
    • handle_IRQ_event 调用中断线上所有中断处理
    • ret_from_intr 恢复寄存器,将内核恢复到中断前的状态。

    img

1.中断触发阶段

  • 硬件信号:外部设备(如键盘、网卡、定时器)通过中断控制器(如 8259A 或 APIC)向 CPU 发送中断请求信号(IRQ)。
  • 中断向量号:每个中断源对应一个唯一的中断向量号(如键盘中断为 0x21),用于标识中断类型。
  • 边沿 / 电平触发:中断信号可以是边沿触发(如上升沿)或电平触发(如高电平持续),取决于硬件设计。
  1. CPU 响应阶段
  • 检测中断:CPU 在每条指令执行结束后,检查中断引脚是否有信号。
  • /保存上下文:若有中断请求且 CPU 允许响应(IF 标志位为 1),则暂停当前程序,保存硬件上下文(如寄存器值、FLAGS 标志、CS:IP 指令指针)到内核栈。
  • 关中断:响应中断时,CPU 自动清除 IF 标志(CLI 指令),禁止同级或低级中断干扰,确保原子性。
  • 获取中断向量:根据中断向量号,从中断向量表(IVT,实模式)或中断描述符表(IDT,保护模式)中查找对应的中断处理程序地址。
  1. 中断处理程序执行阶段
  • 跳转到 ISR:CPU 将控制权转移到中断服务程序(ISR,Interrupt Service Routine)的入口地址。

  • 中断上下文建立:

  • 保存更多寄存器值到内核栈(如通用寄存器 eax、ebx 等)。

  • 设置内核栈指针,切换到中断处理专用栈(避免与进程栈冲突)。

  • 中断处理分阶段:

  • 上半部(Top Half):快速执行紧急任务(如读取设备数据、清除中断标志),通常禁止中断。

  • 下半部(Bottom Half):将耗时操作推迟到非关键路径(如数据处理、唤醒等待进程),通过软中断、tasklet 或工作队列实现。

  • 中断返回:执行 IRET/IRETD 指令,恢复硬件上下文(寄存器值、FLAGS、CS:IP),重新开中断(IF 标志恢复)。

  1. 中断嵌套与优先级
  • 多级中断:现代 CPU 支持多级中断优先级(如 x86 的 IRQL 机制),高优先级中断可抢占低优先级中断。
  • 中断屏蔽:通过设置中断控制器的屏蔽位(如 8259A 的 IMR 寄存器),可选择性屏蔽某些中断源。
  1. 中断控制的方法
函数 说明
local_irq_disable() 禁止本地中断传递
local_irq_enable() 激活本地中断传递
local_irq_save() 保存本地中断传递的当前状态,然后禁止本地中断传递
local_irq_restore() 恢复本地中断传递到给定的状态
disable_irq() 禁止给定中断线,并确保该函数返回之前在该中断线上没有处理程序在运行
disable_irq_nosync() 禁止给定中断线
enable_irq() 激活给定中断线
irqs_disabled() 如果本地中断传递被禁止,则返回非0;否则返回0
in_interrupt() 如果在中断上下文中,则返回非0;如果在进程上下文中,则返回0
in_irq() 如果当前正在执行中断处理程序,则返回非0;否则返回0

第八章 下半部分和推后执行的工作

1. 中断下半部处理

那么对于一个中断,如何划分上下两部分呢?哪些处理放在上半部,哪些处理放在下半部?

这里有一些经验可供借鉴:

  1. 如果一个任务对时间十分敏感,将其放在上半部
  2. 如果一个任务和硬件有关,将其放在上半部
  3. 如果一个任务要保证不被其他中断打断,将其放在上半部
  4. 其他所有任务,考虑放在下半部

上半部分完成绝对中断的及时相应,比如操作硬件对中断的到达确认,从硬件中拷贝数据。

下半部的任务就是执行和中断处理密切相关但中断处理程序本身不执行的工作。

2. 实现中断下半部的机制

实现下半部的方法很多,随着内核的发展,产生了一些新的方法,也淘汰了一些旧方法。

目前使用最多的是以下3种方法

  • 2.1 软中断
  • 2.2 tasklet
  • 2.3 工作队列

2.1 软中断

软中断是子啊编译期间静态分配的。

其流程如图所示:

23111716-dc0f4c90540c48569dd18f835b2d8af9.png (543×358)

在编译期间,有个枚举类型静态声明软中断,内核用这些从0开始的索引标识一种相对优先级。索引号小的软中断在索引号大的软中断之前执行。软中断类型通过enum枚举定义,每个枚举值对应一个唯一的软中断编号。例如,在include/linux/interrupt.h中:建立一个新的软中断必须在此枚举类型种加入新的项。

内核维护一个全局数组softirq_vec,每个数组元素对应一个软中断处理函数:

1
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;
  • NR_SOFTIRQS表示软中断的最大数量(通常为 32)。
  • 数组索引与枚举值一一对应,例如NET_TX_SOFTIRQ对应的处理函数存储在softirq_vec[NET_TX_SOFTIRQ]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */

NR_SOFTIRQS
};

为什么创建新的软中断时,必须修改枚举类型并添加新的项?

  1. 唯一性保证
    每个软中断必须有唯一的编号,枚举类型通过编译器确保不会出现重复值。
  2. 静态数组索引
    软中断处理函数数组softirq_vec的索引依赖于枚举值。例如,新添加的软中断MY_NEW_SOFTIRQ必须对应数组中的特定位置。
  3. 编译时验证
    通过枚举类型,内核在编译时就能检查软中断编号是否越界或冲突,避免运行时错误。

流程:

  1. 注册软中断的函数open_softirq(kernel/softirq.c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 /* 
* 将软中断类型和软中断处理函数加入到软中断序列中
* @nr - 软中断类型 索引号
* @(*action)(struct softirq_action *) - 软中断处理的函数指针
*/
void open_softirq(int nr, void (*action)(struct softirq_action *))
{
/* softirq_vec是个struct softirq_action类型的数组 */
softirq_vec[nr].action = action;
}
/*
* 当内核运行一个软中断处理程序的时候,他就会执行这个action函数
* 参数指向相应softirq_action结构体的指针
*/
void my_softirq_handler(struct softirq_action *h)
{
// 软中断处理逻辑
}

struct softirq_action 的定义也在 include/linux/interrupt.h 文件中

1
2
3
4
5
6
7
8
9
10
11
12
/*
* 这个结构体的字段是个函数指针,字段名称是action
* 函数指针的返回指是void型
* 函数指针的参数是 struct softirq_action 的地址,其实就是指向 softirq_vec 中的某一项
* 如果 open_softirq 是这样调用的: open_softirq(NET_TX_SOFTIRQ, my_tx_action);
* my_tx_action 就是处理函数
* 那么 my_tx_action 的参数就是 softirq_vec[NET_TX_SOFTIRQ]的地址
*/
struct softirq_action
{
void (*action)(struct softirq_action *);
};
  1. 触发软中断 raise_softirq 参见 kernel/softirq.c文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /*
    * 触发某个中断类型的软中断
    * @nr - 被触发的中断类型
    * 从函数中可以看出,在处理软中断前后有保存和恢复寄存器的操作
    * 触发软中断前 “禁止中断” 是为了保证 pending 位图修改的原子性,避免并发场景下的竞态和数据不一致;而 “恢复状态” 是为了最小化对系统中断响应的影响,确保其他中断能正常处理。这一机制是内核在 “安全性” 和 “实时性” 之间的平衡设计,既保护了软中断触发的可靠性,又避免了过度屏蔽中断导致的系统性能下降。
    */
    void raise_softirq(unsigned int nr)
    {
    unsigned long flags;

    local_irq_save(flags); // 保存当前中断状态,并静止中断.
    raise_softirq_irqoff(nr);
    local_irq_restore(flags);
    }
    void raise_softirq_irqoff(unsigned int nr) {
    __raise_softirq_irqoff(nr);
    if (!in_interrupt())
    wakeup_softirqd(); // 唤醒软中断内核线程
    }
    /*
    * 通过设置pending位图(每个 CPU 一个)标记软中断待处理。
    * 若当前不在中断上下文,唤醒ksoftirqd内核线程处理软中断。
    */
  2. 执行软中断 do_softirq

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    asmlinkage void do_softirq(void)
    {
    __u32 pending;
    unsigned long flags;

    /* 判断是否在中断处理中,如果正在中断处理,就直接返回 */
    if (in_interrupt())
    return;

    /* 保存当前寄存器的值 */
    local_irq_save(flags);

    /* 取得当前已注册软中断的位图 */
    pending = local_softirq_pending();

    /* 循环处理所有已注册的软中断 */
    if (pending)
    __do_softirq();

    /* 恢复寄存器的值到中断处理前 */
    local_irq_restore(flags);
    }

    asmlinkage void __do_softirq(void) {
    unsigned long pending;
    int max_restart = MAX_SOFTIRQ_RESTART; // 默认10次
    int cpu;

    pending = local_softirq_pending(); // 获取待处理软中断位图
    account_system_vtime(current);
    __local_bh_disable_ip(_RET_IP_, SOFTIRQ_OFFSET);
    lockdep_softirq_enter();

    cpu = smp_processor_id();
    restart:
    /* Reset the pending bitmask before enabling irqs */
    set_softirq_pending(0); // 清除位图

    local_irq_enable(); // 开中断,允许嵌套

    h = softirq_vec;
    while ((softirq_bit = ffs(pending))) { // 查找待处理的最高位
    unsigned int vec_nr;
    int prev_count;

    h += softirq_bit - 1; // 定位到对应softirq_action
    vec_nr = h - softirq_vec; // 计算软中断编号
    prev_count = preempt_count();

    kstat_incr_softirqs_this_cpu(vec_nr);

    trace_softirq_entry(vec_nr);
    h->action(h); // 调用处理函数!
    trace_softirq_exit(vec_nr);

    if (unlikely(prev_count != preempt_count())) {
    printk(KERN_ERR "huh, entered softirq %u %s %p"
    "with preempt_count %08x, exited with %08x?\n",
    vec_nr, softirq_to_name[vec_nr],
    h->action, prev_count, preempt_count());
    preempt_count() = prev_count;
    }
    h++;
    pending >>= softirq_bit; // 右移,处理下一位
    }

    local_irq_disable(); // 关中断

    pending = local_softirq_pending();
    if (pending && --max_restart)
    goto restart; // 若有新的软中断被触发,且未超过重试次数,继续处理

    if (pending)
    wakeup_softirqd(); // 若仍有待处理,唤醒内核线程

    lockdep_softirq_exit();
    account_system_vtime(current);
    _local_bh_enable(SOFTIRQ_OFFSET);
    }
  3. 执行相应的软中断 - 执行自己写的中断处理

znelinux中,执行软中断有专门的内核线程,每个处理器对应一个线程,名称ksoftirqd/n (n对应处理器号)

2.2 tasklet

tasklet也是利用软中断实现的,但是他提供了比软中断更好用的接口(实际就是基于软中断又封装了一下),所以除了对性能要求特别高的情况,一般建议使用tasklet来实现自己的中断。tasklet由两类软中断代表:HI_SOFTIRQ和TASKLET_SOFTIRQ。这两者的唯一区别就是前者优先级高。

tasklet对应的结构体在<linux/interrupt.h>

1
2
3
4
5
6
7
8
9
10
11
/*
* 每个结构体代表一个 tasklet
*/
struct tasklet_struct
{
struct tasklet_struct *next; /* 链表中的下一个tasklet */
unsigned long state; /* tasklet状态 */
atomic_t count; /* 引用计数器 */
void (*func)(unsigned long); /* tasklet处理函数 和软中断的action一样,*/
unsigned long data; /* tasklet处理函数的参数 */
};

tasklet状态只有3种值:

  1. 值 0 表示该tasklet没有被调度
  2. 值 TASKLET_STATE_SCHED 表示该tasklet已经被调度
  3. 值 TASKLET_STATE_RUN 表示该tasklet正在运行

引用计数器count 的值不为0,表示该tasklet被禁止,不允许被执行。只有当它为0,tasklet才被激活,并且在被设置为挂起状态时,该taaklet才能够执行。

tasklet的使用流程如下:

  1. 声明tasklet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* 参见linux/interrupt.h
* 使用宏 静态声明一个tasklet
*/
#define DECLARE_TASKLET(name, func, data) \

// 这段代码等价于
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }

#define DECLARE_TASKLET_DISABLED(name, func, data) \

/*动态声明一个tasklet 传递一个tasklet_struct指针给初始化函数*/
extern void tasklet_init (struct tasklet_struct *t,
void (*func)(unsigned long), unsigned long data);
void tasklet_init(t , tasklet_handler, dev);
  1. 编写处理程序

参照tasklet处理函数的原型来写自己的处理逻辑

1
void task_handler (unsigned long data);
  1. 调度tasklet

中断的上半部分处理完后调度tasklet,在适当时候进行下半部的处理。

1
tasklet_schedule(&my_tasklet) /* 这里的就是之前声明的,这里将my_tasklet标记为挂起。*/

调度的流程:

2.3 工作队列

工作队列子系统是一个用于创建内核线程的接口,通过它可以创建一个工作者线程来专门处理中断的下半部工作,负责执行由内核其他部分排到队列里的任务。工作队列提供了一个缺省的工作者线程来处理这些工作。叫做events/n(n对应处理器号)

工作队列和tasklet不一样,不是基于软中断来实现的。

工作队列主要用到下面三个结构体,弄懂了这三个结构体的关系!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
 /* 在 include/linux/workqueue.h 文件中定义 */
struct work_struct {
atomic_long_t data; /* 这个并不是处理函数的参数,而是表示此work是否pending等状态的flag */
#define WORK_STRUCT_PENDING 0 /* T if work item pending execution */
#define WORK_STRUCT_FLAG_MASK (3UL)
#define WORK_STRUCT_WQ_DATA_MASK (~WORK_STRUCT_FLAG_MASK)
struct list_head entry; /* 中断下半部处理函数的链表 */
work_func_t func; /* 处理中断下半部工作的函数 */
#ifdef CONFIG_LOCKDEP
struct lockdep_map lockdep_map;
#endif
};

/* 在 kernel/workqueue.c文件中定义
* 每个工作者线程对应一个 cpu_workqueue_struct ,其中包含要处理的工作的链表
* (即 work_struct 的链表,当此链表不空时,唤醒工作者线程来进行处理)
*/
/*
* The per-CPU workqueue (if single thread, we always use the first
* possible cpu).
*/
struct cpu_workqueue_struct {

spinlock_t lock; /* 锁保护这种结构 */

struct list_head worklist; /* 工作队列头节点 */
wait_queue_head_t more_work;
struct work_struct *current_work;

struct workqueue_struct *wq; /* 关联工作队列结构 */
struct task_struct *thread; /* 关联线程 */
} ____cacheline_aligned;

/* 也是在 kernel/workqueue.c 文件中定义的
* 每个 workqueue_struct 表示一种工作者类型,系统默认的就是 events 工作者类型
* 每个工作者类型一般对应n个工作者线程,n就是处理器的个数
*/
/*
* The externally visible workqueue abstraction is an array of
* per-CPU workqueues:
*/
struct workqueue_struct {
struct cpu_workqueue_struct *cpu_wq; /* 工作者线程 */
struct list_head list;
const char *name;
int singlethread;
int freezeable; /* Freeze threads during suspend */
int rt;
#ifdef CONFIG_LOCKDEP
struct lockdep_map lockdep_map;
#endif
};
  • 使用工作队列

第十二章 内存管理

1.1 页和区

在内核中把物理页作为内存管理的基本单位。页的大小根据体系结构不同大小不同,32就是4KB,64位就是8KB,使用一个page结构体来表示系统中的每个页。

1
2
3
4
5
6
7
8
9
10
struct page {
unsigned long flags; // 存放页的状态 可以至少同时表示出32种不同的状态
atomic_t _count; // 页的引用计数,为-1的时候表示内核没有引用这一页,在新的分配中可以使用它,内核调用page_count(struct page)来返回0表示这是个空闲页。
atomic_t _mapcount;
unsigned long private; //私有数据
struct address_space *mapping; //指向和这个页相关联address_space 对象
pgoff_t index;
struct list_head lru;
void *virtual; //页的虚拟地址,高端内存的动态映射地址
}

内核仅用这个数据来描述当前在相关物理页种存放的东西,目的在于描述物理页本身,即页是否被分配,谁拥有这个页等等。

区是为了在有限的虚拟地址空间里,既让内核安全高效的访问所有物理内存,又兼顾dma、性能、碎片和硬件限制的设计。

在32位的机器中,Linux 把 4 GB 虚拟地址空间切成两段:

  • 0–3 GB:用户进程
  • 3–4 GB:内核固定映射(对所有进程相同)

内核又把这 1 GB 继续细分为几个“内存域(zone):

区域 物理地址范围 虚拟地址范围 用途
ZONE_DMA 0–16 MB 3 GB+0–16 MB ISA 老设备 DMA 只能访问低 16 MB
ZONE_NORMAL 16 MB–896 MB 3 GB+16 MB–896 MB 直接映射区,kmalloc() 大多数分配在这里,可以快速找到
ZONE_HIGHMEM 896 MB–4 GB 3 GB+896 MB 以上 高端内存,不能永久映射到内核地址空间,只有在需要时临时映射

linux把系统的页划分为区,形成不同的内存池,这样可以按照用途来分配,分配是不可以跨区的。区的划分没有物理意义,是内核为了管理页采取的逻辑上的分组。

在64位的体系结构中,如x86-64 给内核预留了 128 TB 虚拟地址,足够线性映射 64 TB 物理内存,不再需要 HIGHMEM,只剩下 ZONE_DMA / ZONE_NORMAL。

内核通过区和页这种机制来管理内存。

1.2 获得内存

  • 获取页

    主要是几个请求内存的接口,以页为单位:

场景 / 需求 函数(宏)原型 获得什么 备注
分配 2^order 个连续页 struct page *alloc_pages(gfp_t gfp_mask, unsigned int order) 首 page 指针 最常用底层接口
只拿 1 页 #define alloc_page(gfp) alloc_pages(gfp, 0) 同上
拿页并返回虚拟地址 unsigned long __get_free_pages(gfp_t gfp, unsigned int order) 逻辑地址的指针 内部仍用 alloc_pages,再 page_address
同上,只拿 1 页 #define __get_free_page(gfp) __get_free_pages(gfp, 0) 同上
拿 1 页并清零 unsigned long get_zeroed_page(gfp_t gfp) 逻辑地址的指针 __GFP_ZERO
释放 page 描述符 void __free_pages(struct page *page, unsigned int order) - 与 alloc_pages 成对
释放线性地址 void free_pages(unsigned long addr, unsigned int order) - 与 __get_free_pages 成对

在获得内存后要进行错误检查,可能会分配失败的。

  • 小对象,以字节为单位的内存申请,物理连续
场景 函数原型 获得什么 备注
通用小对象 void *kmalloc(size_t size, gfp_t gfp) 内存块的指针 < 4 MB,最常用
清零版本 void *kzalloc(size_t size, gfp_t gfp) 同上 kmalloc + memset 0
大数组 void *kcalloc(size_t n, size_t size, gfp_t gfp) 同上 kzalloc 的数组封装
释放 void kfree(void *ptr) -

注意检查分配是否成功,返回的是不是null

  • vmalloc()
场景 函数原型 获得什么 备注
任意大小,虚拟连续 void *vmalloc(unsigned long size) 内核虚拟地址 可能睡眠,不能用于 DMA
释放 void vfree(const void *addr) -

大多数情况下,只有硬件设备需要得到物理连续的地址。但是内核使用kmalloc性能好,vmalloc需要一个个的映射,建立页表项,

  • gfp_mask标志

在以上接口中,我们都能发现,它用到了一个参数gfp_t gfp_mask ,这个参数是一个分配器的标志,可以理解为内核把“怎么拿页”抽象成了gfp_mask这样的一个位图。包括从哪里拿?拿的时候能干吗?即区修饰符,行为修饰符。

类别 常用位 作用一句话
区修饰符 __GFP_DMA__GFP_DMA32__GFP_HIGHMEM 限定从 ZONE_DMA / DMA32 / HIGHMEM 拿页,默认从zone_normal区分配。不能给_get_free_pages()和kalloc()指定高端内存,他们返回的是逻辑地址而不是page结构,分配的这个页可能还没有映射到虚拟空间上。
行为修饰符 __GFP_IO__GFP_FS__GFP_WAIT__GFP_COLD__GFP_ZERO__GFP_NOWARN 能否启磁盘 I/O、能否启文件系统 I/O、能否睡眠、是否应该使用高速缓存中快要淘汰出的页、是否清零、是否不打印失败警告

类型标志则是行为和区描述符的组合,来完成特殊类型的处理。一般用这个就行了。

隐含的位 典型场景 能否阻塞
GFP_KERNEL __GFP_WAIT | __GFP_IO | __GFP_FS 普通进程上下文
GFP_ATOMIC __GFP_HIGH | __GFP_ATOMIC 中断、持有 spinlock(不可睡眠)
GFP_NOIO __GFP_WAIT(但无 __GFP_IO 块层、不会启动磁盘I/O不能递归 I/O
GFP_NOFS __GFP_WAIT | __GFP_IO(无 __GFP_FS 文件系统内部
GFP_USER GFP_KERNEL 给用户态进程页缓存
GFP_HIGHUSER GFP_USER | __GFP_HIGHMEM 用户态页,优先 HIGHMEM
GFP_HIGHUSER_MOVABLE GFP_HIGHUSER + 可迁移属性 匿名页、可压缩/迁移
GFP_DMA __GFP_DMA ISA 设备 DMA,通常与上面某个标志组合使用 ✅/❌
GFP_DMA32 __GFP_DMA32 32 位总线设备 ✅/❌

什么时候使用哪种标志:

普通内核数据结构 GFP_KERNEL
中断 / 自旋锁内 GFP_ATOMIC
文件系统内部 GFP_NOFS
块层 I/O 路径 GFP_NOIO
用户态页缓存 GFP_USER / GFP_HIGHUSER_MOVABLE
DMA内存可以睡眠 GFP_DMA|GFP_KERNEL

1.3 使用页

​ 从上面我们知道了怎么从内存中获得页,但对于程序的数据结构来说,一个页的粒度还是太大了,用不了那么多啊,总不能随便一个数据结构就用一页,那多浪费,又产生大量碎片,怎么办呢?那就继续细分!
​ 在内核中,分配和释放数据结构是内核中最普遍的操作,为了便于数据的频繁分配和回收,内核的开发人员经常会用到空闲链表,空闲链表里装着已经分配好的数据结构块,当我们需要的时候就从空闲链表抓一个数据块,把我们的数据塞进去.不用的时候再放回空闲链表(并不释放).这样空闲链表就相当于对象高速缓存(快速存储频繁使用的对象类型).

​ 空闲链表虽便于数据结构的频繁分配和回收,但面临不能全局控制的问题,内核无法在内存紧张时通知其收缩缓存大小以释放内存,且内核不知其存在。

​ 空闲链表也有缺陷,那继续修复.slab层应运而生.slab分配器就扮演了通用数据结构缓存层的角色.我们用slab层在页之上,再做一级对象级的内存管理.

​ slab分配器的设计原则- 缓存频繁使用的数据结构:频繁分配和释放的数据结构应被缓存,以提高性能。- 避免内存碎片:空闲链表的缓存连续存放,防止内存碎片化.

​ 那么slab是怎么设计的呢?

    • slab的设计

​ slab层把不同的对象划分为所谓的高速缓存组,其中的每个高速缓存组都存放着不同类型的对象.而每种对象类型,对应一个高速缓存.在内核中,我们把这个高速缓存称作kmem_cache .他用来专门存放一种对象.一种内核数据结构(如 task_structinodedentry 等)对应一个 kmem_cache.

​ 然后,这些高速缓存又被分为了slab.每个 slab 是 一个或多个连续物理页(通常是 1、2、4、8 页,按 2ⁿ 对齐),内部切成固定数量的 对象槽位。每个slab都包含一些对象成员,,就是被缓存的数据结构.

slab 有三种状态:

empty:所有槽位空闲;系统内存紧张时可以被整页回收。
partial:部分已分配,部分空闲;分配优先从这里拿,避免创建新 slab。
full:已全部用完;不再参与分配,直到有对象被释放回来。

​ 每个slab被划分为许多对象槽.这些槽位是真正返给调用者的那块对齐内存;大小由 kmem_cache 决定,通常是 32、64、128、…、8192 字节档.

​ 分配时:slab 层直接把一个对象槽位从链表里摘掉;

​ 释放时:把对象放回所属 slab,并立刻合并到空闲链表中,O(1) 完成。

可以将这个抽象为一个小抽屉:

┌──────────────────────────┐
│ 抽屉柜:kmem_cache │ ← 一种对象类型一个柜子
│ │
│ ┌────────────────────┐ │
│ │ 抽屉:slab │ ← 一个/多个连续物理页
│ │ │ │
│ │ ┌──┐ ┌──┐ ┌──┐ │ │
│ │ │obj││obj││obj│ … │ │ ← 真正可用的对象实例
│ │ └──┘ └──┘ └──┘ │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ 抽屉:slab │ │
│ └────────────────────┘ │
│ … │
└──────────────────────────┘

​ 上面说了一些理论知识的理解,我们再来看一些代码相关的。我们自上而下的从抽屉柜(高速缓存)kmem_cache来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct kmem_cache {
/* 对象属性 */
unsigned int object_size; /* 用户请求的对象大小 */
unsigned int size; /* 对齐/填充后真正占用空间 */
unsigned int align; /* 对齐边界 */
unsigned int min_partial; /* per-node partial 链表最少保留 slab 数 */

/* 分配/释放钩子 */
void (*ctor)(void *); /* 对象构造函数(如初始化锁) */
const char *name; /* 人类可读名字,/proc/slabinfo 可见 */

/* slab 管理参数 */
unsigned int oo; /* oo = (order << 16) | objects
- order: 本 cache 一个 slab 占 2^order 页
- objects: 该 slab 可容纳对象数 */
unsigned int min; /* 最小 slab 阶数(内存紧张时降级) */

/* CPU 与节点级缓存 */
struct kmem_cache_cpu __percpu *cpu_slab; /* per-CPU 数据结构 */
struct kmem_cache_node *node[MAX_NUMNODES]; /* per-NUMA-node 结构数组 */

/* 调试与安全 */
#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long random; /* 每次启动随机值,用于指针哈希 */
#endif
/* 其余字段:list_head list; (所有 kmem_cache 全局链表) 等 */
};
/*来自kimi的5.18+内核的slub_def.h中的简化视图*/

​ 从结构体内中我们可以看出kmem_cache为某一种专用的对象类型服务,用来统管某一种固定大小对象的分配,结构体的定义在创建阶段就将对象的大小,以及摆放规则划分好。他管理着更下一层的slab,包含着满,半满,空三个数据链表。

​ 再来看看slab。slab描述符struct slab 用来描述每个slab。现在的slab描述符和page复用。

1
2
3
4
5
6
7
8
struct slab {                 /* 与 struct page 共用内存 */
struct kmem_cache *slab_cache; /* 指向所属的 cache */
struct slab *next; /* per-CPU 单向链表指针 */
int slabs; /* 本链表中剩余 slab 数(per-CPU 列表用) */
struct list_head slab_list; /* per-node 双向链表节点 */
void *freelist; /* 指向本 slab 内第一个空闲对象 */
/* 其余 page 字段被复用/隐藏 */
};//这里和2.6有很多不同了,但是思想上还是大体一致。

​ 对象就是一个个简单的结构体就不细述了。

1.5 slab分配器的接口

创建销毁:

kmem_cache_create() struct kmem_cache *kmem_cache_create(const char *name, size_t size, size_t align, unsigned flags, void (*ctor)(void *)); 给某一类对象建专用高速缓存;返回的指针就是“cache 句柄”。
kmem_cache_create_usercopy() 同上,多两个参数 useroffset, usersize 若对象里包含用户可拷贝区域,需用此版本以便 hardened usercopy 检查。
kmem_cache_destroy() void kmem_cache_destroy(struct kmem_cache *s); 卸载模块时销毁;必须保证所有 slab 已空闲,否则返回 -EBUSY
kmem_cache_shrink() int kmem_cache_shrink(struct kmem_cache *s); 主动把空闲 slab 还给伙伴系统,返回值是“已释放页数”。

分配释放:

kmem_cache_alloc() void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfpflags); 从指定 cache 拿对象;gfpflags 同 kmalloc。
kmem_cache_zalloc() void *kmem_cache_zalloc(struct kmem_cache *s, gfp_t gfpflags); 同上但自动清零。
kmem_cache_free() void kmem_cache_free(struct kmem_cache *s, void *obj); 把对象放回所属 cache;必须配对,不能 kfree。
kmem_cache_alloc_bulk() int kmem_cache_alloc_bulk(struct kmem_cache *s, gfp_t gfp, size_t nr, void **objs); 一次拿多个对象,减少 per-CPU 链表锁操作。
kmem_cache_free_bulk() void kmem_cache_free_bulk(struct kmem_cache *s, size_t nr, void **objs); 对应批量释放。

kmem_cache_create() 只是把“对象规格、对齐、构造/析构钩子、per-CPU 与 per-node 管理结构”准备好,并没有真正去向伙伴系统拿页、也没有生成任何 slab。

第一次真正需要内存时(即第一次调用 kmem_cache_alloc() 且发现 freelist 为空)才会触发。

分配器2.6中和现代内核差距不小,后面再看。

2.6内核中,获得一个slab的内存是底层调用用kmem_getpages(strcut kmem_cahce * cachep,gfp_t flags,int nodeid)这个函数函数来实现的,而这个用的是__get_free_pages()低级内核页分配器进行的。

-------------本文结束感谢您的阅读-------------