公告

Gentoo交流群:87709706 欢迎您的加入

#1 进程模块 » Gentoo6.6.13 内核配置选项--General setup--Timers subsystem(1) » 2024-04-10 07:57:20

batsom
回复: 0

一、前言

时钟或者钟表(clock)是一种计时工具,每个人都至少有一块,可能在你的手机里,也可能佩戴在你的手腕上。如果Linux也是一个普通人的话,那么她的手腕上应该有十几块手表,包括:CLOCK_REALTIME、CLOCK_MONOTONIC、CLOCK_PROCESS_CPUTIME_ID、CLOCK_THREAD_CPUTIME_ID、CLOCK_MONOTONIC_RAW、CLOCK_REALTIME_COARSE、CLOCK_MONOTONIC_COARSE、CLOCK_BOOTTIME、CLOCK_REALTIME_ALARM、CLOCK_BOOTTIME_ALARM、CLOCK_TAI。本文主要就是介绍Linux内核中的形形色色的“钟表”。

二、理解Linux中各种clock分类的基础

既然本文讲Linux中的计时工具,那么我们首先面对的就是“什么是时间?”,这个问题实在是太难回答了,因此我们这里就不正面回答了,我们只是从几个侧面来窥探时间的特性,而时间的本质就留给物理学家和哲学家思考吧。

1、如何度量时间

时间往往是和变化相关,因此人们往往喜欢使用有固定周期变化规律的运动行为来定义时间,于是人们把地球围自转一周的时间分成24份,每一份定义为一个小时,而一个小时被平均分成3600份,每一份就是1秒。然而,地球的运动周期不是那么稳定,怎么办?多测量几个,平均一下嘛。

虽然通过天体的运动定义了秒这样的基本的时间度量单位,但是,要想精确的表示时间,我们依赖一种有稳定的周期变化的现象。上一节我们说过了:地球围绕太阳运转不是一个稳定的周期现象,因此每次观察到的周期不是固定的(当然都大约是24小时的样子),用它来定义秒多少显得不是那么精准。科学家们发现铯133原子在能量跃迁时候辐射的电磁波的振荡频率非常的稳定(不要问我这是什么原理,我也不知道),因此被用来定义时间的基本单位:秒(或者称之为原子秒)。

2、Epoch

定义了时间单位,等于时间轴上有了刻度,虽然这条代表时间的直线我们不知道从何开始,最终去向何方,我们终归是可以把一个时间点映射到这条直线上了。甚至如果定义了原点,那么我们可以用一个数字(到原点的距离)来表示时间。

如果说定义时间的度量单位是技术活,那么定义时间轴的原点则完全是一个习惯问题。拿出你的手表,上面可以读出2017年5月10,23时17分28秒07毫秒……作为一个地球人,你选择了耶稣诞辰日做原点,讲真,这弱爆了。作为linuxer,你应该拥有这样的一块手表,从这个手表上只能看到一个从当前时间点到linux epoch的秒数和毫秒数。Linux epoch定义为1970-01-01 00:00:00 +0000 (UTC),后面的这个UTC非常非常重要,我们后面会描述。

除了wall time,linux系统中也需要了解系统自启动以来过去了多少的时间,这时候,我们可以把钟表的epoch调整成系统的启动时间点,这时候获取系统启动时间就很容易了,直接看这块钟表的读数即可。

3、时间调整

记得小的时候,每隔一段时间,老爸的手表总会慢上一分钟左右的时间,也是他总是在7点钟,新闻联播之前等待那校时的最后一响。一听到“刚才最后一响是北京时间7点整”中那最后“滴”的一声,老爸也把自己的手表调整成为7点整。对于linux系统,这个操作类似clock_set接口函数。

类似老爸机械表的时间调整,linux的时间也需要调整,机械表的发条和齿轮结构没有那么精准,计算机的晶振亦然。前面讲了,UTC的计时是基于原子钟的,但是来到Linux内核这个场景,我们难道要为我们的计算机安装一个原子钟来计时吗?当然可以,如果你足够有钱的话。我们一般人的计算机还是基于系统中的本地振荡器来计时的,虽然精度不理想,但是短时间内你也不会有太多的感觉。当然,人们往往是向往更精确的计时(有些场合也需要),因此就有了时间同步的概念(例如NTP(Network Time Protocol))。

所谓时间同步其实就是用一个精准的时间来调整本地的时间,具体的调整方式有两种,一种就是直接设定当前时间值,另外一种是采用了润物细无声的形式,对本地振荡器的输出进行矫正。第一种方法会导致时间轴上的时间会向前或者向后的跳跃,无法保证时间的连续性和单调性。第二种方法是对时间轴缓慢的调整(而不是直接设定),从而保证了连续性和单调性。

4、闰秒(leap second)

通过原子秒延展出来的时间轴就是TAI(International Atomic Time)clock。这块“表”不管日出、日落,机械的按照ce原子定义的那个秒在推进时间。冷冰冰的TAI clock虽然精准,但是对人类而言是不友好的,毕竟人还是生活在这颗蓝色星球上。而那些基于地球自转,公转周期的时间(例如GMT)虽然符合人类习惯,但是又不够精确。在这样的背景下,UTC(Coordinated Universal Time)被提出来了,它是TAI clock的基因(使用原子秒),但是又会适当的调整(leap second),满足人类生产和生活的需要。

OK,至此,我们了解了TAI和UTC两块表的情况,这两块表的发条是一样的,按照同样的时间滴答(tick,精准的根据原子频率定义的那个秒)来推动钟表的秒针的转动,唯一不同的是,UTC clock有一个调节器,在适当的时间,可以把秒针向前或者向后调整一秒。

TAI clock和UTC clock在1972年进行了对准(相差10秒),此后就各自独立运行了。在大部分的时间里,UTC clock跟随TAI clock,除了在适当的时间点,realtime clock会进行leap second的补偿。从1972年到2017年,已经有了27次leap second,因此TAI clock的读数已经比realtime clock(UTC时间)快了37秒。换句话说,TAI和UTC两块表其实可以抽象成一个时间轴,只不过它们之间有一个固定的偏移。在1972年,它们之间的offset是10秒,经过多年的运转,到了2017年,offset累计到37秒,让我静静等待下一个leap second到了的时刻吧。

5、计时范围

有一类特殊的clock称作秒表,启动后开始计时,中间可以暂停,可以恢复。我们可以通过这样的秒表来记录一个人睡眠的时间,当进入睡眠状态的时候,按下start按键开始计时,一旦醒来则按下stop,暂停计时。linux中也有这样的计时工具,用来计算一个进程或者线程的执行时间。

6、时间精度

时间是连续的吗?你眼中的世界是连续的吗?看到窗外清风吹拂的树叶的时候,你感觉每一个树叶的形态都被你捕捉到了。然而,未必,你看急速前进的汽车的轮胎的时候,感觉车轮是倒转的。为什么?其实这仅仅是因为我们的眼睛大约是每秒15~20帧的速度在采样这个世界,你看到的世界是离散的。算了,扯远了,我们姑且认为时间的连续的,但是Linux中的时间记录却不是连续的,我们可以用下面的图片表示:

FluxBB bbcode 测试

系统在每个tick到来的时候都会更新系统时间(到linux epoch的秒以及纳秒值记录),当然,也有其他场景进行系统时间的更新,这里就不赘述了。因此,对于linux的时间而言,它是一些离散值,是一些时间采样点的值而已。当用户请求时间服务的时候,例如获取当前时间(上图中的红线),那么最近的那个Tick对应的时间采样点值再加上一个当前时间点到上一个tick的delta值就精准的定位了当前时间。不过,有些场合下,时间精度没有那么重要,直接获取上一个tick的时间值也基本是OK的,不需要校准那个delta也能满足需求。而且粗粒度的clock会带来performance的优势。

7、睡觉的时候时间会停止运作吗?

在现实世界提出这个问题会稍显可笑,鲁迅同学有一句名言:时间永是流逝,街市依旧太平。但是对于Linux系统中的clock,这个就有现实的意义了。比如说clock的一个重要的派生功能是创建timer(也就是说timer总是基于一个特定的clock运作)。在一个5秒的timer超期之前,系统先进入了suspend或者关机状态,这时候,5秒时间到达的时候,一般的timer都不会触发,因为底层的clock可能是基于一个free running counter的,在suspend或者关机状态的时候,这个HW counter都不再运作了,你如何期盼它能唤醒系统,来执行timer expired handler?但是用户还是有这方面的实际需求的,最简单的就是关机闹铃。怎么办?这就需要一个特别的clock,能够在suspend或者关机的时候,仍然可以运作,推动timer到期触发。

三、Linux下的各种clock总结

在linux系统中定义了如下的clock id:

>#define CLOCK_REALTIME            0
>#define CLOCK_MONOTONIC            1
>#define CLOCK_PROCESS_CPUTIME_ID    2
>#define CLOCK_THREAD_CPUTIME_ID        3
>#define CLOCK_MONOTONIC_RAW        4
>#define CLOCK_REALTIME_COARSE        5
>#define CLOCK_MONOTONIC_COARSE        6
>#define CLOCK_BOOTTIME            7
>#define CLOCK_REALTIME_ALARM        8
>#define CLOCK_BOOTTIME_ALARM        9
>#define CLOCK_SGI_CYCLE            10    /* Hardware specific */
>#define CLOCK_TAI            11

CLOCK_PROCESS_CPUTIME_ID和CLOCK_THREAD_CPUTIME_ID这两个clock是专门用来计算进程或者线程的执行时间的(用于性能剖析),一旦进程(线程)被切换出去,那么该进程(线程)的clock就会停下来。因此,这两种的clock都是per-process或者per-thread的,而其他的clock都是系统级别的。

根据上面一章的各种分类因素,我们可以将其他clock总结整理如下:

FluxBB bbcode 测试

#2 进程模块 » Gentoo 之 Core Scheduling for SMT » 2024-04-06 23:25:50

batsom
回复: 0

说超线程之前,首先要搞清楚什么是cpu,在之前的有一篇文档中对cpu做了简单介绍。

建立在cpu 础之上的内核-聊聊cpu


超线程是针对cpu提出的一种概念与实现,那么超线程的定义是什么?从某文档中摘抄的定义如下:

超线程(hyper-theading)其实就是同时多线程(simultaneous multi-theading),是一项允许一个CPU执行多个控制流的技术。它的原理很简单,就是把一颗CPU当成两颗来用,将一颗具有超线程功能的物理CPU变成两颗逻辑CPU,而逻辑CPU对操作系统来说,跟物理CPU并没有什么区别。因此,操作系统会把工作线程分派给这两颗(逻辑)CPU上去执行,让(多个或单个)应用程序的多个线程,能够同时在同一颗CPU上被执行。注意:两颗逻辑CPU共享单颗物理CPU的所有执行资源。因此,我们可以认为,超线程技术就是对CPU的虚拟化。

比如上述描述,说超线程是让多个线程能同时在同一颗cpu上被执行,其实我觉得这种描述都不够准确,精确的定义应该是:

超线程是同一个时钟周期内一个物理核心上可以执行两个线程或者进的技术。

超线程的定义主要在三个点上,第一个就是同一个时钟周期内,第二个是同一个物理核心,第三个就是两个线程同时执行。

正常情况下,没有超线程技术,以上三个条件是绝对无法满足的。

现在开始去分析超线程的实现过程。

首先,为了让单核cpu发挥更大的作,超线程只是其中一种技术,相关的技术还有很多,比如超标量技术等。

指令的基本执行过程包括:
>取指Fetch)::从存储器取指令,并更新PC
>译码(Decode):指令译码,从寄存器堆读出寄存器的值
>执行(Execute):运算指令:进行算术逻辑运算,访存指令:计算存储器的地址
>访存(Memory):Load指令:从存储器读指令,Store指令:将数据写入寄存器
>回写(Write Back):将数据写入寄存器堆

FluxBB bbcode 测试

更具体而言,在具体执行过程中,这几个步骤还会区分前端和后端,而且还会有一些相关的技术。
再具体而言,

前端

>前端按顺序取指令和译码,将X86指令翻译成uop。通过分支预测来提前执行最可能的程序路径。
>带有超标量功能的执行引擎每时钟周期最多执行6条uop。带有乱序功能的执行引擎能够重排列uop执行顺序,只要源数据准备好了,即可执行uop。
>顺序提交功能确保最后执行结果,包括碰到的异常,跟源程序顺序一致。

后端

The Out-of-Order Engine

当一个执行流程再等待资源时,比如l2 cache数据,乱序引擎可以把另一个执行流程的uop发射给执行核心。

> Renamer:每时钟周期最多发射4条uop(包括unfused, micro-fused, or macro-fused)。它的工作为:1 重命名uop里的寄存器,解决false dependencies问题。2 分配资源给给uop,例如load or store buffers。3 绑定uop到合适的dispatch port。
>某些uop可以在rename阶段完成,从而不占用之后的执行带宽。
>Micro-fused load 和store操作此时会分解为2条uop,这样就会占用2个发射槽(总共4个)。(没明白为啥之前2条uop融合为一条了现在又分解回2条)
>Scheduler:当uop需要的资源就绪时,即可调度给下一步执行。根据执行单元可用的ports,writeback buses,就绪uop的优先级, 调度器来选择被发射的uop。
>The Execution Core:具有6个ports,每时钟周期最多发射6条uop。指令发射给port执行完成后,需要把数据通过writeback bus写回。每个port有多个不同运算器,这意味着可以有多个不同uop在同一个port里执行,不同uop的写回延时并不相同,但是writeback bus只能独享,这就会造成uop的等待。Sandy Bridge架构尽可能消除改延时,通过把不同类型数据写回到不同的execution stack中来避免。

FluxBB bbcode 测试

而超线程的实现就是基于以上前端和后端过程的改造与实现。

首先从物理cpu层面上:

从因特尔的cpu开发手册上,我们可以找到超线程的相关实现部分,架构图如下:

FluxBB bbcode 测试

从该架构图上,我们可以看到一个物理核心上有两个逻辑核心,他们有共享的部分也有独立的部分,比如APIC,这个叫做可编程中断控制器,也就是说逻辑核心也是可以自己独立接收中断信号的。

通过该手册,我们可以清楚的了解到超线程中的逻辑核与物理核之间的区别。

The following features are part of he architectural state of logical processors within Intel 64 or IA-32 processors supporting Intel Hyper-Threading Technology. The features can be subdivided into three groups:【以下相关寄存器的作用在文章建立在cpu 基础之上的内核-聊聊cpu中有介绍,但是通过该手册可以了解到,逻辑核心中的大部分寄存器都是独立的,换句话说,在cpu核心中存在双份】

>Duplicated for each logical processor
>Shared by logical processors in a physical processor
>Shared or duplicated, depending on the implementation
>The following features are duplicated for each logical processor:
>General purpose registers (EAX, EBX, ECX, EDX, ESI, EDI, ESP, and EBP)
>Segment registers (CS, DS, SS, ES, FS, and GS)
>EFLAGS and EIP registers. Note that the CS and EIP/RIP registers for each logical processor point to the instruction stream for the thread being executed by the logical processor.
>x87 FPU registers (ST0 through ST7, status word, control word, tag word, data operand pointer, and instruction pointer)
>MMX registers (MM0 through MM7)
>XMM registers (XMM0 through XMM7) and the MXCSR register
>Control registers and system table pointer registers (GDTR, LDTR, IDTR, task register)
>Debug registers (DR0, DR1, DR2, DR3, DR6, DR7) and the debug control MSRs
>Machine check global status (IA32_MCG_STATUS) and machine check capability (IA32_MCG_CAP) MSRs
>Thermal clock modulation and ACPI Power management control MSRs
>Time stamp counter MSRs
>Most of the other MSR registers, including the page attribute table (PAT). See the exceptions below.
>Local APIC registers.
>Additional general purpose registers (R8-R15), XMM registers (XMM8-XMM15), control register,IA32_EFER on Intel 64 processors.
The following features are shared by logical processors:
>Memory type range registers (MTRRs)
Whether the following features are shared or duplicated is implementation-specific:
>IA32_MISC_ENABLE MSR (MSR address 1A0H)
>Machine check architecture (MCA) MSRs (except for the IA32_MCG_STATUS and IA32_MCG_CAP MSRs)
>Performance monitoring control and counter MSRs


其次从指令处理流程上:

整体流程如下

从指令处理过程中,两个逻辑核心都是单独的处理流

前端处理部分

FluxBB bbcode 测试

红和黄分属不同的逻辑核心,在有些步骤不作区分,比如解码

FluxBB bbcode 测试

后端部分,在某些流程共享,某些流程独立。

更加具体的可以阅读相关论文

https://www.moreno.marzolla.name/teachi … _art01.pdf

简而言之,超线程的实现是基于物理层面cpu的支持,在一个物理核心中通过改造寄存器的数量以及共享其他资源,从而实现近似于两个物理核心的能力,在操作系统层面可以把线程和进程向上调度,从而更充分的利用资源,提升cpu性能。

#3 进程模块 » Getnoo 之 process_vm_readv/writev syscalls » 2024-04-06 22:32:12

batsom
回复: 0

多进程之间需要传输大量数据的时候,比如多进程 RPC 框架的进程之间通信,常用共享内存队列。

但是共享内存队列难免会有 入队+出队 2次 memcpy 。

而且要变长共享内存队列,如果支持多生产者进程+多消费者进程 ,就要处理线程安全方面的问题, 比较麻烦。

process_vm_readv() ,  process_vm_writev() 是 Linux 3.2 新增的 syscall,用于在多个进程的地址空间之间,高效传输大块数据。

https://www.man7.org/linux/man-pages/ma … adv.2.html

https://github.com/open-mpi/ompi/blob/m … _get.c#L96

在此, 我提个设想,可以用  process_vm_readv 实现一个多进程内存队列,相比之下,优势是:
>在处理 多线程/多进程 并发时,更简单
>省掉一次 memcpy。

函数声明

#include <sys/uio.h>
ssize_t process_vm_readv(pid_t pid,
                         const struct iovec *local_iov,
                         unsigned long liovcnt,
                         const struct iovec *remote_iov,
                         unsigned long riovcnt,
                         unsigned long flags);
ssize_t process_vm_writev(pid_t pid,
                          const struct iovec *local_iov,
                          unsigned long liovcnt,
                          const struct iovec *remote_iov,
                          unsigned long riovcnt,
                          unsigned long flags);

参数说明
>pid                    进程pid号
>struct iovec *local_iov        结构体local进程指向一个数组基地址
>liovcnt                    local进程数组大小
>struct iovec *remote_iov    结构体remote进程指向一个数组基地址
>riovcnt                remote进程数组大小
>flags                    默认0

介绍

这些系统调用在不同进程地址空间之间传输数据。调用进程:“local进程”以及“remote进程”。数据直接在两个进程的地址空间传输,无需通过内核空间。前提是必须知道传输数据的大小。

process_vm_readv()从remote进程传送数据到local进程。要传输的数据由remote_iov和riovcnt标识:remote_iov指向一个数组,用于描述remote进程的地址范围,而riovcnt指定remote_iov中的元素数。数据传输到由local_iov和liovcnt指定的位置:local_iov是指向描述地址范围的数组的指针。并且liovcnt指定local_iov中的元素数。

process_vm_writev()系统调用是process_vm_readv()的逆过程。它从local进程传送数据到remote进程。除了转移的方向,参数liovcnt,local_iov,riovcnt和remote_iov具有相同的参数含义,与process_vm_readv()相同。

local_iov和remote_iov参数指向iovec结构的数组,在<sys / uio.h>中定义为:

<sys/uio.h>
   struct iovec {
               void  *iov_base;    /* 地址基址 */
               size_t iov_len;     /* 数据传输字节数 */
           };

缓冲区以数组顺序处理。 这意味着process_vm_readv()在进行到local_iov [1]之前会完全填充local_iov [0],依此类推。 同样,在进行remote_iov [1]之前,将完全读取remote_iov[0],依此类推。

同样,process_vm_writev()在local_iov [1]之前写出local_iov [0]的全部内容,并在remote_iov [1]之前完全填充remote_iov [0]。

remote_iov[i].iov_len

local_iov[i].iov_len

的长度不必相同。 因此,可以将单个本地缓冲区拆分为多个远程缓冲区,反之亦然。

flags参数当前未使用,必须设置为0。
返回值

成功后,process_vm_readv()返回读取的字节数,process_vm_writev()返回写入的字节数。 如果发生部分读/写,则此返回值可能小于请求的字节总数。 调用方应检查返回值以确定是否发生了部分读/写。

错误时,返回-1并正确设置errno。

示例

以下代码示例演示了process_vm_readv()的用法,它从具有PID的进程中读取地址上的19个字节,并将前10个字节写入buf1,并将后10个字节写入buf2。

#include <sys/uio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#include <iostream>

using namespace std;

int main(void) {
    struct iovec local[2];
    struct iovec remote[1];
    char buf1[10];
    char buf2[10];
    char remote_addr[]={"abc1234567890defABC"};
    long data_len = strlen(remote_addr);

    ssize_t nread;
    pid_t pid = getpid();             //PID of remote process

//读remotedata_len个字节,buf1 :10 ; buf2 :10
    local[0].iov_base = buf1;
    local[0].iov_len = 10;
    local[1].iov_base = buf2;
    local[1].iov_len = 10;
    remote[0].iov_base = remote_addr;
    remote[0].iov_len = data_len;


    nread = process_vm_readv(pid, local, 2, remote, 1, 0);
    cout<<"cout nread:"<<nread<<endl;
    fprintf(stderr,"read in CreateProcess %s, Process ID %d \n",strerror(errno),pid);

    printf("buf1: %s\n",buf1);
    printf("buf2: %s\n",buf2);

}

相关的系统调用还有readv,writev,preadv,pwritev,preadv2,pwrite2

#include <sys/uio.h>

       ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

       ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

       ssize_t preadv(int fd, const struct iovec *iov, int iovcnt,
                      off_t offset);

       ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt,
                       off_t offset);

       ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt,
                       off_t offset, int flags);

       ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt,
                        off_t offset, int flags);

示例如下:

int main(){
    char *str0 = "hello ";
    char *str1 = "world\n";
    struct iovec iov[2];
    ssize_t nwritten;

    iov[0].iov_base = str0;
    iov[0].iov_len = strlen(str0);
    iov[1].iov_base = str1;
    iov[1].iov_len = strlen(str1);

    nwritten = writev(STDOUT_FILENO, iov, 2);

    printf("nwritten: %d\n",nwritten);
}

#4 引导模块和保护模式 » U-Boot启动过程--详细版的完全分析 » 2024-04-03 23:04:17

batsom
回复: 0

在PC机上引导程序一般由BIOS开始执行,然后读取硬盘中位于MBR(Main Boot Record,主引导记录)中的Bootloader(例如LILO或GRUB),并进一步引导操作系统的启动。

然而在嵌入式系统中通常没有像BIOS那样的固件程序,因此整个系统的加载启动就完全由bootloader来完成。它主要的功能是加载与引导内核映像

一个嵌入式的存储设备通过通常包括四个分区:

>第一分区:存放的当然是u-boot
>第二个分区:存放着u-boot要传给系统内核的参数
>第三个分区:是系统内核(kernel)
>第四个分区:则是根文件系统

如下图所示:
FluxBB bbcode 测试

Bootloader介绍

u-boot是一种普遍用于嵌入式系统中的Bootloader。

Bootloader是进行嵌入式开发必然会接触的一个概念

Bootloader的定义:Bootloader是在操作系统运行之前执行的一小段程序,通过这一小段程序,我们可以初始化硬件设备、建立内存空间的映射表,从而建立适当的系统软硬件环境,为最终调用操作系统内核做好准备。意思就是说如果我们要想让一个操作系统在我们的板子上运转起来,我们就必须首先对我们的板子进行一些基本配置和初始化,然后才可以将操作系统引导进来运行。具体在Bootloader中完成了哪些操作我们会在后面分析到,这里我们先来回忆一下PC的体系结构:PC机中的引导加载程序是由BIOS和位于硬盘MBR中的OS Boot Loader(比如LILO和GRUB等)一起组成的,BIOS在完成硬件检测和资源分配后,将硬盘MBR中的Boot Loader读到系统的RAM中,然后将控制权交给OS Boot Loader。Boot Loader的主要运行任务就是将内核映象从硬盘上读到RAM中,然后跳转到内核的入口点去运行,即开始启动操作系统。在嵌入式系统中,通常并没有像BIOS那样的固件程序(注:有的嵌入式cpu也会内嵌一段短小的启动程序),因此整个系统的加载启动任务就完全由Boot Loader来完成。比如在一个基于ARM7TDMI core的嵌入式系统中,系统在上电或复位时通常都从地址0x00000000处开始执行,而在这个地址处安排的通常就是系统的Boot Loader程序。(先想一下,通用PC和嵌入式系统为何会在此处存在如此的差异呢?)

Bootloader是基于特定硬件平台来实现的,因此几乎不可能为所有的嵌入式系统建立一个通用的Bootloader,不同的处理器架构都有不同的Bootloader,Bootloader不但依赖于cpu的体系结构,还依赖于嵌入式系统板级设备的配置。对于2块不同的板子而言,即使他们使用的是相同的处理器,要想让运行在一块板子上的Bootloader程序也能运行在另一块板子上,一般也需要修改Bootloader的源程序。

Bootloader的启动方式

Bootloader的启动方式主要有网络启动方式、磁盘启动方式和Flash启动方式。

1、网络启动方式

FluxBB bbcode 测试

Bootloader网络启动方式示意图

如图1所示,里面主机和目标板,他们中间通过网络来连接,首先目标板的DHCP/BIOS通过BOOTP服务来为Bootloader分配IP地址,配置网络参数,这样才能支持网络传输功能。我们使用的u-boot可以直接设置网络参数,因此这里就不用使用DHCP的方式动态分配IP了。接下来目标板的Bootloader通过TFTP服务将内核映像下载到目标板上,然后通过网络文件系统来建立主机与目标板之间的文件通信过程,之后的系统更新通常也是使用Boot Loader的这种工作模式。工作于这种模式下的Boot Loader通常都会向它的终端用户提供一个简单的命令行接口。

2、磁盘启动方式

这种方式主要是用在台式机和服务器上的,这些计算机都使用BIOS引导,并且使用磁盘作为存储介质,这里面两个重要的用来启动linux的有LILO和GRUB,这里就不再具体说明了。

3、Flash启动方式

这是我们最常用的方式。Flash有NOR Flash和NAND Flash两种。NOR Flash可以支持随机访问,所以代码可以直接在Flash上执行,Bootloader一般是存储在Flash芯片上的。另外Flash上还存储着参数、内核映像和文件系统。这种启动方式与网络启动方式之间的不同之处就在于,在网络启动方式中,内核映像和文件系统首先是放在主机上的,然后经过网络传输下载进目标板的,而这种启动方式中内核映像和文件系统则直接是放在Flash中的,这两点在我们u-boot的使用过程中都用到了。

U-boot的定义

U-boot,全称Universal Boot Loader,是由DENX小组的开发的遵循GPL条款的开放源码项目,它的主要功能是完成硬件设备初始化、操作系统代码搬运,并提供一个控制台及一个指令集在操作系统运行前操控硬件设备。U-boot之所以这么通用,原因是他具有很多特点:开放源代码、支持多种嵌入式操作系统内核、支持多种处理器系列、较高的稳定性、高度灵活的功能设置、丰富的设备驱动源码以及较为丰富的开发调试文档与强大的网络技术支持。另外u-boot对操作系统和产品研发提供了灵活丰富的支持,主要表现在:可以引导压缩或非压缩系统内核,可以灵活设置/传递多个关键参数给操作系统,适合系统在不同开发阶段的调试要求与产品发布,支持多种文件系统,支持多种目标板环境参数存储介质,采用CRC32校验,可校验内核及镜像文件是否完好,提供多种控制台接口,使用户可以在不需要ICE的情况下通过串口/以太网/USB等接口下载数据并烧录到存储设备中去(这个功能在实际的产品中是很实用的,尤其是在软件现场升级的时候),以及提供丰富的设备驱动等。

u-boot源代码的目录结构

>1、board中存放于开发板相关的配置文件,每一个开发板都以子文件夹的形式出现。
>2、Commom文件夹实现u-boot行下支持的命令,每一个命令对应一个文件。
>3、cpu中存放特定cpu架构相关的目录,每一款cpu架构都对应了一个子目录。
>4、Doc是文档目录,有u-boot非常完善的文档。
>5、Drivers中是u-boot支持的各种设备的驱动程序。
>6、Fs是支持的文件系统,其中最常用的是JFFS2文件系统。
>7、Include文件夹是u-boot使用的头文件,还有各种硬件平台支持的汇编文件,系统配置文件和文件系统支持的文件。
>8、Net是与网络协议相关的代码,bootp协议、TFTP协议、NFS文件系统得实现。
>9、Tooles是生成U-boot的工具。

对u-boot的目录有了一些了解后,分析启动代码的过程就方便多了,其中比较重要的目录就是/board、/cpu、/drivers和/include目录,如果想实现u-boot在一个平台上的移植,就要对这些目录进行深入的分析。

什么是《编译地址》?什么是《运行地址》?

(一)编译地址: 32位的处理器,它的每一条指令是4个字节,以4个字节存储顺序,进行顺序执行,CPU是顺序执行的,只要没发生什么跳转,它会顺序进行执行行, 编译器会对每一条指令分配一个编译地址,这是编译器分配的,在编译过程中分配的地址,我们称之为编译地址。
(二)运行地址:是指程序指令真正运行的地址,是由用户指定,用户将运行地址烧录到哪里,哪里就是运行的地址。

比如有一个指令的编译地址是0x5,实际运行的地址是0x200,如果用户将指令烧到0x200上,那么这条指令的运行地址就是0x200,当编译地址和运行地址不同的时候会出现什么结果?结果是不能跳转,编译后会产生跳转地址,如果实际地址和编译后产生的地址不相等,那么就不能跳转。

C语言编译地址:都希望把编译地址和实际运行地址放在一起的,但是汇编代码因为不需要做C语言到汇编的转换,可以认为的去写地址,所以直接写的就是他的运行地址,这就是为什么任何bootloader刚开始会有一段汇编代码,因为起始代码编译地址和实际地址不相等,这段代码和汇编无关,跳转用的运行地址。                                                   

编译地址和运行地址如何来算呢?

1.假如有两个编译地址a=0x10,b=0x7,b的运行地址是0x300,那么a的运行地址就是b的运行地址加上两者编译地址的差值,a-b=0x10-0x7=0x9, a的运行地址就是0x300+0x9=0x309。

2.假设uboot上两条指令的编译地址为a=0x33000007和b=0x33000001,这两条指令都落在bank6上,现在要计算出他们对应的运行地址,要找出运行地址的始地址,这个是由用户烧录进去的,假设运行地址的首地址是0x0,则a的运行地址为0x7,b为0x1,就是这样算出来的。

为什么要分配编译地址?这样做有什么好处,有什么作用?

比如在函数a中定义了函数b,当执行到函数b时要进行指令跳转,要跳转到b函数所对应的起始地址上去,编译时,编译器给每条指令都分配了编译地址,如果编译器已经给分配了地址就可以直接进行跳转,查找b函数跳转指令所对应的表,进行直接跳转,因为有个编译地址和指令对应的一个表,如果没有分配,编译器就查找不到这个跳转地址,要进行计算,非常麻烦。

什么是《相对地址》?

以NOR Flash为例,NOR Falsh是映射到bank0上面,SDRAM是映射到bank6上面,uboot和内核最终是在SDRAM上面运行,最开始我们是从Nor Flash的零地址开始往后烧录,uboot中至少有一段代码编译地址和运行地址是不一样的,编译uboot或内核时,都会将编译地址放入到SDRAM中,他们最终都会在SDRAM中执行,刚开始uboot在Nor Flash中运行,运行地址是一个低端地址,是bank0中的一个地址,但编译地址是bank6中的地址,这样就会导致绝对跳转指令执行的失败,所以就引出了相对地址的概念。

那么什么是相对地址呢?

至少在bank0中uboot这段代码要知道不能用b+编译地址这样的方法去跳转指令,因为这段代码的编译地址和运行地址不一样,那如何去做呢?要去计算这个指令运行的真实地址,计算出来后再做跳转,应该是b+运行地址,不能出现b+编译地址,而是b+运行地址,而运行地址是算出来的。

   _TEXT_BASE:
  .word TEXT_BASE //0x33F80000,  // 在board/config.mk中

这段话表示,用户告诉编译器编译地址的起始地址

uboot 工作过程

大多数 Boot Loader 都包含两种不同的操作模式:"启动加载"模式和"下载"模式,这种区别仅对于开发人员才有意义。

但从最终用户的角度看,Boot Loader 的作用就是:用来加载操作系统,而并不存在所谓的启动加载模式与下载工作模式的区别。

(一)启动加载(Boot loading)模式:这种模式也称为"自主"(Autonomous)模式。
也即 Boot Loader 从目标机上的某个固态存储设备上将操作系统加载到 RAM 中运行,整个过程并没有用户的介入。这种模式是 Boot Loader 的正常工作模式,因此在嵌入式产品发布的时侯,Boot Loader 显然必须工作在这种模式下。


(二)下载(Downloading)模式:
在这种模式下,目标机上的 Boot Loader 将通过串口连接或网络连接等通信手段从主机(Host)下载文件,比如:下载内核映像和根文件系统映像等。从主机下载的文件通常首先被 Boot Loader保存到目标机的RAM 中,然后再被 BootLoader写到目标机上的FLASH类固态存储设备中。Boot Loader 的这种模式通常在第一次安装内核与根文件系统时被使用;此外,以后的系统更新也会使用 Boot Loader 的这种工作模式。工作于这种模式下的 Boot Loader 通常都会向它的终端用户提供一个简单的命令行接口。这种工作模式通常在第一次安装内核与跟文件系统时使用。或者在系统更新时使用。进行嵌入式系统调试时一般也让bootloader工作在这一模式下。

U­Boot 这样功能强大的 Boot Loader 同时支持这两种工作模式,而且允许用户在这两种工作模式之间进行切换。

大多数 bootloader 都分为阶段 1(stage1)和阶段 2(stage2)两大部分,u­boot 也不例外。依赖于 CPU 体系结构的代码(如 CPU 初始化代码等)通常都放在阶段 1 中且通常用汇编语言实现,而阶段 2 则通常用 C 语言来实现,这样可以实现复杂的功能,而且有更好的可读性和移植性。

第一、大概总结性得的分析

系统启动的入口点。既然我们现在要分析u-boot的启动过程,就必须先找到u-boot最先实现的是哪些代码,最先完成的是哪些任务。

另一方面一个可执行的image必须有一个入口点,并且只能有一个全局入口点,所以要通知编译器这个入口在哪里。由此我们可以找到程序的入口点是在/board/lpc2210/u-boot.lds中指定的,其中ENTRY(_start)说明程序从_start开始运行,而他指向的是cpu/arm7tdmi/start.o文件。

因为我们用的是ARM7TDMI的cpu架构,在复位后从地址0x00000000取它的第一条指令,所以我们将Flash映射到这个地址上,

这样在系统加电后,cpu将首先执行u-boot程序。u-boot的启动过程是多阶段实现的,分了两个阶段。

依赖于cpu体系结构的代码(如设备初始化代码等)通常都放在stage1中,而且通常都是用汇编语言来实现,以达到短小精悍的目的。

而stage2则通常是用C语言来实现的,这样可以实现复杂的功能,而且代码具有更好的可读性和可移植性。

下面我们先详细分析下stage1中的代码,如图2所示:

FluxBB bbcode 测试

代码真正开始是在_start,设置异常向量表,这样在cpu发生异常时就跳转到/cpu/arm7tdmi/interrupts中去执行相应得中断代码。

在interrupts文件中大部分的异常代码都没有实现具体的功能,只是打印一些异常消息,其中关键的是reset中断代码,跳到reset入口地址。

reset复位入口之前有一些段的声明。

>1.在reset中,首先是将cpu设置为svc32模式下,并屏蔽所有irq和fiq。
>2.在u-boot中除了定时器使用了中断外,其他的基本上都不需要使用中断,比如串口通信和网络等通信等,在u-boot中只要完成一些简单的通信就可以了,所以在这里屏蔽掉了所有的中断响应。
>3.初始化外部总线。这部分首先设置了I/O口功能,包括串口、网络接口等的设置,其他I/O口都设置为GPIO。然后设置BCFG0~BCFG3,即外部总线控制器。这里bank0对应Flash,设置为16位宽度,总线速度设为最慢,以实现稳定的操作;Bank1对应DRAM,设置和Flash相同;Bank2对应RTL8019。
>4.接下来是cpu关键设置,包括系统重映射(告诉处理器在系统发生中断的时候到外部存储器中去读取中断向量表)和系统频率。
>5.lowlevel_init,设定RAM的时序,并将中断控制器清零。这些部分和特定的平台有关,但大致的流程都是一样的。

下面就是代码的搬移阶段了。为了获得更快的执行速度,通常把stage2加载到RAM空间中来执行,因此必须为加载Boot Loader的stage2准备好一段可用的RAM空间范围。空间大小最好是memory page大小(通常是4KB)的倍数一般而言,1M的RAM空间已经足够了。

flash中存储的u-boot可执行文件中,代码段、数据段以及BSS段都是首尾相连存储的,所以在计算搬移大小的时候就是利用了用BSS段的首地址减去代码的首地址,这样算出来的就是实际使用的空间。

程序用一个循环将代码搬移到0x81180000,即RAM底端1M空间用来存储代码。

然后程序继续将中断向量表搬到RAM的顶端。由于stage2通常是C语言执行代码,所以还要建立堆栈去。

在堆栈区之前还要将malloc分配的空间以及全局数据所需的空间空下来,他们的大小是由宏定义给出的,可以在相应位置修改。

基本内存分布图:

FluxBB bbcode 测试

下来是u-boot启动的第二个阶段,是用c代码写的,这部分是一些相对变化不大的部分,我们针对不同的板子改变它调用的一些初始化函数,并且通过设置一些宏定义来改变初始化的流程,所以这些代码在移植的过程中并不需要修改,也是错误相对较少出现的文件。在文件的开始先是定义了一个函数指针数组,通过这个数组,程序通过一个循环来按顺序进行常规的初始化,并在其后通过一些宏定义来初始化一些特定的设备。在最后程序进入一个循环,main_loop。这个循环接收用户输入的命令,以设置参数或者进行启动引导。

本篇文章将分析重点放在了前面的start.s上,是因为这部分无论在移植还是在调试过程中都是最容易出问题的地方,要解决问题就需要程序员对代码进行修改,所以在这里简单介绍了一下start.s的基本流程,希望能对大家有所帮助

第二、代码分析

u­boot 的 stage1 代码通常放在 start.s 文件中,它用汇编语言写成

由于一个可执行的 Image 必须有一个入口点,并且只能有一个全局入口,通常这个入口放在 ROM(Flash)的 0x0地址,因此,必须通知编译器以使其知道这个入口,该工作可通过修改连接器脚本来完成。

1. board/crane2410/u­boot.lds:  ENTRY(_start)   ==> cpu/arm920t/start.S: .globl _start
2. uboot 代码区(TEXT_BASE = 0x33F80000)定义在 board/crane2410/config.mk

U-Boot启动内核的过程可以分为两个阶段,两个阶段的功能如下:

(1)第一阶段的功能
>Ø  硬件设备初始化
>Ø  加载U-Boot第二阶段代码到RAM空间
>Ø  设置好栈
>Ø  跳转到第二阶段代码入口

(2)第二阶段的功能
>Ø  初始化本阶段使用的硬件设备
>Ø  检测系统内存映射
>Ø  将内核从Flash读取到RAM中
>Ø  为内核设置启动参数
>Ø  调用内核

Uboot启动第一阶段代码分析

第一阶段对应的文件是cpu/arm920t/start.S和board/samsung/mini2440/lowlevel_init.S。

U-Boot启动第一阶段流程如下:

FluxBB bbcode 测试

详细分析

FluxBB bbcode 测试

根据cpu/arm920t/u-boot.lds中指定的连接方式:

看一下uboot.lds文件,在board/smdk2410目录下面,uboot.lds是告诉编译器这些段改怎么划分,GUN编译过的段,最基本的三个段是RO,RW,ZI,RO表示只读,对应于具体的指代码段,RW是数据段,ZI是归零段,就是全局变量的那段,Uboot代码这么多,如何保证start.s会第一个执行,编译在最开始呢?就是通过uboot.lds链接文件进行


OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
/*OUTPUT_FORMAT("elf32-arm", "elf32-arm", "elf32-arm")*/
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
. = 0x00000000; //起始地址
 
. = ALIGN(4); //4字节对齐
.text : //test指代码段,上面3行标识是不占用任何空间的
{
cpu/arm920t/start.o (.text) //这里把start.o放在第一位就表示把start.s编
译时放到最开始,这就是为什么把uboot烧到起始地址上它肯定运行的是start.s
*(.text)
}
 
. = ALIGN(4); //前面的 “.” 代表当前值,是计算一个当前的值,是计算上
面占用的整个空间,再加一个单元就表示它现在的位置
.rodata : { *(.rodata) }
 
. = ALIGN(4);
.data : { *(.data) }
 
. = ALIGN(4);
.got : { *(.got) }
 
. = .;
__u_boot_cmd_start = .;
.u_boot_cmd : { *(.u_boot_cmd) }
__u_boot_cmd_end = .;
 
. = ALIGN(4);
__bss_start = .; //bss表示归零段
.bss : { *(.bss) }
_end = .;
}

第一个链接的是cpu/arm920t/start.o,因此u-boot.bin的入口代码在cpu/arm920t/start.o中,其源代码在cpu/arm920t/start.S中。下面我们来分析cpu/arm920t/start.S的执行。

1.硬件设备初始化

(1)设置异常向量

下面代码是系统启动后U-boot上电后运行的第一段代码,它是什么意思?

u-boot对应的第一阶段代码放在cpu/arm920t/start.S文件中,入口代码如下:


globl _startglobal   /*声明一个符号可被其它文件引用,相当于声明了一个全局变量,.globl与.global相同*/
_start: b start_code /* 复位 */ //b是不带返回的跳转(bl是带返回的跳转),意思是无条件直接跳转到start_code标号出执行程序
 
ldr pc, _undefined_instruction /*未定义指令向量 l---dr相当于mov操作*/
ldr pc, _software_interrupt /* 软件中断向量 */
ldr pc, _prefetch_abort /* 预取指令异常向量 */
ldr pc, _data_abort /* 数据操作异常向量 */
ldr pc, _not_used /* 未使用 */
ldr pc, _irq /* irq中断向量 */
ldr pc, _fiq /* fiq中断向量 */
 
/* 中断向量表入口地址 */
 
_undefined_instruction: .word undefined_instruction /*就是在当前地址,_undefined_instruction 处存放 undefined_instruction*/
_software_interrupt: .word software_interrupt
_prefetch_abort: .word prefetch_abort
_data_abort: .word data_abort
_not_used: .word not_used
_irq: .word irq
_fiq: .word fiq
 
 
// word伪操作用于分配一段字内存单元(分配的单元都是字对齐的),并用伪操作中的expr初始化
.balignl 16,0xdeadbeef

它们是系统定义的异常,一上电程序跳转到start_code异常处执行相应的汇编指令,下面定义出的都是不同的异常,比如软件发生软中断时,CPU就会去执行软中断的指令,这些异常中断在CUP中地址是从0开始,每个异常占4个字节

ldr pc, _undefined_instruction表示把_undefined_instruction存放的数值存放到pc指针上

_undefined_instruction: .word undefined_instruction表示未定义的这个异常是由.word来定义的,它表示定义一个字,一个32位的数

. word后面的数:表示把该标识的编译地址写入当前地址,标识是不占用任何指令的。把标识存放的数值copy到指针pc上面,那么标识上存放的值是什么?

是由.word undefined_instruction来指定的,pc就代表你运行代码的地址,实现了CPU要做一次跳转时的工作。

以上代码设置了ARM异常向量表,各个异常向量介绍如下:

表 2.1 ARM异常向量表

FluxBB bbcode 测试

在cpu/arm920t/start.S中还有这些异常对应的异常处理程序。当一个异常产生时,CPU根据异常号在异常向量表中找到对应的异常向量,然后执行异常向量处的跳转指令,CPU就跳转到对应的异常处理程序执行。

其中复位异常向量的指令“b start_code”决定了U-Boot启动后将自动跳转到标号“start_code”处执行。

(2)CPU进入SVC模式


start_code:
 
/*
* set the cpu to SVC32 mode
*/
 
 mrs r0, cpsr
 
 bic  r0, r0, #0x1f   /*工作模式位清零 */
 
 orr   r0, r0, #0xd3  /*工作模式位设置为“10011”(管理模式),并将中断禁止位和快中断禁止位置1 */
 
 msr cpsr, r0

以上代码将CPU的工作模式位设置为管理模式,即设置相应的CPSR程序状态字,并将中断禁止位和快中断禁止位置一,从而屏蔽了IRQ和FIQ中断。

操作系统先注册一个总的中断,然后去查是由哪个中断源产生的中断,再去查用户注册的中断表,查出来后就去执行用户定义的用户中断处理函数。

(3)设置控制寄存器地址


#if defined(CONFIG_S3C2400)       /* 关闭看门狗 */
 
#define pWTCON 0x15300000         /* 看门狗寄存器 */
 
#define INTMSK  0x14400008        /* 中断屏蔽寄存器 */
 
#define CLKDIVN      0x14800014   /* 时钟分频寄存器 */
 
#else      /* s3c2410与s3c2440下面4个寄存器地址相同 */
 
#define pWTCON 0x53000000         /* WATCHDOG控制寄存器地址 */
 
#define INTMSK  0x4A000008        /* INTMSK寄存器地址  */
 
#define INTSUBMSK 0x4A00001C      /* INTSUBMSK寄存器地址 次级中断屏蔽寄存器*/
 
#define CLKDIVN  0x4C000014       /* CLKDIVN寄存器地址 ;时钟分频寄存器*/
 
#endif

对与s3c2440开发板,以上代码完成了WATCHDOG,INTMSK,INTSUBMSK,CLKDIVN四个寄存器的地址的设置。各个寄存器地址参见参考文献

(4)关闭看门狗


ldr   r0, =pWTCON   /* 将pwtcon寄存器地址赋给R0 */
 
mov   r1, #0x0      /* r1的内容为0 */
 
str   r1, [r0]      /* 看门狗控制器的最低位为0时,看门狗不输出复位信号 */

以上代码向看门狗控制寄存器写入0,关闭看门狗。否则在U-Boot启动过程中,CPU将不断重启。

为什么要关看门狗?

就是防止,不同得两个以上得CPU,进行喂狗的时间间隔问题:说白了,就是你运行的代码如果超出喂狗时间,而你不关狗,就会导致,你代码还没运行完又得去喂狗,就这样反复得重启CPU,那你代码永远也运行不完,所以,得先关看门狗得原因,就是这样。

关狗---详细的原因:

关闭看门狗,关闭中断,所谓的喂狗是每隔一段时间给某个寄存器置位而已,在实际中会专门启动一个线程或进程会专门喂狗,当上层软件出现故障时就会停止喂狗,停止喂狗之后,cpu会自动复位,一般都在外部专门有一个看门狗,做一个外部的电路,不在cpu内部使用看门狗,cpu内部的看门狗是复位的cpu,当开发板很复杂时,有好几个cpu时,就不能完全让板子复位,但我们通常都让整个板子复位。看门狗每隔短时间就会喂狗,问题是在两次喂狗之间的时间间隔内,运行的代码的时间是否够用,两次喂狗之间的代码是否在两次喂狗的时间延迟之内,如果在延迟之外的话,代码还没运行完就又进行喂狗,代码永远也运行不完

(5)屏蔽中断


/*
 * mask all IRQs by setting all bits in the INTMR - default
 */
 
 mov       r1, #0xffffffff    /*屏蔽所有中断, 某位被置1则对应的中断被屏蔽 */ /*寄存器中的值*/
 
 ldr   r0, =INTMSK            /*将管理中断的寄存器地址赋给ro*/
 
 str   r1, [r0]               /*将全r1的值赋给ro地址中的内容*/

INTMSK是主中断屏蔽寄存器,每一位对应SRCPND(中断源引脚寄存器)中的一位,表明SRCPND相应位代表的中断请求是否被CPU所处理。

INTMSK寄存器是一个32位的寄存器,每位对应一个中断,向其中写入0xffffffff就将INTMSK寄存器全部位置一,从而屏蔽对应的中断。


# if defined(CONFIG_S3C2440)
 
  ldr  r1, =0x7fff                  
 
  ldr  r0, =INTSUBMSK  
 
  str  r1, [r0]            
 
 # endif

INTSUBMSK每一位对应SUBSRCPND中的一位,表明SUBSRCPND相应位代表的中断请求是否被CPU所处理。

INTSUBMSK寄存器是一个32位的寄存器,但是只使用了低15位。向其中写入0x7fff就是将INTSUBMSK寄存器全部有效位(低15位)置一,从而屏蔽对应的中断。

屏蔽所有中断,为什么要关中断?

中断处理中ldr pc是将代码的编译地址放在了指针上,而这段时间还没有搬移代码,所以编译地址上面没有这个代码,如果进行跳转就会跳转到空指针上面

(6)设置MPLLCON,UPLLCON, CLKDIVN


# if defined(CONFIG_S3C2440) 
 
#define MPLLCON   0x4C000004
 
#define UPLLCON   0x4C000008  
 
  ldr  r0, =CLKDIVN   ;设置时钟
 
  mov  r1, #5
 
  str  r1, [r0]
 
 
  ldr  r0, =MPLLCON
 
  ldr  r1, =0x7F021 
 
  str  r1, [r0]
 
 
 
  ldr  r0, =UPLLCON 
 
  ldr  r1, =0x38022
 
  str  r1, [r0]
 
# else
 
   /* FCLK:HCLK:PCLK = 1:2:4 */
 
   /* default FCLK is 120 MHz ! */
 
   ldr   r0, =CLKDIVN
 
   mov       r1, #3
 
   str   r1, [r0]
 
#endif

CPU上电几毫秒后,晶振输出稳定,FCLK=Fin(晶振频率),CPU开始执行指令。但实际上,FCLK可以高于Fin,为了提高系统时钟,需要用软件来启用PLL。这就需要设置CLKDIVN,MPLLCON,UPLLCON这3个寄存器。

CLKDIVN寄存器用于设置FCLK,HCLK,PCLK三者间的比例,可以根据表2.2来设置。

表 2.2 S3C2440 的CLKDIVN寄存器格式

FluxBB bbcode 测试

设置CLKDIVN为5,就将HDIVN设置为二进制的10,由于CAMDIVN[9]没有被改变过,取默认值0,因此HCLK = FCLK/4。PDIVN被设置为1,因此PCLK= HCLK/2。因此分频比FCLK:HCLK:PCLK = 1:4:8 。

MPLLCON寄存器用于设置FCLK与Fin的倍数。MPLLCON的位[19:12]称为MDIV,位[9:4]称为PDIV,位[1:0]称为SDIV。

对于S3C2440,FCLK与Fin的关系如下面公式:

       MPLL(FCLK) = (2×m×Fin)/(p× )

       其中: m=MDIC+8,p=PDIV+2,s=SDIV

MPLLCON与UPLLCON的值可以根据“PLL VALUE SELECTION TABLE”设置。部分摘录如下:

表 2.3 推荐PLL值

FluxBB bbcode 测试

当mini2440系统主频设置为405MHZ,USB时钟频率设置为48MHZ时,系统可以稳定运行,因此设置MPLLCON与UPLLCON为:

       MPLLCON=(0x7f<<12) | (0x02<<4) | (0x01) = 0x7f021

       UPLLCON=(0x38<<12) | (0x02<<4) | (0x02) = 0x38022

默认频率为      FCLK:HCLK:PCLK = 1:2:4,默认 FCLK 的值为 120 MHz,该值为 S3C2410 手册的推荐值。

设置时钟分频,为什么要设置时钟?

起始可以不设,系统能不能跑起来和频率没有任何关系,频率的设置是要让外围的设备能承受所设置的频率,如果频率过高则会导致cpu操作外围设备失败

说白了:设置频率,就为了CPU能去操作外围设备

(7)关闭MMU,cache(也就是做bank的设置)


#ifndef CONFIG_SKIP_LOWLEVEL_INIT
 
bl   cpu_init_crit  /* ;跳转并把转移后面紧接的一条指令地址保存到链接寄存器LR(R14)中,以此来完成子程序的调用*/
 
#endif

cpu_init_crit 这段代码在U-Boot正常启动时才需要执行,若将U-Boot从RAM中启动则应该注释掉这段代码。

下面分析一下cpu_init_crit到底做了什么:


#ifndef CONFIG_SKIP_LOWLEVEL_INIT
 
cpu_init_crit:
 
 /*
 
 * 使数据cache与指令cache无效 */
 
 */
 
 mov r0, #0
 
 mcr p15, 0, r0, c7, c7, 0 /* 向c7写入0将使ICache与DCache无效*/
 
 mcr p15, 0, r0, c8, c7, 0 /* 向c8写入0将使TLB失效 ,协处理器*/
 
 
 
 /*
 
 * disable MMU stuff and caches
 
 */
 
 mrc p15, 0, r0, c1, c0, 0 /* 读出控制寄存器到r0中 */
 
 bic r0, r0, #0x00002300 @ clear bits 13, 9:8 (--V- --RS)
 
 bic r0, r0, #0x00000087 @ clear bits 7, 2:0 (B--- -CAM)
 
 orr r0, r0, #0x00000002 @ set bit 2 (A) Align
 
 orr r0, r0, #0x00001000 @ set bit 12 (I) I-Cache
 
 mcr p15, 0, r0, c1, c0, 0 /* 保存r0到控制寄存器 */
 
 /*
 
 * before relocating, we have to setup RAM timing
 
 * because memory timing is board-dependend, you will
 
 * find a lowlevel_init.S in your board directory.
 
 */
 
 mov ip, lr
 
 bl lowlevel_init
 
 mov lr, ip
 
 mov pc, lr
 
#endif /* CONFIG_SKIP_LOWLEVEL_INIT */

代码中的c0,c1,c7,c8都是ARM920T的协处理器CP15的寄存器。其中c7是cache控制寄存器,c8是TLB控制寄存器。325~327行代码将0写入c7、c8,使Cache,TLB内容无效。

disable MMU stuff and caches 代码关闭了MMU。这是通过修改CP15的c1寄存器来实现的,先看CP15的c1寄存器的格式(仅列出代码中用到的位):

表 2.3 CP15的c1寄存器格式(部分)

FluxBB bbcode 测试

各个位的意义如下:

>V :  表示异常向量表所在的位置,0:异常向量在0x00000000;1:异常向量在 0xFFFF0000
>I :  0 :关闭ICaches;1 :开启ICaches
>R、S : 用来与页表中的描述符一起确定内存的访问权限
>B :  0 :CPU为小字节序;1 : CPU为大字节序
>C :  0:关闭DCaches;1:开启DCaches
>A :  0:数据访问时不进行地址对齐检查;1:数据访问时进行地址对齐检查
>M :  0:关闭MMU;1:开启MMU

代码将c1的 M位置零,关闭了MMU。

为什么要关闭catch和MMU呢?catch和MMU是做什么用的?

MMU是Memory Management Unit的缩写,中文名是内存管理单元,它是中央处理器(CPU)中用来管理虚拟存储器、物理存储器的控制线路,同时也负责虚拟地址映射为物理地址,以及提供硬件机制的内存访问授权     

概述:

一,关catch

catch和MMU是通过CP15管理的,刚上电的时候,CPU还不能管理它们,上电的时候MMU必须关闭,指令catch可关闭,可不关闭,但数据catch一定要关闭。否则可能导致刚开始的代码里面,去取数据的时候,从catch里面取,而这时候RAM中数据还没有catch过来,导致数据预取异常

二:关MMU

因为MMU是;把虚拟地址转化为物理地址得作用,而目的是设置控制寄存器,而控制寄存器本来就是实地址(物理地址),再使能MMU,不就是多此一举了吗?

详细分析

Catch是cpu内部的一个2级缓存,它的作用是将常用的数据和指令放在cpu内部,MMU是用来把虚实地址转换为物理地址用的

我们的目的:是设置控制的寄存器,寄存器都是实地址(物理地址),如果既要开启MMU又要做虚实地址转换的话,中间还多一步,多此一举了嘛?

先要把实地址转换成虚地址,然后再做设置,但对uboot而言就是起到一个简单的初始化的作用和引导操作系统,如果开启MMU的话,很麻烦,也没必要,所以关闭MMU.

说到catch就必须提到一个关键字 Volatile,以后在设置寄存器时会经常遇到,他的本质:是告诉编译器不要对我的代码进行优化,作用是让编写者感觉不到变量的变化情况(也就是说,让它执行速度加快吧)

优化的过程:是将常用的代码取出来放到catch中,它没有从实际的物理地址去取,它直接从cpu的缓存中去取,但常用的代码就是为了感觉一些常用变量的变化

优化原因:如果正在取数据的时候发生跳变,那么就感觉不到变量的变化了,所以在这种情况下要用Volatile关键字告诉编译器不要做优化,每次从实际的物理地址中去取指令,这就是为什么关闭catch关闭MMU。

但在C语言中是不会关闭catch和MMU的,会打开,如果编写者要感觉外界变化,或变化太快,从catch中取数据会有误差,就加一个关键字Volatile。

(8)初始化RAM控制寄存器

bl lowlevel_init下来初始化各个bank,把各个bank设置必须搞清楚,对以后移植复杂的uboot有很大帮助,设置完毕后拷贝uboot代码到4k空间,拷贝完毕后执行内存中的uboot代码

其中的lowlevel_init就完成了内存初始化的工作,由于内存初始化是依赖于开发板的,因此lowlevel_init的代码一般放在board下面相应的目录中。对于mini2440,lowlevel_init在board/samsung/mini2440/lowlevel_init.S中定义如下:


#define BWSCON 0x48000000 /* 13个存储控制器的开始地址 */
 
 _TEXT_BASE:
 
  .word TEXT_BASE0x33F80000, board/config.mk中这段话表示,用户告诉编译器编译地址的起始地址
 
 
 
 .globl lowlevel_init
 
 lowlevel_init:
 
  /* memory control configuration */
 
  /* make r0 relative the current location so that it */
 
  /* reads SMRDATA out of FLASH rather than memory ! */
 
  ldr r0, =SMRDATA
 
  ldr r1, _TEXT_BASE
 
  sub r0, r0, r1 /* SMRDATA减 _TEXT_BASE就是13个寄存器的偏移地址 */
 
  ldr r1, =BWSCON /* Bus Width Status Controller */
 
  add r2, r0, #13*4
 
 0:
 
  ldr r3, [r0], #4 /*将13个寄存器的值逐一赋值给对应的寄存器*/
 
  str r3, [r1], #4
 
  cmp r2, r0
 
  bne 0b
 
  /* everything is fine now */
 
  mov pc, lr
 
  .ltorg
 
 /* the literal pools origin */
 
 
 SMRDATA: /* 下面是13个寄存器的值 */
 
  .word ...
 
  .word ...
 
...
 
 
 lowlevel_init初始化了13个寄存器来实现RAM时钟的初始化。lowlevel_init函数对于U-Boot从NAND Flash或NOR Flash启动的情况都是有效的。
 
 U-Boot.lds链接脚本有如下代码:
 
 .text :
 {
 
   cpu/arm920t/start.o (.text)
   board/samsung/mini2440/lowlevel_init.o (.text)
   board/samsung/mini2440/nand_read.o (.text)
 
   ...
 }

board/samsung/mini2440/lowlevel_init.o将被链接到cpu/arm920t/start.o后面,因此board/samsung/mini2440/lowlevel_init.o也在U-Boot的前4KB的代码中。

U-Boot在NAND Flash启动时,lowlevel_init.o将自动被读取到CPU内部4KB的内部RAM中。因此/* reads SMRDATA out of FLASH rather than memory ! */ 开始行的代码将从CPU内部RAM中复制寄存器的值到相应的寄存器中。

对于U-Boot在NOR Flash启动的情况,由于U-Boot连接时确定的地址是U-Boot在内存中的地址,而此时U-Boot还在NOR Flash中,因此还需要在NOR Flash中读取数据到RAM中。

由于NOR Flash的开始地址是0,而U-Boot的加载到内存的起始地址是TEXT_BASE,SMRDATA标号在Flash的地址就是SMRDATA-TEXT_BASE。

综上所述,lowlevel_init的作用就是将SMRDATA开始的13个值复制给开始地址[BWSCON]的13个寄存器,从而完成了存储控制器的设置。

问题一:如果换一块开发板有可能改哪些东西?

首先,cpu的运行模式,如果需要对cpu进行设置那就设置,管看门狗,关中断不用改,时钟有可能要改,如果能正常使用则不用改,关闭catch和MMU不用改,设置bank有可能要改。最后一步拷贝时看地址会不会变,如果变化也要改,执行内存中代码,地址有可能要改。


问题二:Nor Flash和Nand Flash本质区别:

就在于是否进行代码拷贝,也就是下面代码所表述:无论是Nor Flash还是Nand Flash,核心思想就是将uboot代码搬运到内存中去运行,但是没有拷贝bss后面这段代码,只拷贝bss前面的代码,bss代码是放置全局变量的。Bss段代码是为了清零,拷贝过去再清零重复操作

(9)复制U-Boot第二阶段代码到RAM

cpu/arm920t/start.S原来的代码是只支持从NOR Flash启动的,经过修改现在U-Boot在NOR Flash和NAND Flash上都能启动了,实现的思路是这样的:


 bl bBootFrmNORFlash /* 判断U-Boot是在NAND Flash还是NOR Flash启动 */
 
 cmp r0, #0 /* r0存放bBootFrmNORFlash函数返回值,若返回0表示NAND Flash启动,否则表示在NOR Flash启动 */
 
 beq nand_boot /* 跳转到NAND Flash启动代码 */
 
 
 /* NOR Flash启动的代码 */
 
 b stack_setup /* 跳过NAND Flash启动的代码 */
 
 
nand_boot:
 
/* NAND Flash启动的代码 */
 
 
stack_setup:
 
 /* 其他代码 */

其中bBootFrmNORFlash函数作用是判断U-Boot是在NAND Flash启动还是NOR Flash启动,若在NOR Flash启动则返回1,否则返回0。根据ATPCS规则,函数返回值会被存放在r0寄存器中,因此调用bBootFrmNORFlash函数后根据r0的值就可以判断U-Boot在NAND Flash启动还是NOR Flash启动。bBootFrmNORFlash函数在board/samsung/mini2440/nand_read.c中定义如下:


int bBootFrmNORFlash(void)
{
    volatile unsigned int *pdw = (volatile unsigned int *)0;
    unsigned int dwVal;
 
 
    dwVal = *pdw;         /* 先记录下原来的数据 */
    *pdw = 0x12345678;
 
    if (*pdw != 0x12345678) /* 写入失败,说明是在NOR Flash启动 */
    {
        return 1;     
    }
    else                   /* 写入成功,说明是在NAND Flash启动 */
    {
        *pdw = dwVal;      /* 恢复原来的数据 */
        return 0;
    }
}

无论是从NOR Flash还是从NAND Flash启动,地址0处为U-Boot的第一条指令“ b    start_code”。

对于从NAND Flash启动的情况,其开始4KB的代码会被自动复制到CPU内部4K内存中,因此可以通过直接赋值的方法来修改。

对于从NOR Flash启动的情况,NOR Flash的开始地址即为0,必须通过一定的命令序列才能向NOR Flash中写数据,所以可以根据这点差别来分辨是从NAND Flash还是NOR Flash启动:向地址0写入一个数据,然后读出来,如果发现写入失败的就是NOR Flash,否则就是NAND Flash。

下面来分析NOR Flash启动部分代码:


 adr r0, _start /* r0 <- current position of code */
 
 ldr r1, _TEXT_BASE /* test if we run from flash or RAM */
 
/* 判断U-Boot是否是下载到RAM中运行,若是,则不用再复制到RAM中了,这种情况通常在调试U-Boot时才发生 */
 
 cmp  r0, r1 /*_start等于_TEXT_BASE说明是下载到RAM中运行 */
 
 beq stack_setup
 
/* 以下直到nand_boot标号前都是NOR Flash启动的代码 */
 
 ldr r2, _armboot_start /*flash中armboot_start的起始地址*/
 
 ldr r3, _bss_start /*uboot_bss的起始地址*/
 
 sub r2, r3, r2 /* r2 <- size of armbootuboot实际程序代码的大小 */
 
 add r2, r0, r2 /* r2 <- source end address */
 
/*搬运U-Boot自身到RAM中*/
 
copy_loop:
 
 ldmia r0!, {r3-r10} /* 从地址为[r0]的NOR Flash中读入8个字的数据 */
 
 stmia r1!, {r3-r10} /* 将r3至r10寄存器的数据复制给地址为[r1]的内存 */
 
 cmp r0, r2 /* until source end addreee [r2] */
 
 ble copy_loop
 
 b stack_setup /* 跳过NAND Flash启动的代码 */

下面再来分析NAND Flash启动部分代码:


nand_boot:
 
 mov r1, #NAND_CTL_BASE
 
 ldr r2, =( (7<<12)|(7<<8)|(7<<4)|(0<<0) )
 str r2, [r1, #oNFCONF] /* 设置NFCONF寄存器 */
 /* 设置NFCONT,初始化ECC编/解码器,禁止NAND Flash片选 */
 ldr r2, =( (1<<4)|(0<<1)|(1<<0) )
 str r2, [r1, #oNFCONT]
 ldr r2, =(0x6) /* 设置NFSTAT */
 str r2, [r1, #oNFSTAT]
 /* 复位命令,第一次使用NAND Flash前复位 */
 mov r2, #0xff
 strb r2, [r1, #oNFCMD]
 mov r3, #0
 /* 为调用C函数nand_read_ll准备堆栈 */
 ldr sp, DW_STACK_START
 mov fp, #0
 /* 下面先设置r0至r2,然后调用nand_read_ll函数将U-Boot读入RAM */
 ldr r0, =TEXT_BASE /* 目的地址:U-Boot在RAM的开始地址 */
 mov r1, #0x0  /* 源地址:U-Boot在NAND Flash中的开始地址 */
 mov r2, #0x30000  /* 复制的大小,必须比u-boot.bin文件大,并且必须是NAND Flash块大小的整数倍,这里设置为0x30000(192KB) */
 bl nand_read_ll  /* 跳转到nand_read_ll函数,开始复制U-Boot到RAM */
 tst r0, #0x0 /* 检查返回值是否正确 */
 beq stack_setup
 bad_nand_read:
 loop2: b loop2 //infinite loop
.align 2
 DW_STACK_START: .word STACK_BASE+STACK_SIZE-4

其中NAND_CTL_BASE,oNFCONF等在include/configs/mini2440.h中定义如下


#define NAND_CTL_BASE 0x4E000000 // NAND Flash控制寄存器基址
 
#define STACK_BASE 0x33F00000 //base address of stack
#define STACK_SIZE 0x8000 //size of stack
#define oNFCONF 0x00 /* NFCONF相对于NAND_CTL_BASE偏移地址 */
#define oNFCONT 0x04 /* NFCONT相对于NAND_CTL_BASE偏移地址*/
#define oNFADDR 0x0c /* NFADDR相对于NAND_CTL_BASE偏移地址*/
#define oNFDATA 0x10 /* NFDATA相对于NAND_CTL_BASE偏移地址*/
#define oNFCMD 0x08 /* NFCMD相对于NAND_CTL_BASE偏移地址*/
#define oNFSTAT 0x20 /* NFSTAT相对于NAND_CTL_BASE偏移地址*/
#define oNFECC 0x2c /* NFECC相对于NAND_CTL_BASE偏移地址*/

NAND Flash各个控制寄存器的设置在S3C2440的数据手册有详细说明,这里就不介绍了。

代码中nand_read_ll函数的作用是在NAND Flash中搬运U-Boot到RAM,该函数在board/samsung/mini2440/nand_read.c中定义。

NAND Flash根据page大小可分为2种: 512B/page和2048B/page的。这两种NAND Flash的读操作是不同的。因此就需要U-Boot识别到NAND Flash的类型,然后采用相应的读操作,也就是说nand_read_ll函数要能自动适应两种NAND Flash。

参考S3C2440的数据手册可以知道:根据NFCONF寄存器的Bit3(AdvFlash (Read only))和Bit2 (PageSize (Read only))可以判断NAND Flash的类型。Bit2、Bit3与NAND Flash的block类型的关系如下表所示:

表 2.4 NFCONF的Bit3、Bit2与NAND Flash的关系

FluxBB bbcode 测试

由于的NAND Flash只有512B/page和2048 B/page这两种,因此根据NFCONF寄存器的Bit3即可区分这两种NAND Flash了。

完整代码见board/samsung/mini2440/nand_read.c中的nand_read_ll函数,这里给出伪代码:


int nand_read_ll(unsigned char *buf, unsigned long start_addr, int size)
{
	//根据NFCONF寄存器的Bit3来区分2种NAND Flash
 
	if( NFCONF & 0x8 )  /* Bit是1,表示是2KB/page的NAND Flash */
	{
		
		读取2K block 的NAND Flash
		
	
	}
	else /* Bit是0,表示是512B/page的NAND Flash */
	{
	
		/
		读取512B block 的NAND Flash
		/
	
	}
 
	return 0;
}

(10)设置堆栈


stack_setup:
 
 ldr r0, _TEXT_BASE /* upper 128 KiB: relocated uboot */
 
 sub r0, r0, #CONFIG_SYS_MALLOC_LEN /* malloc area */
 
 sub r0, r0, #CONFIG_SYS_GBL_DATA_SIZE /* 跳过全局数据区 */
 
#ifdef CONFIG_USE_IRQ
 
 sub r0, r0, #(CONFIG_STACKSIZE_IRQ+CONFIG_STACKSIZE_FIQ)
 
#endif
 
 sub sp, r0, #12 /* leave 3 words for abort-stack */

只要将sp指针指向一段没有被使用的内存就完成栈的设置了。根据上面的代码可以知道U-Boot内存使用情况了,如下图所示:

FluxBB bbcode 测试

(11)清除BSS段


clear_bss:
 
 ldr r0, _bss_start /* BSS段开始地址,在u-boot.lds中指定*/
 
 ldr r1, _bss_end /* BSS段结束地址,在u-boot.lds中指定*/
 
 mov r2, #0x00000000
 
clbss_l:str r2, [r0] /* 将bss段清零*/
 
 add r0, r0, #4
 
 cmp  r0, r1
 
 ble clbss_l

初始值为0,无初始值的全局变量,静态变量将自动被放在BSS段。应该将这些变量的初始值赋为0,否则这些变量的初始值将是一个随机的值,若有些程序直接使用这些没有初始化的变量将引起未知的后果。

(12)跳转到第二阶段代码入口


ldr   pc, _start_armboot
 
_start_armboot:   .word  start_armboot  //跳转到第二阶段代码入口start_armboot处

UBOOT 启动第二阶段代码分析

start_armboot函数在lib_arm/board.c中定义,是U-Boot第二阶段代码的入口。U-Boot启动第二阶段流程如下:

FluxBB bbcode 测试

分析start_armboot函数前先来看看一些重要的数据结构:

(1)gd_t结构体

U-Boot使用了一个结构体gd_t来存储全局数据区的数据,这个结构体在include/asm-arm/global_data.h中定义如下:


typedef struct global_data {
 
 bd_t *bd;
 unsigned long flags;
 unsigned long baudrate;
 unsigned long have_console; /* serial_init() was called */
 unsigned long env_addr; /* Address of Environment struct */
 unsigned long env_valid; /* Checksum of Environment valid */
 unsigned long fb_base; /* base address of frame buffer */
 void **jt; /* jump table */
 
} gd_t;

U-Boot使用了一个存储在寄存器中的指针gd来记录全局数据区的地址:


#define DECLARE_GLOBAL_DATA_PTR     register volatile gd_t *gd asm ("r8")

DECLARE_GLOBAL_DATA_PTR定义一个gd_t全局数据结构的指针,这个指针存放在指定的寄存器r8中。这个声明也避免编译器把r8分配给其它的变量。任何想要访问全局数据区的代码,只要代码开头加入“DECLARE_GLOBAL_DATA_PTR”一行代码,然后就可以使用gd指针来访问全局数据区了。

根据U-Boot内存使用图中可以计算gd的值:


gd = TEXT_BASE - CONFIG_SYS_MALLOC_LEN - sizeof(gd_t)

(2)bd_t结构体

bd_t在include/asm-arm.u/u-boot.h中定义如下:


typedef struct bd_info {
 
	int bi_baudrate;  /* 串口通讯波特率 */
	unsigned long bi_ip_addr;  /* IP 地址*/
	struct environment_s  *bi_env; /* 环境变量开始地址 */
	ulong  bi_arch_number; /* 开发板的机器码 */
	ulong  bi_boot_params; /* 内核参数的开始地址 */
 
	struct /* RAM配置信息 */
	{
		ulong start;
		ulong size;
	}bi_dram[CONFIG_NR_DRAM_BANKS];
 
} bd_t;

U-Boot启动内核时要给内核传递参数,这时就要使用gd_t,bd_t结构体中的信息来设置标记列表。

第一阶段调用start_armboot指向C语言执行代码区,首先它要从内存上的重定位数据获得不完全配置的全局数据表格和板级信息表格,即获得gd_t和bd_t,

这两个类型变量记录了刚启动时的信息,并将要记录作为引导内核和文件系统的参数,如bootargs等等,并且将来还会在启动内核时,由uboot交由kernel时会有所用。

(3)init_sequence数组

U-Boot使用一个数组init_sequence来存储对于大多数开发板都要执行的初始化函数的函数指针。init_sequence数组中有较多的编译选项,去掉编译选项后init_sequence数组如下所示:


typedef int (init_fnc_t) (void);
 
init_fnc_t *init_sequence[] = {
 
 board_init,   /*开发板相关的配置--board/samsung/mini2440/mini2440.c */
 timer_init, /* 时钟初始化-- cpu/arm920t/s3c24x0/timer.c */
 env_init,  /*初始化环境变量--common/env_flash.c 或common/env_nand.c*/
 init_baudrate, /*初始化波特率-- lib_arm/board.c */
 serial_init, /* 串口初始化-- drivers/serial/serial_s3c24x0.c */
 console_init_f, /* 控制通讯台初始化阶段1-- common/console.c */
 display_banner, /*打印U-Boot版本、编译的时间-- gedit lib_arm/board.c */
 dram_init, /*配置可用的RAM-- board/samsung/mini2440/mini2440.c */
 display_dram_config, /* 显示RAM大小-- lib_arm/board.c */
 NULL,
 
};

其中的board_init函数在board/samsung/mini2440/mini2440.c中定义,该函数设置了MPLLCOM,UPLLCON,以及一些GPIO寄存器的值,还设置了U-Boot机器码和内核启动参数地址 :


/* MINI2440开发板的机器码 */
 
gd->bd->bi_arch_number = MACH_TYPE_MINI2440;
 
/* 内核启动参数地址 */
 
gd->bd->bi_boot_params = 0x30000100;  

其中的dram_init函数在board/samsung/mini2440/mini2440.c中定义如下:


int dram_init (void)
{
 
 /* 由于mini2440只有 */
 
 gd->bd->bi_dram[0].start = PHYS_SDRAM_1;
 gd->bd->bi_dram[0].size = PHYS_SDRAM_1_SIZE;
 
 return 0;
}

mini2440使用2片32MB的SDRAM组成了64MB的内存,接在存储控制器的BANK6,地址空间是0x30000000~0x34000000。

在include/configs/mini2440.h中 PHYS_SDRAM_1和PHYS_SDRAM_1_SIZE 分别被定义为0x30000000和0x04000000(64M)

分析完上述的数据结构,下面来分析start_armboot函数:


void start_armboot (void)
{
	init_fnc_t **init_fnc_ptr;
	char *s;
	
	… …
	
	/* 计算全局数据结构的地址gd */
	gd = (gd_t*)(_armboot_start - CONFIG_SYS_MALLOC_LEN - sizeof(gd_t));
	
	… …
	
	memset ((void*)gd, 0, sizeof (gd_t));
	gd->bd = (bd_t*)((char*)gd - sizeof(bd_t));
	memset (gd->bd, 0, sizeof (bd_t));
	gd->flags |= GD_FLG_RELOC;
	
	monitor_flash_len = _bss_start - _armboot_start;
	
	/* 逐个调用init_sequence数组中的初始化函数 */
	for (init_fnc_ptr = init_sequence; *init_fnc_ptr; ++init_fnc_ptr) {
	
		if ((*init_fnc_ptr)() != 0) {
		
			hang ();
		}
	}
 
 
	/* armboot_start 在cpu/arm920t/start.S 中被初始化为u-boot.lds连接脚本中的_start */
	mem_malloc_init (_armboot_start - CONFIG_SYS_MALLOC_LEN,CONFIG_SYS_MALLOC_LEN);
 
 
 
	/* NOR Flash初始化 */
	
	#ifndef CONFIG_SYS_NO_FLASH
		/* configure available FLASH banks */
		display_flash_config (flash_init ());
	#endif /* CONFIG_SYS_NO_FLASH */
 
	… …
	
	/* NAND Flash 初始化*/
	
	#if defined(CONFIG_CMD_NAND)
		puts ("NAND: ");
		nand_init(); /* go init the NAND */
	#endif
 
	… …
	
	/*配置环境变量,重新定位 */
	env_relocate ();
	
	… …
 
	/* 从环境变量中获取IP地址 */
	gd->bd->bi_ip_addr = getenv_IPaddr ("ipaddr");
	stdio_init (); /* get the devices list going. */
	jumptable_init ();
	
	… …
	
	/* fully init console as a device */
	console_init_r (); 
	
	… …
	
	/* enable exceptions */
	enable_interrupts ();
 
  
  // USB 初始化
	#ifdef CONFIG_USB_DEVICE
		usb_init_slave();
	#endif
 
	/* Initialize from environment */
	
	if ((s = getenv ("loadaddr")) != NULL) {
		load_addr = simple_strtoul (s, NULL, 16);
	}
 
	#if defined(CONFIG_CMD_NET)
	
	if ((s = getenv ("bootfile")) != NULL) {
		copy_filename (BootFile, s, sizeof (BootFile));
	}
	
	#endif
 
	… …
	
	/* 网卡初始化 */
	
	#if defined(CONFIG_CMD_NET)
	
	#if defined(CONFIG_NET_MULTI)
	
		puts ("Net: ");
	
	#endif
	
	eth_initialize(gd->bd);
 
	… …
 
	#endif
 
 
	/* main_loop() can return to retry autoboot, if so just run it again. */
	
	for (;;) {
	
		main_loop ();
	
	}
 
	/* NOTREACHED - no way out of command loop except booting */
 
}

main_loop函数在common/main.c中定义。一般情况下,进入main_loop函数若干秒内没有按键触发就进入kernel 执行流程

UBOOT启动Linux过程

U-Boot使用标记列表(tagged list)的方式向Linux传递参数。标记的数据结构式是tag,在U-Boot源代码目录include/asm-arm/setup.h中定义如下:


struct tag_header {
 
	u32 size; /* 表示tag数据结构的联合u实质存放的数据的大小*/
	u32 tag;  /* 表示标记的类型 */
 
};
 
struct tag {
 
	struct tag_header hdr;
	
	union {
	
		struct tag_core core;
		struct tag_mem32 mem;
		struct tag_videotext videotext;
		struct tag_ramdisk ramdisk;
		struct tag_initrd initrd;
		struct tag_serialnr serialnr;
		struct tag_revision revision;
		struct tag_videolfb videolfb;
		struct tag_cmdline cmdline;
		
		 /*
		 * Acorn specific
		 */
		
		 struct tag_acorn acorn;
		
		 /*
		 * DC21285 specific
		 */
		
		 struct tag_memclk memclk;
	
	 } u;
 
};

U-Boot使用命令bootm来启动已经加载到内存中的内核。而bootm命令实际上调用的是do_bootm函数。对于Linux内核,do_bootm函数会调用do_bootm_linux函数来设置标记列表和启动内核。do_bootm_linux函数在lib_arm/bootm.c 中定义如下:


int do_bootm_linux(int flag, int argc, char *argv[], bootm_headers_t *images)
{
 
	bd_t *bd = gd->bd;
	char *s;
	int machid = bd->bi_arch_number;
	void (*theKernel)(int zero, int arch, uint params);
 
#ifdef CONFIG_CMDLINE_TAG
 
	char *commandline = getenv ("bootargs"); /* U-Boot环境变量bootargs */
 
#endif
 
	…
	
	theKernel = (void (*)(int, int, uint))images->ep; /* 获取内核入口地址 */
	
	…
 
	#if defined (CONFIG_SETUP_MEMORY_TAGS) || \
	
	defined (CONFIG_CMDLINE_TAG) || \
	
	defined (CONFIG_INITRD_TAG) || \
	
	defined (CONFIG_SERIAL_TAG) || \
	
	defined (CONFIG_REVISION_TAG) || \
	
	defined (CONFIG_LCD) || \
	
	defined (CONFIG_VFD)
	
	setup_start_tag (bd); /* 设置ATAG_CORE标志 */
	
	…
	
	#ifdef CONFIG_SETUP_MEMORY_TAGS
		setup_memory_tags (bd);  /* 设置内存标记 */
	#endif
	
	#ifdef CONFIG_CMDLINE_TAG
		setup_commandline_tag (bd, commandline); /* 设置命令行标记 */
	#endif
	
	…
	
	setup_end_tag (bd); /* 设置ATAG_NONE标志 */
	
	#endif
	
	
	/* we assume that the kernel is in place */
	
	printf ("\nStarting kernel ...\n\n");
	
	…
	
	cleanup_before_linux (); /* 启动内核前对CPU作最后的设置 */
	
	theKernel (0, machid, bd->bi_boot_params); /* 调用内核 */
	
	/* does not return */
	
	return 1;
 
}

其中的setup_start_tag,setup_memory_tags,setup_end_tag函数在lib_arm/bootm.c中定义如下:

(1)setup_start_tag函数


static void setup_start_tag (bd_t *bd)
{
	params = (struct tag *) bd->bi_boot_params; /* 内核的参数的开始地址 */
	
	params->hdr.tag = ATAG_CORE;
	params->hdr.size = tag_size (tag_core);
	params->u.core.flags = 0;
	params->u.core.pagesize = 0;
	params->u.core.rootdev = 0;
	params = tag_next (params);
}

标记列表必须以ATAG_CORE开始,setup_start_tag函数在内核的参数的开始地址设置了一个ATAG_CORE标记

(2)setup_memory_tags函数


static void setup_memory_tags (bd_t *bd)
{
	int i;
 
	/*设置一个内存标记 */
 
	for (i = 0; i < CONFIG_NR_DRAM_BANKS; i++) {
 
		params->hdr.tag = ATAG_MEM;
		params->hdr.size = tag_size (tag_mem32);
		params->u.mem.start = bd->bi_dram[i].start;
		params->u.mem.size = bd->bi_dram[i].size;
		params = tag_next (params);
 
	}
}

setup_memory_tags函数设置了一个ATAG_MEM标记,该标记包含内存起始地址,内存大小这两个参数。

(3)setup_end_tag函数


static void setup_end_tag (bd_t *bd)
{
	params->hdr.tag = ATAG_NONE;
	params->hdr.size = 0;
}

标记列表必须以标记ATAG_NONE结束,setup_end_tag函数设置了一个ATAG_NONE标记,表示标记列表的结束。

U-Boot设置好标记列表后就要调用内核了。但调用内核前,CPU必须满足下面的条件:

(1) CPU寄存器的设置
>Ø  r0=0
>Ø  r1=机器码
>Ø  r2=内核参数标记列表在RAM中的起始地址

(2)CPU工作模式
>Ø  禁止IRQ与FIQ中断
>Ø  CPU为SVC模式

(3) 使数据Cache与指令Cache失效

do_bootm_linux中调用的cleanup_before_linux函数完成了禁止中断和使Cache失效的功能。cleanup_before_linux函数在cpu/arm920t/cpu.中定义:


int cleanup_before_linux (void)
{
	/*
	* this function is called just before we call linux
	* it prepares the processor for linux
	*
	* we turn off caches etc ...
	*/
	
	disable_interrupts (); /* 禁止FIQ/IRQ中断 */
	
	/* turn off I/D-cache */
	
	icache_disable(); /* 使指令Cache失效 */
	
	dcache_disable(); /* 使数据Cache失效 */
	
	/* flush I/D-cache */
	
	cache_flush(); /* 刷新Cache */
	
	return 0;
}

由于U-Boot启动以来就一直工作在SVC模式,因此CPU的工作模式就无需设置了。


do_bootm_linux中:
 
void (*theKernel)(int zero, int arch, uint params);
 
… …
 
theKernel = (void (*)(int, int, uint))images->ep;
 
… …
 
theKernel (0, machid, bd->bi_boot_params);

第73行代码将内核的入口地址“images->ep”强制类型转换为函数指针。根据ATPCS规则,函数的参数个数不超过4个时,使用r0~r3这4个寄存器来传递参数。因此第128行的函数调用则会将0放入r0,机器码machid放入r1,内核参数地址bd->bi_boot_params放入r2,从而完成了寄存器的设置,最后转到内核的入口地址。

到这里,U-Boot的工作就结束了,系统跳转到Linux内核代码执行。

UBOOT 添加命令的方法及U-Boot命令执行过程

下面以添加menu命令(启动菜单)为例讲解U-Boot添加命令的方法。

(1)建立common/cmd_menu.c

习惯上通用命令源代码放在common目录下,与开发板专有命令源代码则放在board/<board_dir>目录下,并且习惯以“cmd_<命令名>.c”为文件名。

(2)定义“menu”命令

在cmd_menu.c中使用如下的代码定义“menu”命令:


_BOOT_CMD(
 
       menu,    3,    0,    do_menu,
       "menu - display a menu, to select the items to do something\n",
       " - display a menu, to select the items to do something"
 
);

其中U_BOOT_CMD命令格式如下:

U_BOOT_CMD(name,maxargs,rep,cmd,usage,help) 各个参数的意义如下:
>name:命令名,非字符串,但在U_BOOT_CMD中用“#”符号转化为字符串
>maxargs:命令的最大参数个数
>rep:是否自动重复(按Enter键是否会重复执行)
>cmd:该命令对应的响应函数
>usage:简短的使用说明(字符串)
>help:较详细的使用说明(字符串)

在内存中保存命令的help字段会占用一定的内存,通过配置U-Boot可以选择是否保存help字段。若在include/configs/mini2440.h中定义了CONFIG_SYS_LONGHELP宏,则在U-Boot中使用help命令查看某个命令的帮助信息时将显示usage和help字段的内容,否则就只显示usage字段的内容。

U_BOOT_CMD宏在include/command.h中定义:


#define U_BOOT_CMD(name,maxargs,rep,cmd,usage,help) \
cmd_tbl_t __u_boot_cmd_##name Struct_Section = {#name, maxargs, rep, cmd, usage, help}
 
 “##”与“#”都是预编译操作符,“##”有字符串连接的功能,“#”表示后面紧接着的是一个字符串。
 
//其中的cmd_tbl_t在include/command.h中定义如下:
 
struct cmd_tbl_s {
 
 char *name; /* 命令名 */
 int maxargs; /* 最大参数个数 */
 int repeatable; /* 是否自动重复 */
 int (*cmd)(struct cmd_tbl_s *, int, int, char *[]); /* 响应函数 */
 char *usage; /* 简短的帮助信息 */
 
#ifdef CONFIG_SYS_LONGHELP
 char *help; /* 较详细的帮助信息 */
#endif
 
#ifdef CONFIG_AUTO_COMPLETE
 
 /* 自动补全参数 */
 int (*complete)(int argc, char *argv[], char last_char, int maxv, char *cmdv[]);
 
#endif
 
};
typedef struct cmd_tbl_s  cmd_tbl_t;
 
一个cmd_tbl_t结构体变量包含了调用一条命令的所需要的信息。
 
其中Struct_Section在include/command.h中定义如下:
 
#define Struct_Section  __attribute__ ((unused,section (".u_boot_cmd")))
 
凡是带有__attribute__ ((unused,section (".u_boot_cmd"))属性声明的变量都将被存放在".u_boot_cmd"段中,
并且即使该变量没有在代码中显式的使用编译器也不产生警告信息。
 
在U-Boot连接脚本u-boot.lds中定义了".u_boot_cmd"段:
 
  . = .;
  __u_boot_cmd_start = .;          /*将 __u_boot_cmd_start指定为当前地址 */
  .u_boot_cmd : { *(.u_boot_cmd) }
  __u_boot_cmd_end = .;           /*  将__u_boot_cmd_end指定为当前地址  */

这表明带有“.u_boot_cmd”声明的函数或变量将存储在“u_boot_cmd”段。这样只要将U-Boot所有命令对应的cmd_tbl_t变量加上“.u_boot_cmd”声明,编译器就会自动将其放在“u_boot_cmd”段,查找cmd_tbl_t变量时只要在__u_boot_cmd_start与__u_boot_cmd_end之间查找就可以了。

因此“menu”命令的定义经过宏展开后如下:

cmd_tbl_t __u_boot_cmd_menu __attribute__ ((unused,section (".u_boot_cmd"))) = {menu, 3, 0, do_menu, "menu - display a menu, to select the items to do something\n", " - display a menu, to select the items to do something"}

实质上就是用U_BOOT_CMD宏定义的信息构造了一个cmd_tbl_t类型的结构体。编译器将该结构体放在“u_boot_cmd”段,执行命令时就可以在“u_boot_cmd”段查找到对应的 cmd_tbl_t类型结构体。

(3)实现命令的函数

在cmd_menu.c中添加“menu”命令的响应函数的实现。具体的实现代码略:


int do_menu (cmd_tbl_t *cmdtp, int flag, int argc, char *argv[])
{
    /* 实现代码略 */
}

(4)将common/cmd_menu.c编译进u-boot.bin

在common/Makefile中加入如下代码:

COBJS-$(CONFIG_BOOT_MENU) += cmd_menu.o

在include/configs/mini2440.h加入如代码:

#define CONFIG_BOOT_MENU 1

重新编译下载U-Boot就可以使用menu命令了

(5)menu命令执行的过程

在U-Boot中输入“menu”命令执行时,U-Boot接收输入的字符串“menu”,传递给run_command函数。run_command函数调用common/command.c中实现的find_cmd函数在__u_boot_cmd_start与__u_boot_cmd_end间查找命令,并返回menu命令的cmd_tbl_t结构。然后run_command函数使用返回的cmd_tbl_t结构中的函数指针调用menu命令的响应函数do_menu,从而完成了命令的执行。

#5 引导模块和保护模式 » Gentoo 之 Initial RAM filesystem and RAM disk » 2024-04-02 21:54:48

batsom
回复: 0

一、简介

(1) initrd

在早期的linux系统中,一般只有硬盘或者软盘被用来作为linux根文件系统的存储设备,因此也就很容易把这些设备的驱动程序集成到内核中。但是现在的嵌入式系统中可能将根文件系统保存到各种存储设备上,包括scsi、sata,u-disk等等。因此把这些设备的驱动代码全部编译到内核中显然就不是很方便。

  为了解决这一矛盾,于是出现了基于ramdisk的initrd( bootloader initialized RAM disk )。Initrd是一个被压缩过的小型根目录,这个目录中包含了启动阶段中必须的驱动模块,可执行文件和启动脚本。当系统启动的时候,bootloader会把initrd文件读到内存中,然后把initrd文件在内存中的起始地址和大小传递给内核。内核在启动初始化过程中会解压缩initrd文件,然后将解压后的initrd挂载为根目录,然后执行根目录中的/linuxrc脚本(cpio格式的initrd为/init,而image格式的initrd<也称老式块设备的initrd或传统的文件镜像格式的initrd>为/initrc),您就可以在这个脚本中加载realfs(真实文件系统)存放设备的驱动程序以及在/dev目录下建立必要的设备节点。这样,就可以mount真正的根目录,并切换到这个根目录中来。

(2) Initramfs

在linux2.5中出现了initramfs,它的作用和initrd类似,只是和内核编译成一个文件(该initramfs是经过gzip压缩后的cpio格式的数据文件),该cpio格式的文件被链接进了内核中特殊的数据段.init.ramfs上,其中全局变量__initramfs_start和__initramfs_end分别指向这个数据段的起始地址和结束地址。内核启动时会对.init.ramfs段中的数据进行解压,然后使用它作为临时的根文件系统。

二、initramfs与initrd区别

(1) Linux内核只认cpio格式的initramfs文件包(因为unpack_to_rootfs只能解析cpio格式文件),非cpio格式的 initramfs文件包将被系统抛弃,而initrd可以是cpio包也可以是传统的镜像(image)文件,实际使用中initrd都是传统镜像文件。
(2) initramfs在编译内核的同时被编译并与内核连接成一个文件,它被链接到地址__initramfs_start处,与内核同时被 bootloader加载到ram中,而initrd是另外单独编译生成的,是一个独立的文件,它由bootloader单独加载到ram中内核空间外的地址,比如加载的地址为addr(是物理地址而非虚拟地址),大小为8MB,那么只要在命令行加入"initrd=addr,8M"命令,系统就可以找到 initrd(当然通过适当修改Linux的目录结构,makefile文件和相关代码,以上两种情况都是可以相通的)。
(3) initramfs被解析处理后原始的cpio包(压缩或非压缩)所占的空间(&__initramfs_start - &__initramfs_end)是作为系统的一部分直接保留在系统中,不会被释放掉,而对于initrd镜像文件,如果没有在命令行中设置"keepinitd"命令,那么initrd镜像文件被处理后其原始文件所占的空间(initrd_end - initrd_start)将被释放掉。
(4) initramfs可以独立ram disk单独存在,而要支持initrd必须要先支持ram disk,即要配置CONFIG_BLK_DEV_INITRD选项 -- 支持initrd,必须先要配置CONFIG_BLK_DEV_RAM -- 支持ram disk ,因为initrd image实际就是初始化好了的ramdisk镜像文件,最后都要解析、写入到ram disk设备/dev/ram或/dev/ram0中。注: 使用initramfs,命令行参数将不需要"initrd="和"root="命令
initramfs利弊:
------------------------------------------------------
由于initramfs使用cpio包格式,所以很容易将一个单一的文件、目录、node编译链接到系统中去,这样很简单的系统中使用起来很方便,不需要另外挂接文件系统。
但是因为cpio包实际是文件、目录、节点的描述语言包,为了描述一个文件、目录、节点,要增加很多额外的描述文字开销,特别是对于目录和节点,本身很小额外添加的描述文字却很多,这样使得cpio包比相应的image文件大很多。


使用initramfs的内核配置(使用initramfs做根文件系统):
------------------------------------------------------
General setup  --->
[ ] Initial RAM filesystem and RAM disk (initramfs/initrd) support
(/rootfs_dir) Initramfs source file(s)   //输入根文件系统的所在目录
使用initramfs的内核启动参数不需要"initrd="和"root="参数,但是必须在initramfs中创建/init文件或者修改内核启动最后代码(init文件是软连接,指向什么? init -> bin/busybox,否则内核启动将会失败)
链接入内核的initramfs文件在linux-2.6.24/usr/initramfs_data.cpio.gz
使用initrd的内核配置(使用网口将根文件系统下载到RAM -- tftp addr ramdisk.gz):
------------------------------------------------------
1. 配置initrd
General setup  --->
[ ] Initial RAM filesystem and RAM disk (initramfs/initrd) support
() Initramfs source file(s)   //清空根文件系统的目录配置
2. 配置ramdisk
Device Drivers  --->   
Block devices  --->
< > RAM disk support
(16)  Default number of RAM disks   // 内核在/dev/目录下生成16个ram设备节点
(4096) Default RAM disk size (kbytes)
(1024) Default RAM disk block size (bytes)
使用 initrd的内 核启动参数:initrd=addr,0x400000 root=/dev/ram rw
注:
(1) addr是根文件系统的下载地址;
(2) 0x400000是根文件系统的大小,该大小需要和内核配置的ramdisk size 4096 kbytes相一致;
(3) /dev/ram是ramdisk的设备节点,rw表示根文件系统可读、可写;
根文件系统存放在FLASH分区:
------------------------------------------------------
1. 内核启动参数不需要"initrd="(也可以写成"noinitrd");
root=/dev/mtdblock2 (/dev/mtdblock2 -- 根文件系统所烧写的FLASH分区)
2. 内核配置不需要ram disk;也不需要配置initramfs或者initrd
[ ] Initial RAM filesystem and RAM disk (initramfs/initrd) support
注: boot的FLASH分区要和kernel的FLASH分区匹配(而非一致),需要进一步解释。


处理流程
linux内核支持两种格式的文件系统镜像:传统格式的文件系统镜像image-initrd和cpio-initrd格式的镜像。
下面分别说明:

cpio-initrd的处理流程:(执行流程可以对照下面博文的代码分析:linux的initrd机制和initramfs机制之根文件挂载流程:代码分析)
1.uboot把内核以及initrd文件加载到内存的特定位置。
2.内核判断initrd的文件格式,如果是cpio格式。
3.将initrd的内容释放到rootfs中。
4.执行initrd中的/init文件,执行到这一点,内核的工作全部结束,完全交给/init文件处理。
可见对于cpio-initrd格式的镜像,它执行的是init文件

image-initrd的处理流程
1.uboot把内核以及initrd文件加载到内存的特定位置。
2.内核判断initrd的文件格式,如果不是cpio格式,将其作为image-initrd处理。
3.内核将initrd的内容保存在rootfs下的/initrd.image文件中。
4.内核将/initrd.image的内容读入/dev/ram0设备中,也就是读入了一个内存盘中。
5.接着内核以可读写的方式把/dev/ram0设备挂载为原始的根文件系统。
6.如果/dev/ram0被指定为真正的根文件系统,那么内核跳至最后一步正常启动。
7.执行initrd上的/linuxrc文件,linuxrc通常是一个脚本文件,负责加载内核访问根文件系统必须的驱动,以及加载根文件系统。
8./linuxrc执行完毕,实际根文件系统被挂载,执行权转交给内核。
9.如果实际根文件系统存在/initrd目录,那么/dev/ram0将从/移动到/initrd。否则如果/initrd目录不存在,/dev/ram0将被卸载。
10.在实际根文件系统上进行正常启动过程,执行/sbin/init。
对于image-initrd格式的镜像,它执行的是linuxrc文件

三、两种格式镜像比较
1. cpio-initrd的制作方法比image-initrd简单。
2. cpio-initrd的内核处理流程相比image-initrd更简单,因为:
a. 根据上面的流程对比可知,cpio-initrd格式的镜像是释放到rootfs中的,不需要额外的文件系统支持,
   而image-initrd格式的镜像先是被挂载成虚拟文件系统,而后被卸载,基于具体的文件系统
b. image-initrd内核在执行完/linuxrc进程后,还要返回执行内核进行一些收尾工作,
   并且要负责执行真正的根文件系统的/sbin/init。

处理流程对比如下图所示:(来自网络)
FluxBB bbcode 测试

由对比可以看出cpio-initrd格式的镜像更具优势,这也是它逐渐代替image-initrd格式镜像的原因

四、initrd镜像的制作

cpio-initrd格式镜像制作:
进入到要制作的文件系统的根目录;

bash# find . | cpio -c -o > ../initrd.img
bash# gzip ../initrd.img

image-initrd格式镜像制作:
进入到要制作的文件系统的根目录;

bash# dd if=/dev/zero of=../initrd.img bs=512k count=5
bash# mkfs.ext2 -F -m0 ../initrd.img
bash# mount -t ext2 -o loop ../initrd.img /mnt
bash# cp -r * /mnt
bash# umount /mnt
bash# gzip -9 ../initrd.img

对于image-initrd格式镜像的制作,往往采用制作工具,如genext2fs

五、image-initrd格式镜像实例解读
参见下一篇博文
一、initrd

ram disk中的file system叫做initrd,全名叫做initial ramdisk。
如何创建initial ramisk

host > dd if=/dev/zero of=/dev/ram0 bs=1k count=<count>
host > mke2fs -vm0 /dev/ram0 <count>
host > tune2fs -c 0 /dev/ram0
host > dd if=/dev/ram0 bs=1k count=<count> | gzip -v9 > ramdisk.gz

这段代码就创建了大小为count的ramdisk
创建完之后还要添加哪些东西

还要添加一些必要的文件让他工作,可能是库,应用程序等。例如busybox。

host $ mkdir mnt
host $ gunzip ramdisk.gz
host $ mount -o loop ramdisk mnt/
host $ ... copy stuff you want to have in ramdisk to mnt...
host $ umount mnt
host $ gzip -v9 ramdisk

内核如何支持initial ramdisk

#
# General setup
#
...
CONFIG_BLK_DEV_INITRD=y
CONFIG_INITRAMFS_SOURCE=""
...

#
# UBI - Unsorted block images
#
.../*****************initramfs 应该不需要配置下面的参数************************/
CONFIG_BLK_DEV_RAM=y
CONFIG_BLK_DEV_RAM_COUNT=1
CONFIG_BLK_DEV_RAM_SIZE=8192
CONFIG_BLK_DEV_RAM_BLOCKSIZE=1024

告诉uboot怎么找到她

UBOOT # tftp 0x87000000 ramdisk.gz
UBOOT # erase 0x2200000 +0x<filesize>
UBOOT # cp.b 0x87000000 0x2200000 0x<filesize>

UBOOT # setenv bootargs ... root=/dev/ram0 rw initrd=0x87000000,8M
UBOOT # setenv bootcmd cp.b 0x2200000 0x87000000 0x<filesize>; bootm
UBOOT # saveenv

注意: ramdisk 中要有ram0节点
最后启动内核
二、initramfs

initramfs相当于把initrd放进了内核,通过cpio(这是一个文件处理工具)实现。
如何创建
比initrd简单多了

host > mkdir target_fs

host > ... copy stuff you want to have in initramfs to target_fs...

注意:
1. initramfs中的cpio系统不能处理hard link,用soft link
2. 顶层必须有个init程序,这是kernel要用的,可以这么做

/init -> /bin/busybox

接着

host > cd target_fs
host > find . | cpio -H newc -o > ../target_fs.cpio

内核支持

#
# General setup
#
...
CONFIG_BLK_DEV_INITRD=y
CONFIG_INITRAMFS_SOURCE="<path_to>/target_fs>"
...

#
# UBI - Unsorted block images
#
...
CONFIG_BLK_DEV_RAM=y
CONFIG_BLK_DEV_RAM_COUNT=1
CONFIG_BLK_DEV_RAM_SIZE=8192
CONFIG_BLK_DEV_RAM_BLOCKSIZE=1024
 

然后执行make uImage的时候就被包含到kernel中了。
uboot支持

因为已经在kernel中了,不需要像initrd一样通过参数 root=/xxx rw initrd=xxx来告诉uboot了

三、比较
    initrd方式中kernel和initial file system为独立的部分,互不影响,下载的时候镜像也小。
    创建修改initramfs比initrd容易。
    在烧写的时候,显然一个镜像更容易管理。

一、简介

(1) initrd

  在早期的linux系统中,一般只有硬盘或者软盘被用来作为linux根文件系统的存储设备,因此也就很容易把这些设备的驱动程序集成到内核中。但是现在的嵌入式系统中可能将根文件系统保存到各种存储设备上,包括scsi、sata,u-disk等等。因此把这些设备的驱动代码全部编译到内核中显然就不是很方便。

  为了解决这一矛盾,于是出现了基于ramdisk的initrd( bootloader initialized RAM disk )。Initrd是一个被压缩过的小型根目录,这个目录中包含了启动阶段中必须的驱动模块,可执行文件和启动脚本。当系统启动的时候,bootloader会把initrd文件读到内存中,然后把initrd文件在内存中的起始地址和大小传递给内核。内核在启动初始化过程中会解压缩initrd文件,然后将解压后的initrd挂载为根目录,然后执行根目录中的/linuxrc脚本(cpio格式的initrd为/init,而image格式的initrd<也称老式块设备的initrd或传统的文件镜像格式的initrd>为/initrc),您就可以在这个脚本中加载realfs(真实文件系统)存放设备的驱动程序以及在/dev目录下建立必要的设备节点。这样,就可以mount真正的根目录,并切换到这个根目录中来。

(2) Initramfs

  在linux2.5中出现了initramfs,它的作用和initrd类似,只是和内核编译成一个文件(该initramfs是经过gzip压缩后的cpio格式的数据文件),该cpio格式的文件被链接进了内核中特殊的数据段.init.ramfs上,其中全局变量__initramfs_start和__initramfs_end分别指向这个数据段的起始地址和结束地址。内核启动时会对.init.ramfs段中的数据进行解压,然后使用它作为临时的根文件系统。

二、initramfs与initrd区别


(1) Linux内核只认cpio格式的initramfs文件包(因为unpack_to_rootfs只能解析cpio格式文件),非cpio格式的 initramfs文件包将被系统抛弃,而initrd可以是cpio包也可以是传统的镜像(image)文件,实际使用中initrd都是传统镜像文件。
(2) initramfs在编译内核的同时被编译并与内核连接成一个文件,它被链接到地址__initramfs_start处,与内核同时被 bootloader加载到ram中,而initrd是另外单独编译生成的,是一个独立的文件,它由bootloader单独加载到ram中内核空间外的地址,比如加载的地址为addr(是物理地址而非虚拟地址),大小为8MB,那么只要在命令行加入"initrd=addr,8M"命令,系统就可以找到 initrd(当然通过适当修改Linux的目录结构,makefile文件和相关代码,以上两种情况都是可以相通的)。
(3) initramfs被解析处理后原始的cpio包(压缩或非压缩)所占的空间(&__initramfs_start - &__initramfs_end)是作为系统的一部分直接保留在系统中,不会被释放掉,而对于initrd镜像文件,如果没有在命令行中设置"keepinitd"命令,那么initrd镜像文件被处理后其原始文件所占的空间(initrd_end - initrd_start)将被释放掉。
(4) initramfs可以独立ram disk单独存在,而要支持initrd必须要先支持ram disk,即要配置CONFIG_BLK_DEV_INITRD选项 -- 支持initrd,必须先要配置CONFIG_BLK_DEV_RAM -- 支持ram disk ,因为initrd image实际就是初始化好了的ramdisk镜像文件,最后都要解析、写入到ram disk设备/dev/ram或/dev/ram0中。注: 使用initramfs,命令行参数将不需要"initrd="和"root="命令
initramfs利弊:
------------------------------------------------------
由于initramfs使用cpio包格式,所以很容易将一个单一的文件、目录、node编译链接到系统中去,这样很简单的系统中使用起来很方便,不需要另外挂接文件系统。
但是因为cpio包实际是文件、目录、节点的描述语言包,为了描述一个文件、目录、节点,要增加很多额外的描述文字开销,特别是对于目录和节点,本身很小额外添加的描述文字却很多,这样使得cpio包比相应的image文件大很多。


使用initramfs的内核配置(使用initramfs做根文件系统):
------------------------------------------------------
General setup  --->
[ ] Initial RAM filesystem and RAM disk (initramfs/initrd) support
(/rootfs_dir) Initramfs source file(s)   //输入根文件系统的所在目录
使用initramfs的内核启动参数不需要"initrd="和"root="参数,但是必须在initramfs中创建/init文件或者修改内核启动最后代码(init文件是软连接,指向什么? init -> bin/busybox,否则内核启动将会失败)
链接入内核的initramfs文件在linux-2.6.24/usr/initramfs_data.cpio.gz
使用initrd的内核配置(使用网口将根文件系统下载到RAM -- tftp addr ramdisk.gz):
------------------------------------------------------
1. 配置initrd
General setup  --->
[ ] Initial RAM filesystem and RAM disk (initramfs/initrd) support
() Initramfs source file(s)   //清空根文件系统的目录配置
2. 配置ramdisk
Device Drivers  --->   
Block devices  --->
< > RAM disk support
(16)  Default number of RAM disks   // 内核在/dev/目录下生成16个ram设备节点
(4096) Default RAM disk size (kbytes)
(1024) Default RAM disk block size (bytes)
使用 initrd的内 核启动参数:initrd=addr,0x400000 root=/dev/ram rw
注:
(1) addr是根文件系统的下载地址;
(2) 0x400000是根文件系统的大小,该大小需要和内核配置的ramdisk size 4096 kbytes相一致;
(3) /dev/ram是ramdisk的设备节点,rw表示根文件系统可读、可写;
根文件系统存放在FLASH分区:
------------------------------------------------------
1. 内核启动参数不需要"initrd="(也可以写成"noinitrd");
root=/dev/mtdblock2 (/dev/mtdblock2 -- 根文件系统所烧写的FLASH分区)
2. 内核配置不需要ram disk;也不需要配置initramfs或者initrd
[ ] Initial RAM filesystem and RAM disk (initramfs/initrd) support
注: boot的FLASH分区要和kernel的FLASH分区匹配(而非一致),需要进一步解释。

#6 内核模块 » Gentoo 之 user space relay support » 2024-04-02 21:02:20

batsom
回复: 0

为了使得用户空间的程序可以使用relayfs文件,relayfs必须被mount,格式跟proc差不多:
         mount -t relayfs relayfs /mnt/relay/       

=========================================================================

        relay 是一种从 Linux 内核到用户空间的高效数据传输技术。通过用户定义的 relay 通道,内核空间的程序能够高效、可靠、便捷地将数据传输到用户空间。relay 特别适用于内核空间有大量数据需要传输到用户空间的情形,目前已经广泛应用在内核调试工具如 SystemTap中。

    relay 要解决的问题

        对于大量数据需要在内核中缓存并传输到用户空间需求,很多传统的方法都已到达了极限,例如内核程序员很熟悉的printk() 调用。此外,如果不同的内核子都开发自己的缓存和传输,造成很大的冗余,而且也带来维护上的困难。

        这些,都要求开发一套能够高效可靠地将数据从内核空间转发到用户空间的,而且这个应该独立于各个调试子。这样就诞生了 relayFS。

    relay的发展历史

        relay 的前身是 relayFS,即作为 Linux 的一个新型文件。2003年3月,relayFS的第一个版本的被开发出来,在7月14日,第一个针对2.6内核的版本也开始提供。经过广泛的试用和改进,直到2005年9月,relayFS才被加入mainline内核(2.6.14)。同时,relayFS也被移植到2.4内核中。在 2006年2月,从2.6.17开始,relayFS不再作为单独的文件存在,而是成为内核的一部分。它的源码也 从fs/目录下转移到 kernel/relay.c中,名称中也从relayFS改成了relay。

        relayFS目前已经被越来越多的内核工具使用,包括内核调试工具SystemTap、LTT,以及一些特殊的文件,例如DebugFS。

    relay的基本原理

        relay提供了一种机制,使得内核空间的程序能够通过用户定义的relay通道(channel)将大量数据高效的传输到用户空间。

        一个relay通道由一组和CPU 一 一对应的内核缓冲区组成。这些缓冲区又被称为relay缓冲区(buffer),其中的每一个在用户空间都用一个常规文件来表示,这被叫做relay文件(file)。内核空间的用户可以利用relay提供的API接口来写入数据,这些数据会被自动的写入当前的 CPU id对应的那个relay缓冲区;同时,这些缓冲区从用户空间看来,是一组普通文件,可以直接使用read()进行读取,也可以使用mmap()进行映射。Relay并不关心数据的格式和内容,这些完全依赖于使用relay的用户程序。relay的目的是提供一个足够简单的接口,从而使得基本操作尽可能的高效。

        relay将数据的读和写分离,使得突发性大量数据写入的时候,不需要受限于用户空间相对较慢的读取速度,从而大大提高了效率。relay作为写入和读取的桥梁,也就是将内核用户写入的数据缓存并转发给用户空间的程序。这种转发机制也正是relay这个名称的由来。

        这里的relay通道由四个relay缓冲区(kbuf0到kbuf3)组成,分别对应于中的cpu0到cpu1。每个CPU上的调用relay_write()的时候将数据写入自己对应的relay缓冲区内。每个relay缓冲区称一个relay文件,即/cpu0到 /cpu3。当文件被mount到/mnt/以后,这个relay文件就被映射成映射到用户空间的地址空间。一旦数据可用,用户程序就可以把它的数据读出来写入到硬盘上的文件中,即cpu0.out到cpu3.out。

    relay的主要API

1、 面向用户空间的API:

        这些 relay 编程接口向用户空间程序提供了访问 relay 通道缓冲区数据基本操作入口,包括:

        open() - 允许用户打开一个已经存在的通道缓冲区。

        mmap() - 使通道缓冲区被映射到位于用户空间的调用者的地址空间。要特别注意的是,我们不能仅对局部区域进行映射。也就是说,必须映射整个缓冲区文件,其大小是CPU的个数和单个CPU 缓冲区大小的乘积。

        read() - 读取通道缓冲区的内容。这些数据一旦被读出,就意味着他们被用户空间的程序消费掉了,也就不能被之后的读操作看到。

        sendfile() - 将数据从通道缓冲区传输到一个输出文件描述符。其中可能的填充字符会被自动去掉,不会被用户看到。

        poll() - 支持 POLLIN/POLLRDNORM/POLLERR 信号。每次子缓冲区的边界被越过时,等待着的用户空间程序会得到通知。

        close() - 将通道缓冲区的引用数减1。当引用数减为0时,表明没有进程或者内核用户需要打开它,从而这个通道缓冲区被释放。

2、 面向内核空间的API:

        这些API接口向位于内核空间的用户提供了管理relay通道、数据写入等功能。包括:

        relay_open() - 创建一个relay通道,包括创建每个CPU对应的relay缓冲区。

        relay_close() - 关闭一个relay通道,包括释放所有的relay缓冲区,在此之前会调用relay_switch()来处理这些relay缓冲区以保证已读取但是未满的数据不会丢失。

        relay_write() - 将数据写入到当前CPU对应的relay缓冲区内。由于它使用了local_irqsave()保护,因此也可以在中断上下文中使用。

        relay_reserve() - 在relay通道中保留一块连续的区域来留给未来的写入操作。这通常用于那些希望直接写入到relay缓冲区的用户。考虑到性能或者其它因素,这些用户不希望先把数据写到一个临时缓冲区中,然后再通过relay_write()进行写入。

    Linux relayfs的介绍以及使用

        从Linux-2.6.14内核(2.6.12需要打补丁)开始,relayfs开始作为内核中File System选项中伪文件系统(Pseudo File System)来出现,这是一个新特性。
    File System--->
        Pseudo filesystems---->
            <>Relayfs File System Support
    我们知道,Pseduo File System 另外一个很有名的东西是Proc File System,几乎每个学习Linux的都知道使用这个文件系统来查看cpu型号、内存容量等其它很多的runtime information。Proc FS为users提供了一个方便的接口来查询很多只有内核才能查看的信息,比如:cpuinfo,meminfo,interrupts等,这些都只是 kernel管理的对象,但是我们可以以一个普通users的身份也可以查看。proc FS将内核信息可以动态地传递出来,供普通的process随时查看,某些情况下,用户也可以将信息传递到内核空间,比如:echo 1>/proc/sys/net/ipv4/ip_forward。同样地,relayfs也是可以一种内核和用户空间交换数据的工具,不同的是,它支持大容量的数据交换。

        relayfs中有一个很重要的概念叫做“channel”,具体来说,一个channel就是由很多个内核的buffer组成的一个集合,这些内核的buffer在relayfs中就体现为一个个的文件。 当kernel中的程序把数据写入某个channel时,这些数据实际上自动填入这些channel的buffer。 用户空间的应用程序mmap()将relayfs中的这些文件做个映射,然后在适当的时候把数据提取出来。

        写入channel的数据格式完全取决于最终从channel中提取数据的程序,relayfs可以提取一些hook程序,这些hook程序允许relayfs的数据提取程序(relayfs的客户端)为buffer中的数据增加一些数据结构。这个过程,就像解码跟编码的关系一样,你使用的编码程序和解码程序只有对应就可以,与传输程序无关,当然,你在传输的同时也可以对它进行一些编码,但是这些取决于你最终的解码。 但是,relayfs不提供任何形式的数据过滤,这些任务留给relayfs客户端去完成。 relayfs的设计目标就是尽可能地简单。

        每一个relayfs channel都有一个buffer(单CPU情况),每一个buffer又有一个或者多个二级buffer。 消息是从第一个二级buffer开始写入的,直到这个buffer满为止。然后如果第二个二级buffer可用,就写入第二个二级buffer,依次类推。 所以,如果第一个二级buffer被填满,那么就会通知用户空间;同时,kernel就会去写第二个二级buffer。

        如果kernel发出通知说一个二级buffer被填满了,那么kernel肯定知道填了多少字节。userspace根据这个数字就可以仅仅拷贝合法的数据。拷贝完毕,userpsace通知kernel说一个二级buffer已经被使用了。

        relayfs采用这么一种模式,它会直接去覆盖数据,即使这些数据还没有被userspace所收集。

    relayfs的user space API:

        relayfs为了使得空间程序可以访问channel里面的buffer数据,实现了基本的文件操作。文件操作函数如下:
    open   打开一个存在的buffer;
    mmap  可以使得channel的buffer被映射到调用函数的内存空间,注意,不能部分映射,而是要映射整个文件;
    read   读取channel buffer的内容;
    poll     通知用户空间程序二级buffer空间已满;
    close   关闭。

        为了使得用户空间的程序可以使用relayfs文件,relayfs必须被mount,格式跟proc差不多:
            mount -t relayfs relayfs /mnt/relay/

       kernel空间的一些API:

      relay_open(base_filename, parent, subbuf_size, n_subbufs, callbacks)
        relay_close(chan)
        relay_flush(chan)
        relay_reset(chan)
        relayfs_create_dir(name, parent)
        relayfs_remove_dir(dentry)
        relayfs_create_file(name, parent, mode, fops, data)
        relayfs_remove_file(dentry)
        relay_subbufs_consumed(chan, cpu, subbufs_consumed)
        relay_write(chan, data, length)
      __relay_write(chan, data, length)
      relay_reserve(chan, length)
      subbuf_start(buf, subbuf, prev_subbuf, prev_padding)
      buf_mapped(buf, filp)
      buf_unmapped(buf, filp)
      create_buf_file(filename, parent, mode, buf, is_global)
      remove_buf_file(dentry)

#7 进程模块 » Gentoo 之 Automatic process group scheduling » 2024-04-01 22:44:08

batsom
回复: 0

什么是进程调度

一般来说,在操作系统中会运行多个进程(几个到几千个不等),但一台计算机的 CPU 资源是有限的,如 8 核的 CPU 只能同时运行 8 个进程。那么当进程数大于 CPU 核心数时,操作系统是如何同时运行这些进程的呢?

这里就涉及 进程调度 问题。

操作系统运行进程的时候,是按 时间片 来运行的。时间片 是指一段很短的时间段(如20毫秒),操作系统会为每个进程分配一些时间片。当进程的时间片用完后,操作系统将会把当前运行的进程切换出去,然后从进程队列中选择一个合适的进程运行,这就是所谓的 进程调度。如下图所示:

FluxBB bbcode 测试

什么是组调度

一般来说,操作系统调度的实体是 进程,也就是说按进程作为单位来调度。但如果按进程作为调度实体,就会出现以下情况:

   Linux 是一个支持多用户的操作系统,如果 A 用户运行了 10 个进程,而 B 用户只运行了 2 个进程,那么就会出现 A 用户使用的 CPU 时间是 B 用户的 5 倍。如果 A 用户和 B 用户都是花同样的钱来买的虚拟主机,那么对 B 用户来说是非常不公平的。

为了解决这个问题,Linux 实现了 组调度 这个功能。那么什么是 组调度 呢?

组调度 的实质是:调度时候不再以进程作为调度实体,而是以 进程组 作为调度实体。比如上面的例子,可以把 A 用户运行的进程划分为 进程组A,而 B 用户运行的进程划分为 进程组B。

调度的时候,进程组A 和 进程组B 分配到相同的可运行 时间片,如 进程组A 和 进程组B 各分配到 100 毫秒的可运行时间片。由于 进程组A 有 10 个进程,所以每个进程分配到的可运行时间片为 10 毫秒。而 进程组B 只有 2 个进程,所以每个进程分配到的可运行时间片为 50 毫秒。

下图是 组调度 的原理:

FluxBB bbcode 测试

如上图所示,当内核进行调度时,首先以 进程组 作为调度实体。当选择出最优的 进程组 后,再从 进程组 中选择出最优的进程进行运行,而被切换出来的进程将会放置回原来的 进程组。

由于 组调度 是建立在 cgroup 机制之上的,而 cgroup 又是基于 虚拟文件系统,所以 进程组 是以树结构存在的。也就是说,进程组 除了可以包含进程,还可以包含进程组。如下图所示:

  cgroup 相关的知识点可以参考文章:《cgroup介绍》 和 《cgroup实现原理》

FluxBB bbcode 测试

在 Linux 系统启动时,会创建一个根进程组 init_task_group。然后,我们可以通过使用 cgroup 的 CPU 子系统创建新的进程组,如下命令:


$ mkdir /sys/cgroup/cpu/A                     # 在根进程组中创建进程组A
$ mkdir /sys/cgroup/cpu/B                     # 在根进程组中创建进程组B
$ mkdir /sys/cgroup/cpu/A/C                   # 在进程组A中创建进程组C
$ echo 1923 > /sys/cgroup/cpu/A/cgroup.procs  # 向进程组A中添加进程ID为1923的进程

Linux 在调度的时候,首先会根据 完全公平调度算法 从根进程组中筛选出一个最优的进程或者进程组进行调度。

   如果筛选出来的是进程,那么可以直接把当前运行的进程切换到筛选出来的进程运行即可。
   如果筛选出来的是进程组,那么就继续根据 完全公平调度算法 从进程组中筛选出一个最优的进程或者进程组进行调度(重复进行第一步操作),如此类推。

组调度实现

接下来,我们将介绍 组调度 是如何实现的。在分析之前,为了对 完全公平调度算法 有个大体了解,建议先看看这篇文章:《Linux完全公平调度算法 》。
1. 进程组

在 Linux 内核中,使用 task_group 结构表示一个进程组。其定义如下:

struct task_group {
    struct cgroup_subsys_state css; // cgroup相关结构

    struct sched_entity **se;       // 调度实体(每个CPU分配一个)
    struct cfs_rq **cfs_rq;         // 完全公平调度运行队列(每个CPU分配一个)
    unsigned long shares;           // 当前进程组权重(用于获取时间片)
    ...

    // 由于进程组支持嵌套, 也就是说进程组可以包含进程组
    // 所以, 进程组可以通过下面3个成员组成一个树结构
    struct task_group *parent;  // 父进程组
    struct list_head siblings;  // 兄弟进程组
    struct list_head children;  // 子进程组
};

下面介绍一下 task_group 结构各个字段的作用:

    se:完全公平调度算法 是以 sched_entity 结构作为调度实体(也就是说运行队列中的元素都是 sched_entity 结构),而 sched_entity 结构既能代表一个进程,也能代表一个进程组。这个字段主要作用是,将进程组放置到运行队列中进行调度。由于进程组中的进程可能会在不同的 CPU 上运行,所以这里为每个 CPU 分配一个 sched_entity 结构。
    cfs_rq:完全公平调度算法 的运行队列。完全公平调度算法 在调度时是通过 cfs_rq 结构完成的,cfs_rq 结构使用一棵红黑树将需要调度的进程或者进程组组织起来,然后选择最左端的节点作为要运行的进程或进程组,详情可以参考文章:《Linux完全公平调度算法》。由于进程组可能在不同的 CPU 上调度,所以进程组也为每个 CPU 分配一个运行队列。
   shares:进程组的权重,用于计算当前进程组的可运行时间片。
   parent、siblings、children:用于将系统中所有的进程组组成一棵亲属关系树。

task_group、sched_entity 和 cfs_rq 这三个结构的关系如下图所示:

FluxBB bbcode 测试

从上图可以看出,每个进程组都为每个 CPU 分配一个可运行队列,可运行队列中保存着可运行的进程和进程组。Linux 调度的时候,就是从上而下(从根进程组开始)地筛选出最优的进程进行运行。
2. 调度过程

当 Linux 需要进行进程调度时,会调用 schedule() 函数来完成,其实现如下(经精简后):

void __sched schedule(void)
{
    struct task_struct *prev, *next;
    struct rq *rq;
    int cpu;
    ...

    rq = cpu_rq(cpu); // 获取当前CPU的可运行队列
    ...

    prev->sched_class->put_prev_task(rq, prev); // 把当前运行的进程放回到运行队列
    next = pick_next_task(rq, prev);            // 从可运行队列筛选一个最优的可运行的进程

    if (likely(prev != next)) {
        ...
        // 将旧进程切换到新进程
        context_switch(rq, prev, next); /* unlocks the rq */
        ...
    }

    ...
}

schedule() 函数会调用 pick_next_task() 函数来筛选最优的可运行进程,我们来看看 pick_next_task() 函数的实现过程:

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev)
{
    const struct sched_class *class;
    struct task_struct *p;

    // 如果所有进程都是使用完全公平调度
    if (likely(rq->nr_running == rq->cfs.nr_running)) {
        p = fair_sched_class.pick_next_task(rq);
        if (likely(p))
            return p;
    }
    ...
}

从 pick_next_task() 函数的实现来看,其最终会调用 完全公平调度算法 的 pick_next_task() 方法来完成筛选工作,我们来看看这个方法的实现:

static struct task_struct *pick_next_task_fair(struct rq *rq)
{
    struct task_struct *p;
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    ...

    do {
        se = pick_next_entity(cfs_rq); // 从可运行队列中获取最优的可运行实体

        // 如果最优可运行实体是一个进程组,
        // 那么将继续从进程组中获取到当前CPU对应的可运行队列
        cfs_rq = group_cfs_rq(se);
    } while (cfs_rq);

    p = task_of(se); // 最后一定会获取一个进程
    ...

    return p; // 返回最优可运行进程
}

我们来分析下 pick_next_task_fair() 函数到流程:

    从根进程组中筛选出最优的可运行实体(进程或进程组)。
    如果筛选出来的实体是进程,那么直接返回这个进程。
    如果筛选出来的实体是进程组,那么将会继续对这个进程组中的可运行队列进行筛选,直至筛选出一个可运行的进程。

  怎么区分 sched_entity 实体是进程或者进程组?sched_entity 结构中有个 my_q 的字段,当这个字段设置为 NULL 时,说明这个实体是一个进程。如果这个字段指向一个可运行队列时,说明这个实体是一个进程组。

#8 进程模块 » Gentoo 之 Checkpoint/restore support » 2024-03-31 23:45:56

batsom
回复: 0

CRIU (Checkpoint and Restore in Userspace)
简介

CRIU是一个为Linux实现检查点/恢复功能的项目。全称Checkpoint/Restore In Userspace,或者CRIU,是一个Linux软件。它可以冻结正在运行的容器(或单个应用程序)并将其状态检查点保存到磁盘上。保存的数据可以用于恢复应用程序并将其完全运行到冻结时的状态。使用此功能,现在可以实现应用程序或容器的实时迁移、快照、远程调试等许多其他功能。CRIU最初是Virtuozzo的一个项目,并得到社区的巨大帮助。它目前被(集成到)OpenVZ、LXC /LXD、Docker、Podman和其他软件中,并为许多Linux发行版打包。
使用场景

    容器实时迁移:容器被检查点,然后将镜像复制到另一台计算机上,然后进行恢复。从远程观察者的角度来看,容器只是暂时冻结了。
    快速启动服务:CRIU可以帮助加速需要启动时间较长的服务或应用程序的启动过程,通过在服务的初始化状态创建检查点,以便在需要时可以快速启动。
    无缝内核升级:CRIU可用于在不中断正在运行的进程的情况下进行内核升级,确保系统保持在线并运行。
    网络负载均衡:CRIU可以与负载均衡器一起使用,以实现流量在不同节点之间的无缝切换,从而提高系统的可伸缩性和可用性。
    高性能计算问题:在高性能计算环境中,CRIU可用于保存和恢复运行中的计算任务,以便在硬件或软件故障发生时保护计算工作。
    桌面环境挂起/恢复:CRIU可以用于实现桌面环境中应用程序的挂起和恢复,以便在需要时恢复到以前的状态。
    进程复制:CRIU允许将进程从一个系统复制到另一个系统,这对于应用程序迁移和负载均衡非常有用。
    应用程序的“保存”功能:CRIU可以为不具备“保存”功能的应用程序(如游戏)添加保存和恢复功能,以便用户可以在中断后继续进行。
    应用程序快照:CRIU可以创建应用程序的快照,以便在需要时可以恢复到特定状态。
    将“遗忘”的应用程序移动到“屏幕”:CRIU可以帮助将在后台运行的应用程序转移到前台或“屏幕”,以便用户更容易访问它们。
    在另一台机器上分析应用程序行为:CRIU可用于在不同的系统上分析应用程序的运行和行为,以进行性能和安全性分析。
    调试挂起的应用程序:CRIU可以用于调试挂起状态的应用程序,以便了解其状态和执行。
    容错系统:CRIU可用于创建容错系统,以在故障时自动保存和恢复系统状态。
    更新模拟测试:CRIU可以用于模拟系统更新和升级,以检查它们对系统的影响,而无需实际执行更新。
    零停机崩溃恢复:CRIU可以用于实现零停机的崩溃恢复,确保系统在发生故障时可以迅速恢复到正常运行状态。

CRIU实现原理

CRIU的功能的实现基本分为两个过程,checkpoint和restore。在checkpoint过程,criu主要通过ptrace机制把一段特殊代码动态注入到dumpee进程(待备份的程序进程)并运行,这段特殊代码就实现了收集dumpee进程的所有上下文信息,然后criu把这些上下文信息按功能分类存储为一个个镜像文件。在restore过程。criu解析checkpoint过程产生的镜像文件,以此来恢复程序备份前的状态没,让程序从备份前的状态继续运行。
  下面详细介绍checkpoint和restore这两个过程。
Checkpoint

checkpoint的过程基本依赖ptrace(linux 提供的系统调用,进程跟踪)功能实现。程序严重依赖/proc文件系统,/proc是一个基于内存的文件系统,包括CPU、内存、分区划分、[I/O地址]、直接内存访问通道和正在运行的进程等等,Linux通过/proc访问内核内部数据结构及更改内核设置等,它从/proc收集的信息包括:

    文件描述信息(通过/proc/p i d / f d 和/proc/pid/fdinfo)
    管道参数信息
    内存表(通过/proc/p i d / m a p s 和/proc/pid/map_files/)

checkpoint过程中,criu做的工作由如下步骤组成:
说明:在描述checkpoint中,我们把criu进程称为dumper进程,简称dumper。把要备份的进程称为dumpee进程,简称dumpee。

步骤1:收集并且冻结dumpee的进程树
  dumper通过dumpee的pid遍历/proc/%pid/task/路径收集线程tid,并且递归遍历/proc/p i d / t a s k / pid/task/pid/task/tid/children,然后通过 ptrace函数的PTRACE_ATTACH和PTRACE_SEIZE命令冻结dumpee程序。

步骤2:收集dumpee的资源并保存
  在这个阶段,dumper获取dumpee的所有可获取的资源信息并写到文件里。这些资源的获取通过如下步骤:

    通过 /proc/p i d / s m a p s ∗ ∗ 解 析 所 有 V M A s 区 域 , 并且通过∗∗/proc/pid/map_files 连接读取所有maps文件。
    通过 /proc/$pid/fd获取文件描述号。
    通过ptrace接口和解析/proc/$pid/stat块完成一个进程的核心参数(寄存器和friends)的获取。
    通过ptrace接口向dumpee注入parasite code。这个过程由两步完成:首先注入mmap系统调用到任务被冻结那一刻的CS:IP位置,然后ptrace允许我们运行这个被注入的系统调用,这样我们就在被监控进程里申请到了足够的内存用于parasite code块。接下来把parasite code拷贝到这个新申请到的内存地址,并把CS:IP指向到parasite code的位置。

步骤3:清理dumpee
  dumper获取到dumpee所有信息(比如内存页,它只能从被监控程序内部地址空间写出)后,我们使用ptrace的系列参数去掉步骤2中对dumpee进程的修改。主要是对被注入代码的清理并并恢复dumpee的地址空间。基本通过PTRACE_DETACH 和 PTACE_CONT。然后criu可以选择杀死dumpee或者让dumpee继续运行。上面的test实例中选择的就是在备份dumpee后杀死进程,实际工作中,如果要对程序做差分备份(或者叫增量备份)时可以选择继续运行dumpee。
Restore

恢复程序的过程完全依赖checkpoint过程后产生的镜像文件,主要过程分如下4步:

步骤1:处理共享资源
  在这个步骤里,criu读取*.img镜像文件并找出哪些(子)进程共享了哪些资源,比如共享内存。如果有共享资源存在,稍后共享资源由这个程序的某个(子)进程还原,其他进程要么在第2阶段继承一个(如会话),要么以其他方式获取。例如,后者是通过unix套接字与SCM-CREDS消息一起发送的共享文件,或者是通过memfd文件描述符还原的共享内存区域。

步骤2:生成进程树
  在这一步,CRIU会调用fork()函数一次或多次来重新创建所需进程。

步骤3:恢复基本的资源信息
  在此阶段CRIU打开文件、准备namespaces、重新映射所有私有内存区域、创建sockets、调用chdir() 和 chroot()等等。

步骤4:切换到dumpee的上下文
  通过将restorer.built-in.bin的代码注入到dumpee进程,来完成余下的内存区域、timers、credentials、threads的恢复。
支持的系统平台

x86:主流x86架构(Intel、AMD),兼容i386
arm:细分armv6/armv7/armv8指令集,向下兼容
aarch64:arm架构额64位系统(基于armv8指令集的64位架构)
ppc64:IBM power系列架构
s390:IBM System z系列大型机硬件平台
mips:龙芯mips架构,根据浪潮云对龙芯平台的需求开发
参考文献

https://github.com/checkpoint-restore/criu

https://criu.org/Main_Page

#9 进程模块 » Gentoo 之 Namespaces support » 2024-03-31 19:45:51

batsom
回复: 0

目前我们所提到的容器技术、虚拟化技术(不论何种抽象层次下的虚拟化技术)都能做到资源层面上的隔离和限制。

对于容器技术而言,它实现资源层面上的限制和隔离,依赖于 Linux 内核所提供的 cgroup 和 namespace 技术。

我们先对这两项技术的作用做个概括:

cgroup 的主要作用:管理资源的分配、限制;
namespace 的主要作用:封装抽象,限制,隔离,使命名空间内的进程看起来拥有他们自己的全局资源;

本篇,我们重点来聊 namespace 。

Namespace 是什么?

我们引用 wiki 上对 namespace 的定义:

“Namespaces are a feature of the Linux kernel that partitions kernel resources such that one set of processes sees one set of resources while another set of processes sees a different set of resources. The feature works by having the same namespace for a set of  resources and processes, but those namespaces refer to distinct resources.”

namespace 是 Linux 内核的一项特性,它可以对内核资源进行分区,使得一组进程可以看到一组资源;而另一组进程可以看到另一组不同的资源。该功能的原理是为一组资源和进程使用相同的 namespace,但是这些 namespace 实际上引用的是不同的资源。

这样的说法未免太绕了些,简单来说 namespace 是由 Linux 内核提供的,用于进程间资源隔离的一种技术。将全局的系统资源包装在一个抽象里,让进程(看起来)拥有独立的全局资源实例。同时 Linux 也默认提供了多种 namespace,用于对多种不同资源进行隔离。

在之前,我们单独使用 namespace 的场景比较有限,但 namespace 却是容器化技术的基石。

我们先来看看它的发展历程。

Namespace 的发展历程
FluxBB bbcode 测试

图 1 ,namespace 的历史过程
最早期 - Plan 9

namespace 的早期提出及使用要追溯到 Plan 9 from Bell Labs ,贝尔实验室的 Plan 9。这是一个分布式操作系统,由贝尔实验室的计算科学研究中心在八几年至02年开发的(02年发布了稳定的第四版,距离92年发布的第一个公开版本已10年打磨),现在仍然被操作系统的研究者和爱好者开发使用。在 Plan 9 的设计与实现中,我们着重提以下3点内容:

文件系统:所有系统资源都列在文件系统中,以 Node 标识。所有的接口也作为文件系统的一部分呈现。
Namespace:能更好的应用及展示文件系统的层次结构,它实现了所谓的 “分离”和“独立”。
标准通信协议:9P协议(Styx/9P2000)。

FluxBB bbcode 测试

开始加入 Linux Kernel

Namespace 开始进入 Linux Kernel 的版本是在 2.4.X,最初始于 2.4.19 版本。但是,自 2.4.2 版本才开始实现每个进程的 namespace。

FluxBB bbcode 测试

图 3 ,Linux Kernel Note

FluxBB bbcode 测试

图 4 ,Linux Kernel 对应的各操作系统版本
Linux 3.8 基本实现

Linux 3.8 中终于完全实现了 User Namespace 的相关功能集成到内核。这样 Docker 及其他容器技术所用到的 namespace 相关的能力就基本都实现了。

FluxBB bbcode 测试

图 5 ,Linux Kernel 从 2001 到2013 逐步演变,完成了 namespace 的实现

Namespace 类型

FluxBB bbcode 测试

系统主机名和 NIS(Network Information Service) 主机名(有时称为域名)

Cgroup namespaces

Cgroup namespace 是进程的 cgroups 的虚拟化视图,通过 /proc/[pid]/cgroup 和 /proc/[pid]/mountinfo 展示。

使用 cgroup namespace 需要内核开启 CONFIG_CGROUPS 选项。可通过以下方式验证:

(MoeLove) ➜ grep CONFIG_CGROUPS /boot/config-$(uname -r)
CONFIG_CGROUPS=y

cgroup namespace 提供的了一系列的隔离支持:

防止信息泄漏(容器不应该看到容器外的任何信息)。
简化了容器迁移。
限制容器进程资源,因为它会把 cgroup 文件系统进行挂载,使得容器进程无法获取上层的访问权限。

每个 cgroup namespace 都有自己的一组 cgroup 根目录。这些 cgroup 的根目录是在 /proc/[pid]/cgroup 文件中对应记录的相对位置的基点。当一个进程用 CLONE_NEWCGROUP(clone(2) 或者 unshare(2)) 创建一个新的 cgroup namespace时,它当前的 cgroups 的目录就变成了新 namespace 的 cgroup 根目录。

(MoeLove) ➜ cat /proc/self/cgroup 
0::/user.slice/user-1000.slice/session-2.scope

当一个目标进程从 /proc/[pid]/cgroup 中读取 cgroup 关系时,每个记录的路径名会在第三字段中展示,会关联到正在读取的进程的相关 cgroup 分层结构的根目录。如果目标进程的 cgroup 目录位于正在读取的进程的 cgroup namespace 根目录之外时,那么,路径名称将会对每个 cgroup 层次中的上层节点显示 ../ 。

我们来看看下面的示例(这里以 cgroup v1 为例,如果你想看 v2 版本的示例,请在留言中告诉我):

在初始的 cgroup namespace 中,我们使用 root (或者有 root 权限的用户),在 freezer 层下创建一个子 cgroup 名为 moelove-sub,同时,将进程放入该 cgroup 进行限制。

**(MoeLove) ➜ mkdir -p /sys/fs/cgroup/freezer/moelove-sub
(MoeLove) ➜ sleep 6666666 & 
[1] 1489125                  
(MoeLove) ➜ echo 1489125 > /sys/fs/cgroup/freezer/moelove-sub/cgroup.procs
**

我们在 freezer 层下创建另外一个子 cgroup,名为 moelove-sub2, 并且再放入执行进程号。可以看到当前的进程已经纳入到  moelove-sub2的 cgroup 下管理了。

(MoeLove) ➜ mkdir -p /sys/fs/cgroup/freezer/moelove-sub2
(MoeLove) ➜ echo $$
1488899
(MoeLove) ➜ echo 1488899 > /sys/fs/cgroup/freezer/moelove-sub2/cgroup.procs 
(MoeLove) ➜ cat /proc/self/cgroup |grep freezer
7:freezer:/moelove-sub2

我们使用 unshare(1) 创建一个进程,这里使用了 -C参数表示是新的 cgroup namespace, 使用了 -m参数表示是新的 mount namespace。

(MoeLove) ➜ unshare -Cm bash
root@moelove:~#

从用 unshare(1) 启动的新 shell 中,我们可以在 /proc/[pid]/cgroup 文件中看到,新 shell 和以上示例中的进程:

root@moelove:~# cat /proc/self/cgroup | grep freezer
7:freezer:/
root@moelove:~# cat /proc/1/cgroup | grep freezer
7:freezer:/..

# 第一个示例进程

root@moelove:~# cat /proc/1489125/cgroup | grep freezer
7:freezer:/../moelove-sub

从上面的示例中,我们可以看到新 shell 的 freezer cgroup 关系中,当新的 cgroup namespace 创建时,freezer cgroup 的根目录与它的关系也就建立了。

root@moelove:~# cat /proc/self/mountinfo | grep freezer
1238 1230 0:37 /.. /sys/fs/cgroup/freezer rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,freezer

第四个字段 ( /..) 显示了在 cgroup 文件系统中的挂载目录。从 cgroup namespaces 的定义中,我们可以知道,进程当前的 freezer cgroup 目录变成了它的根目录,所以这个字段显示 /.. 。我们可以重新挂载来处理它。

root@moelove:~# mount --make-rslave /
root@moelove:~# umount /sys/fs/cgroup/freezer
root@moelove:~# mount -t cgroup -o freezer freezer /sys/fs/cgroup/freezer
root@moelove:~# cat /proc/self/mountinfo | grep freezer
1238 1230 0:37 / /sys/fs/cgroup/freezer rw,relatime - cgroup freezer rw,freezer
root@moelove:~# mount |grep freezer
freezer on /sys/fs/cgroup/freezer type cgroup (rw,relatime,freezer)

IPC namespaces

IPC namespaces 隔离了 IPC 资源,如 System V IPC objects、  POSIX message queues。每个 IPC namespace 都有着自己的一组 System V IPC 标识符,以及 POSIX 消息队列系统。在一个 IPC namespace 中创建的对象,对所有该 namespace 下的成员均可见(对其他 namespace 下的成员均不可见)。

使用 IPC namespace 需要内核支持 CONFIG_IPC_NS 选项。如下:

(MoeLove) ➜ grep CONFIG_IPC_NS /boot/config-$(uname -r)
CONFIG_IPC_NS=y

可以在 IPC namespace 中设置以下 /proc 接口:

    /proc/sys/fs/mqueue - POSIX 消息队列接口
    /proc/sys/kernel - System V IPC 接口 (msgmax, msgmnb, msgmni, sem, shmall, shmmax, shmmni, shm_rmid_forced)
    /proc/sysvipc - System V IPC 接口

当 IPC namespace 被销毁时(空间里的最后一个进程都被停止删除时),在 IPC namespace 中创建的 object 也会被销毁。
Network namepaces

Network namespaces 隔离了与网络相关的系统资源(这里罗列一些):

    network devices - 网络设备
    IPv4 and IPv6 protocol stacks - IPv4、IPv6 的协议栈
    IP routing tables - IP 路由表
    firewall rules - 防火墙规则
    /proc/net (即 /proc/PID/net)
    /sys/class/net
    /proc/sys/net 目录下的文件
    端口、socket
    UNIX domain abstract socket namespace

使用 Network namespaces 需要内核支持 CONFIG_NET_NS 选项。如下:

(MoeLove) ➜ grep CONFIG_NET_NS /boot/config-$(uname -r)
CONFIG_NET_NS=y

一个物理网络设备只能存在于一个 Network namespace 中。当一个 Network namespace 被释放时(空间里的最后一个进程都被停止删除时),物理网络设备将被移动到初始的 Network namespace 而不是上层的 Network namespace。

一个虚拟的网络设备(veth(4)) ,在 Network namespace 间通过一个类似管道的方式进行连接。这使得它能存在于多个 Network namespace,但是,当 Network namespace 被摧毁时,该空间下包含的 veth(4) 设备可能被破坏。
Mount namespaces

Mount namespaces 最早出现在 Linux 2.4.19 版本。Mount namespaces 隔离了各空间中挂载的进程实例。每个 mount namespace 的实例下的进程会看到不同的目录层次结构。

每个进程在 mount namespace 中的描述可以在下面的文件视图中看到:

  /proc/[pid]/mounts
  /proc/[pid]/mountinfo
  /proc/[pid]/mountstats

一个新的 Mount namespace 的创建标识是 CLONE_NEWNS ,使用了 clone(2) 或者 unshare(2) 。

如果 Mount namespace 用 clone(2) 创建,子 namespace 的挂载列表是从父进程的 mount namespace 拷贝的。
如果 Mount namespace 用 unshare(2) 创建,新 namespace 的挂载列表是从调用者之前的 moun namespace 拷贝的。

如果 mount namespace 发生了修改,会引起什么样的连锁反应?下面,我们就在 共享子树中谈谈。

每个 mount 都被可以有如下标记 :

MS_SHARED - 与组内每个成员分享 events 。也就是说相同的 mount 或者 unmount 将自动发生在组内其他的 mounts  中。反之,mount 或者 unmount 事件 也会影响这次的 event 动作。
MS_PRIVATE - 这个 mount 是私有的。mount 或者 unmount events 都不会影响这次的 event 动作。
MS_SLAVE - mount 或者 unmount events 会从 master 节点传入影响该节点。但是这个节点下的 mount 或者 unmount events 不会影响组内的其他节点。
MS_UNBINDABLE - 这也是个私有的 mount 。任何尝试绑定的 mount 在这个设置下都将失败。

在文件 /proc/[pid]/mountinfo 中可以看到 propagation 类型的字段。每个对等组都会由内核生成唯一的 ID ,同一对等组的 mount 都是这个 ID(即,下文中的 X )。

(MoeLove) ➜ cat /proc/self/mountinfo  |grep root  
65 1 0:33 /root / rw,relatime shared:1 - btrfs /dev/nvme0n1p6 rw,seclabel,compress=zstd:1,ssd,space_cache,subvolid=256,subvol=/root
1210 65 0:33 /root/var/lib/docker/btrfs /var/lib/docker/btrfs rw,relatime shared:1 - btrfs /dev/nvme0n1p6 rw,seclabel,compress=zstd:1,ssd,space_cache,subvolid=256,subvol=/root

  shared:X - 在组 X 中共享。
  master:X - 对于组 X 而言是 slave,即,从属于 ID 为 X 的主。
  propagate_from:X - 接收从组 X 发出的共享 mount。这个标签总是个 master:X 一同出现。
  unbindable -  表示不能被绑定,即,不与其他关联从属。

新 mount namespace 的传播类型取决于它的父节点。如果父节点的传播类型是 MS_SHARED ,那么新 mount namespace 的传播类型是 MS_SHARED ,不然会默认为 MS_PRIVATE。

关于 mount namespaces 我们还需要注意以下几点:

(1)每个  mount namespace 都有一个 owner user namespace。如果新的 mount namespace 和拷贝的 mount namespace 分属于不同的 user namespace ,那么,新的 mount namespace 优先级低。

(2)当创建的 mount namespace 优先级低时,那么,slave 的 mount events 会优先于 shared 的 mount events。

(3)高优先级和低优先级的 mount namespace 有关联被锁定在一起时,他们都不能被单独卸载。

(4)mount(2) 标识和 atime 标识会被锁定,即,不能被传播影响而修改。
小结

以上就是关于 Linux 内核中 namespace 的一些介绍了,篇幅原因,剩余部分以及 namespace 在容器中的应用我们放在下一篇中介绍,敬请期待

#10 进程模块 » Gentoo 之 Control Group support » 2024-03-31 08:39:07

batsom
回复: 0

Linux资源管理之cgroups简介
引子

cgroups 是Linux内核提供的一种可以限制单个进程或者多个进程所使用资源的机制,可以对 cpu,内存等资源实现精细化的控制,目前越来越火的轻量级容器 Docker 就使用了 cgroups 提供的资源限制能力来完成cpu,内存等部分的资源控制。

另外,开发者也可以使用 cgroups 提供的精细化控制能力,限制某一个或者某一组进程的资源使用。比如在一个既部署了前端 web 服务,也部署了后端计算模块的八核服务器上,可以使用 cgroups 限制 web server 仅可以使用其中的六个核,把剩下的两个核留给后端计算模块。

本文从以下四个方面描述一下 cgroups 的原理及用法:

    cgroups 的概念及原理
    cgroups 文件系统概念及原理
    cgroups 使用方法介绍
    cgroups 实践中的例子

概念及原理
cgroups子系统

cgroups 的全称是control groups,cgroups为每种可以控制的资源定义了一个子系统。典型的子系统介绍如下:

    cpu 子系统,主要限制进程的 cpu 使用率。
    cpuacct 子系统,可以统计 cgroups 中的进程的 cpu 使用报告。
    cpuset 子系统,可以为 cgroups 中的进程分配单独的 cpu 节点或者内存节点。
    memory 子系统,可以限制进程的 memory 使用量。
    blkio 子系统,可以限制进程的块设备 io。
    devices 子系统,可以控制进程能够访问某些设备。
    net_cls 子系统,可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块(traffic control)对数据包进行控制。
    freezer 子系统,可以挂起或者恢复 cgroups 中的进程。
    ns 子系统,可以使不同 cgroups 下面的进程使用不同的 namespace。

这里面每一个子系统都需要与内核的其他模块配合来完成资源的控制,比如对 cpu 资源的限制是通过进程调度模块根据 cpu 子系统的配置来完成的;对内存资源的限制则是内存模块根据 memory 子系统的配置来完成的,而对网络数据包的控制则需要 Traffic Control 子系统来配合完成。本文不会讨论内核是如何使用每一个子系统来实现资源的限制,而是重点放在内核是如何把 cgroups 对资源进行限制的配置有效的组织起来的,和内核如何把cgroups 配置和进程进行关联的,以及内核是如何通过 cgroups 文件系统把cgroups的功能暴露给用户态的。
cgroups 层级结构(Hierarchy)

内核使用 cgroup 结构体来表示一个 control group 对某一个或者某几个 cgroups 子系统的资源限制。cgroup 结构体可以组织成一颗树的形式,每一棵cgroup 结构体组成的树称之为一个 cgroups 层级结构。cgroups层级结构可以 attach 一个或者几个 cgroups 子系统,当前层级结构可以对其 attach 的 cgroups 子系统进行资源的限制。每一个 cgroups 子系统只能被 attach 到一个 cpu 层级结构中。

FluxBB bbcode 测试

比如上图表示两个cgroups层级结构,每一个层级结构中是一颗树形结构,树的每一个节点是一个 cgroup 结构体(比如cpu_cgrp, memory_cgrp)。第一个 cgroups 层级结构 attach 了 cpu 子系统和 cpuacct 子系统, 当前 cgroups 层级结构中的 cgroup 结构体就可以对 cpu 的资源进行限制,并且对进程的 cpu 使用情况进行统计。 第二个 cgroups 层级结构 attach 了 memory 子系统,当前 cgroups 层级结构中的 cgroup 结构体就可以对 memory 的资源进行限制。

在每一个 cgroups 层级结构中,每一个节点(cgroup 结构体)可以设置对资源不同的限制权重。比如上图中 cgrp1 组中的进程可以使用60%的 cpu 时间片,而 cgrp2 组中的进程可以使用20%的 cpu 时间片。

####cgroups与进程

上面的小节提到了内核使用 cgroups 子系统对系统的资源进行限制,也提到了 cgroups 子系统需要 attach 到 cgroups 层级结构中来对进程进行资源控制。本小节重点关注一下内核是如何把进程与 cgroups 层级结构联系起来的。

在创建了 cgroups 层级结构中的节点(cgroup 结构体)之后,可以把进程加入到某一个节点的控制任务列表中,一个节点的控制列表中的所有进程都会受到当前节点的资源限制。同时某一个进程也可以被加入到不同的 cgroups 层级结构的节点中,因为不同的 cgroups 层级结构可以负责不同的系统资源。所以说进程和 cgroup 结构体是一个多对多的关系。

FluxBB bbcode 测试

上面这个图从整体结构上描述了进程与 cgroups 之间的关系。最下面的P代表一个进程。每一个进程的描述符中有一个指针指向了一个辅助数据结构css_set(cgroups subsystem set)。 指向某一个css_set的进程会被加入到当前css_set的进程链表中。一个进程只能隶属于一个css_set,一个css_set可以包含多个进程,隶属于同一css_set的进程受到同一个css_set所关联的资源限制。

上图中的”M×N Linkage”说明的是css_set通过辅助数据结构可以与 cgroups 节点进行多对多的关联。但是 cgroups 的实现不允许css_set同时关联同一个cgroups层级结构下多个节点。 这是因为 cgroups 对同一种资源不允许有多个限制配置。

一个css_set关联多个 cgroups 层级结构的节点时,表明需要对当前css_set下的进程进行多种资源的控制。而一个 cgroups 节点关联多个css_set时,表明多个css_set下的进程列表受到同一份资源的相同限制。
cgroups文件系统

Linux 使用了多种数据结构在内核中实现了 cgroups 的配置,关联了进程和 cgroups 节点,那么 Linux 又是如何让用户态的进程使用到 cgroups 的功能呢? Linux内核有一个很强大的模块叫 VFS (Virtual File System)。 VFS 能够把具体文件系统的细节隐藏起来,给用户态进程提供一个统一的文件系统 API 接口。 cgroups 也是通过 VFS 把功能暴露给用户态的,cgroups 与 VFS 之间的衔接部分称之为 cgroups 文件系统。下面先介绍一下 VFS 的基础知识,然后再介绍下 cgroups 文件系统的实现。
VFS

VFS 是一个内核抽象层,能够隐藏具体文件系统的实现细节,从而给用户态进程提供一套统一的 API 接口。VFS 使用了一种通用文件系统的设计,具体的文件系统只要实现了 VFS 的设计接口,就能够注册到 VFS 中,从而使内核可以读写这种文件系统。 这很像面向对象设计中的抽象类与子类之间的关系,抽象类负责对外接口的设计,子类负责具体的实现。其实,VFS本身就是用 c 语言实现的一套面向对象的接口。
通用文件模型

VFS 通用文件模型中包含以下四种元数据结构:

    超级块对象(superblock object),用于存放已经注册的文件系统的信息。比如ext2,ext3等这些基础的磁盘文件系统,还有用于读写socket的socket文件系统,以及当前的用于读写cgroups配置信息的 cgroups 文件系统等。

    索引节点对象(inode object),用于存放具体文件的信息。对于一般的磁盘文件系统而言,inode 节点中一般会存放文件在硬盘中的存储块等信息;对于socket文件系统,inode会存放socket的相关属性,而对于cgroups这样的特殊文件系统,inode会存放与 cgroup 节点相关的属性信息。这里面比较重要的一个部分是一个叫做 inode_operations 的结构体,这个结构体定义了在具体文件系统中创建文件,删除文件等的具体实现。

    文件对象(file object),一个文件对象表示进程内打开的一个文件,文件对象是存放在进程的文件描述符表里面的。同样这个文件中比较重要的部分是一个叫 file_operations 的结构体,这个结构体描述了具体的文件系统的读写实现。当进程在某一个文件描述符上调用读写操作时,实际调用的是 file_operations 中定义的方法。 对于普通的磁盘文件系统,file_operations 中定义的就是普通的块设备读写操作;对于socket文件系统,file_operations 中定义的就是 socket 对应的 send/recv 等操作;而对于cgroups这样的特殊文件系统,file_operations 中定义的就是操作 cgroup 结构体等具体的实现。

    目录项对象(dentry object),在每个文件系统中,内核在查找某一个路径中的文件时,会为内核路径上的每一个分量都生成一个目录项对象,通过目录项对象能够找到对应的 inode 对象,目录项对象一般会被缓存,从而提高内核查找速度。

#####cgroups文件系统的实现

基于 VFS 实现的文件系统,都必须实现 VFS 通用文件模型定义的这些对象,并实现这些对象中定义的部分函数。cgroup 文件系统也不例外,下面来看一下 cgroups 中这些对象的定义。

首先看一下 cgroups 文件系统类型的结构体:

static struct file_system_type cgroup_fs_type = {
        .name = "cgroup",
        .mount = cgroup_mount,
        .kill_sb = cgroup_kill_sb,
};

这里面两个函数分别代表安装和卸载某一个 cgroup 文件系统所需要执行的函数。每次把某一个 cgroups 子系统安装到某一个装载点的时候,cgroup_mount 方法就会被调用,这个方法会生成一个 cgroups_root(cgroups层级结构的根)并封装成超级快对象。

然后看一下 cgroups 超级块对象定义的操作:

static const struct super_operations cgroup_ops = {
        .statfs = simple_statfs,
        .drop_inode = generic_delete_inode,
        .show_options = cgroup_show_options,
        .remount_fs = cgroup_remount,
};

本文并不去研究这些函数的代码实现是什么样的,但是从这些代码可以推断出,cgroups 通过实现 VFS 的通用文件系统模型,把维护 cgroups 层级结构的细节,隐藏在 cgroups 文件系统的这些实现函数中。

从另一个方面说,用户在用户态对 cgroups 文件系统的操作,通过 VFS 转化为对 cgroups 层级结构的维护。通过这样的方式,内核把 cgroups 的功能暴露给了用户态的进程。
cgroups使用方法
cgroups文件系统挂载

Linux中,用户可以使用mount命令挂载 cgroups 文件系统,格式为: mount -t cgroup -o subsystems name /cgroup/name,其中 subsystems 表示需要挂载的 cgroups 子系统, /cgroup/name 表示挂载点,如上文所提,这条命令同时在内核中创建了一个cgroups 层级结构。

比如挂载 cpuset, cpu, cpuacct, memory 4个subsystem到/cgroup/cpu_and_mem 目录下,就可以使用 mount -t cgroup -o remount,cpu,cpuset,memory cpu_and_mem /cgroup/cpu_and_mem

在centos下面,在使用yum install libcgroup安装了cgroups模块之后,在 /etc/cgconfig.conf 文件中会自动生成 cgroups 子系统的挂载点:

mount {
    cpuset  = /cgroup/cpuset;
    cpu = /cgroup/cpu;
    cpuacct = /cgroup/cpuacct;
    memory  = /cgroup/memory;
    devices = /cgroup/devices;
    freezer = /cgroup/freezer;
    net_cls = /cgroup/net_cls;
    blkio   = /cgroup/blkio;
}

上面的每一条配置都等价于展开的 mount 命令,例如mount -t cgroup -o cpuset cpuset /cgroup/cpuset。这样系统启动之后会自动把这些子系统挂载到相应的挂载点上。

####子节点和进程

挂载某一个 cgroups 子系统到挂载点之后,就可以通过在挂载点下面建立文件夹或者使用cgcreate命令的方法创建 cgroups 层级结构中的节点。比如通过命令cgcreate -t sankuai:sankuai -g cpu:test就可以在 cpu 子系统下建立一个名为 test 的节点。结果如下所示:

[root@idx cpu]# ls
cgroup.event_control  cgroup.procs  cpu.cfs_period_us  cpu.cfs_quota_us  cpu.rt_period_us   cpu.rt_runtime_us  cpu.shares  cpu.stat  lxc  notify_on_release  release_agent  tasks  test

然后可以通过写入需要的值到 test 下面的不同文件,来配置需要限制的资源。每个子系统下面都可以进行多种不同的配置,需要配置的参数各不相同,详细的参数设置需要参考 cgroups 手册。使用 cgset 命令也可以设置 cgroups 子系统的参数,格式为 cgset -r parameter=value path_to_cgroup。

当需要删除某一个 cgroups 节点的时候,可以使用 cgdelete 命令,比如要删除上述的 test 节点,可以使用 cgdelete -r cpu:test命令进行删除

把进程加入到 cgroups 子节点也有多种方法,可以直接把 pid 写入到子节点下面的 task 文件中。也可以通过 cgclassify 添加进程,格式为 cgclassify -g subsystems:path_to_cgroup pidlist,也可以直接使用 cgexec 在某一个 cgroups 下启动进程,格式为gexec -g subsystems:path_to_cgroup command arguments.
实践中的例子

相信大多数人都没有读过 Docker 的源代码,但是通过这篇文章,可以估计 Docker 在实现不同的 Container 之间资源隔离和控制的时候,是可以创建比较复杂的 cgroups 节点和配置文件来完成的。然后对于同一个 Container 中的进程,可以把这些进程 PID 添加到同一组 cgroups 子节点中已达到对这些进程进行同样的资源限制。

通过各大互联网公司在网上的技术文章,也可以看到很多公司的云平台都是基于 cgroups 技术搭建的,其实也都是把进程分组,然后把整个进程组添加到同一组 cgroups 节点中,受到同样的资源限制。

笔者所在的广告组,有一部分任务是给合作的广告投放网站生成“商品信息”,广告投放网站使用这些信息,把广告投放在他们各自的网站上。但是有时候会有恶意的爬虫过来爬取商品信息,所以我们生成了另外“一小份”数据供优先级较低的用户下载,这时候基本能够区分开大部分恶意爬虫。对于这样的“一小份”数据,对及时更新的要求不高,生成商品信息又是一个比较费资源的任务,所以我们把这个任务的cpu资源使用率限制在了50%。

首先在 cpu 子系统下面创建了一个 halfapi 的子节点:cgcreate abc:abc -g cpu:halfapi。

然后在配置文件中写入配置数据:echo 50000 > /cgroup/cpu/halfapi/cpu.cfs_quota_us。cpu.cfs_quota_us中的默认值是100000,写入50000表示只能使用50%的 cpu 运行时间。

最后在这个cgroups中启动这个任务:cgexec -g "cpu:/halfapi" php halfapi.php half >/dev/null 2>&1

在 cgroups 引入内核之前,想要完成上述的对某一个进程的 cpu 使用率进行限制,只能通过 nice 命令调整进程的优先级,或者 cpulimit 命令限制进程使用进程的 cpu 使用率。但是这些命令的缺点是无法限制一个进程组的资源使用限制,也就无法完成 Docker 或者其他云平台所需要的这一类轻型容器的资源限制要求。

同样,在 cgroups 之前,想要完成对某一个或者某一组进程的物理内存使用率的限制,几乎是不可能完成的。使用 cgroups 提供的功能,可以轻易的限制系统内某一组服务的物理内存占用率。 对于网络包,设备访问或者io资源的控制,cgroups 同样提供了之前所无法完成的精细化控制。
结语

本文首先介绍了 cgroups 在内核中的实现方式,然后介绍了 cgroups 如何通过 VFS 把相关的功能暴露给用户,然后简单介绍了 cgroups 的使用方法,最后通过分析了几个 cgroups 在实践中的例子,进一步展示了 cgroups 的强大的精细化控制能力。

笔者希望通过整篇文章的介绍,读者能够了解到 cgroups 能够完成什么样的功能,并且希望读者在使用 cgroups 的功能的时候,能够大体知道内核通过一种什么样的方式来实现这种功能。
参考

1 cgroups 详解:http://files.cnblogs.com/files/lisperl/cgroups%E4%BB%8B%E7%BB%8D.pdf 2 how to use cgroup: http://tiewei.github.io/devops/howto-use-cgroup/ 3 Control groups, part 6: A look under the hood: http://lwn.net/Articles/606925/

#11 进程模块 » Gentoo 之 RCU Subsystem » 2024-03-29 22:09:27

batsom
回复: 0

RCU锁本质是用空间换时间,是对读写锁的一种优化加强,但不仅仅是这样简单,RCU体现出来的垃圾回收思想,也是值得我们学习和借鉴,各个语言C, C++,Java, go等标准库都有RCU锁实现,同时内核精巧的实现也是学习代码设计好素材,深入理解RCU分为两个部分,第一部分主要是讲核心原理,理解其核心设计思想,对RCU会有个宏观的理解;后续第二部分会分析源码实现,希望大家喜欢。

FluxBB bbcode 测试
并行程序设计演进

如何正确有效的保护共享数据是编写并行程序必须面临的一个难题,通常的手段就是同步。同步可分为阻塞型同步(Blocking Synchronization)和非阻塞型同步( Non-blocking Synchronization)。

阻塞型同步是指当一个线程到达临界区时,因另外一个线程已经持有访问该共享数据的锁,从而不能获取锁资源而阻塞(睡眠),直到另外一个线程释放锁。常见的同步原语有 mutex、semaphore 等。如果同步方案采用不当,就会造成死锁(deadlock),活锁(livelock)和优先级反转(priority inversion),以及效率低下等现象。

为了降低风险程度和提高程序运行效率,业界提出了不采用锁的同步方案,依照这种设计思路设计的算法称为非阻塞型同步,其本质就是停止一个线程的执行不会阻碍系统中其他执行实体的运行。
先有阻塞型同步

互斥锁(英語:Mutual exclusion,缩写Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。临界区域指的是一块对公共资源进行存取的代码。

信号量(Semaphore),是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用,可以认为mutex是0-1信号量;

读写锁是计算机程序的并发控制的一种同步机制,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作,读操作可并发重入,写操作是互斥的。
再有非阻塞型同步

当今比较流行的非阻塞型同步实现方案有三种:

    Wait-free(无等待)
    Wait-free 是指任意线程的任何操作都可以在有限步之内结束,而不用关心其它线程的执行速度。Wait-free 是基于 per-thread 的,可以认为是 starvation-free 的。非常遗憾的是实际情况并非如此,采用 Wait-free 的程序并不能保证 starvation-free,同时内存消耗也随线程数量而线性增长。目前只有极少数的非阻塞算法实现了这一点。
    简单理解:任意时刻所有的线程都在干活;
    Lock-free(无锁)
    Lock-Free是指能够确保执行它的所有线程中至少有一个能够继续往下执行。由于每个线程不是 starvation-free 的,即有些线程可能会被任意地延迟,然而在每一步都至少有一个线程能够往下执行,因此系统作为一个整体是在持续执行的,可以认为是 system-wide 的。所有 Wait-free 的算法都是 Lock-Free 的。
    简单理解:任意时刻至少一个线程在干活;
    Obstruction-free(无障碍)
    Obstruction-free 是指在任何时间点,一个孤立运行线程的每一个操作可以在有限步之内结束。只要没有竞争,线程就可以持续运行。一旦共享数据被修改,Obstruction-free 要求中止已经完成的部分操作,并进行回滚。所有 Lock-Free 的算法都是 Obstruction-free 的。
    简单理解:只要数据有修改,就会重新获取,并且把已经完成操作回滚重来;

综上所述,不难得出 Obstruction-free 是 Non-blocking synchronization 中性能最差的,而 Wait-free 性能是最好的,但实现难度也是最大的,因此 Lock-free 算法开始被重视,并广泛运用于各种程序设计中,这里主要介绍Lock_free算法。

lock-free(无锁)往往可以提供更好的性能和伸缩性保证,但实际上其优点不止于此。早期这些概念首先是在操作系统上应用的,因为一个不依赖于锁的算法,可以应用于各种场景下,而无需考虑各种错误,故障,失败等情形。比如死锁,中断,甚至CPU失效。
主流无锁技术

Atomic operation(原子操作),在单一、不间断的步骤中读取和更改数据的操作。需要处理器指令支持原子操作:

● test-and-set (TSR)

● compare-and-swap (CAS)

● load-link/store-conditional (ll/sc)

Spin Lock(自旋锁)是一种轻量级的同步方法,一种非阻塞锁。当 lock 操作被阻塞时,并不是把自己挂到一个等待队列,而是死循环 CPU 空转等待其他线程释放锁。

FluxBB bbcode 测试

Seqlock (顺序锁) 是Linux 2.6 内核中引入一种新型锁,它与 spin lock 读写锁非常相似,只是它为写者赋予了较高的优先级。也就是说,即使读者正在读的时候也允许写者继续运行,读者会检查数据是否有更新,如果数据有更新就会重试,因为 seqlock 对写者更有利,只要没有其他写者,写锁总能获取成功。

FluxBB bbcode 测试

RCU(Read-Copy Update),顾名思义就是读-拷贝修改,它是基于其原理命名的。对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针替换为新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的访问。

本文主要讲解RCU的核心原理。
历史背景

高性能并行程序中,数据一致性访问是一个非常重要的部分,一般都是采用锁机制(semaphore、spinlock、rwlock等)进行保护共享数据,根本的思想就是在访问临界资源时,首先访问一个全局的变量(锁),通过全局变量的状态来控制线程对临界资源的访问。但是,这种思想是需要硬件支持的,硬件需要配合实现全局变量(锁)的读-修改-写,现代CPU都会提供这样的原子化指令。

采用锁机制实现数据访问的一致性存在如下两个问题:

    效率问题。锁机制的实现需要对内存的原子化访问,这种访问操作会破坏流水线操作,降低了流水线效率,这是影响性能的一个因素。另外,在采用读写锁机制的情况下,写锁是排他锁,无法实现写锁与读锁的并发操作,在某些应用下会降低性能。
    扩展性问题。例如,当系统中CPU数量增多的时候,采用锁机制实现数据的同步访问效率偏低。并且随着CPU数量的增多,效率降低,由此可见锁机制实现的数据一致性访问扩展性差。

原始的RCU思想

在多线程场景下,经常我们需要并发访问一个数据结构,为了保证线程安全我们会考虑使用互斥设施来进行同步,更进一步我们会根据对这个数据结构的读写比例而选用读写锁进行优化。但是读写锁不是唯一的方式,我们可以借助于COW技术来做到写操作不需要加锁,也就是在读的时候正常读,写的时候,先加锁拷贝一份,然后进行写,写完就原子的更新回去,使用COW实现避免了频繁加读写锁本身的性能开销。
优缺点

由于 RCU 旨在最小化读取端开销,因此仅在以更高速率使用同步逻辑进行读取操作时才使用它。如果更新操作超过10%,性能反而会变差,所以应该选择另一种同步方式而不是RCU。

    好处
        几乎没有读取端开销。零等待,零开销
        没有死锁问题
        没有优先级倒置问题(优先级倒置和优先级继承)
        无限制延迟没有问题
        无内存泄漏风险问题
    缺点
        使用起来有点复杂
        对于写操作,它比其他同步技术稍慢
    适用场景

FluxBB bbcode 测试

核心原理
理论基础-QSBR算法

(Quiescent State-Based Reclamation)

这个算法的核心思想就是识别出线程的不活动(quiescent)状态,那么什么时候才算是不活动的状态呢?这个状态和临界区状态是相对的,线程离开临界区就是不活动的状态了。识别出不活动状态了,还需要把状态通知出去,让其他线程知道,这整个过程可以用下面的图来描述:

FluxBB bbcode 测试

上面有四个线程,线程1执行完更新操作后添加了释放内存的callback,此时线程2,3,4都读取的是之前的内容,等他们执行完成后分别回去调用onQuiescentState来表明自己已经不不活动了,等到最后一个线程调用onQuiescentState的时候就可以去调用注册的callback了。要实现上面这个过程其要点就是选择适合的位置执行onQuiescentState,还有就是如何知道谁是最后一个执行onQuiescentState的线程。

FluxBB bbcode 测试

批量回收,如果更新的次数比较多的话,但是每次只回调一个callback,释放一次内存就会导致内存释放跟不上回收的速度,为此需要进行批量回收,每次更新都会注册新的callback,当第一次所有的线程都进入不活动状态的时候就把当前的所有callback保存起来,等待下一次所有线程进入不活动的状态的时候就回调前一次所有的callback。
基本架构

Linux 内核RCU 参考QSBR算法设计一套无锁同步机制。

FluxBB bbcode 测试

    多个读者可以并发访问共享数据,而不需要加锁;
    写者更新共享数据时候,需要先copy副本,在副本上修改,最终,读者只访问原始数据,因此他们可以安全地访问数据,多个写者之间是需要用锁互斥访问的(比如用自旋锁);
    修改资源后,需要更新共享资源,让后面读者可以访问最新的数据;
    等旧资源上所有的读者都访问完毕后,就可以回收旧资源了;

RCU 模型

FluxBB bbcode 测试

    Removal:在写端临界区部分,读取(Read()),进行复制(Copy),并执行更改(Update)操作;
    Grace Period:这是一个等待期,以确保所有与执行删除的数据相关的reader访问完毕;
    Reclamation:回收旧数据;

三个重要概念

静止状态QS(Quiescent State): CPU发生了上下文切换称为经历一个quiescent state;

FluxBB bbcode 测试

宽限期GP(Grace Period): grace period就是所有CPU都经历一次quiescent state所需要的等待的时间,也即系统中所有的读者完成对共享临界区的访问;

FluxBB bbcode 测试

GP原理

读侧临界部分RCS(Read-Side Critical Section): 保护禁止其他CPU修改的代码区域,但允许多个CPU同时读;

FluxBB bbcode 测试

三个主要的角色

FluxBB bbcode 测试

读者reader:

    安全访问临界区资源;
    负责标识进出临界区;

写者updater:

    复制一份数据,然后更新数据;
    用新数据覆盖旧数据,然后进入grace period;

回收者reclaimer:

    等待在grace period之前的读者退出临界区;
    在宽限期结束后,负责回收旧资源;


三个重要机制
发布/订阅机制

    主要用于更新数据,即使在数据被同时修改时线程也能安全浏览数据。RCU通过发布-订阅机制(Publish-Subscribe Mechanism)实现这种并发的插入操作能力;

延迟回收机制:

    实现检查旧数据上所有RCU读者完成,用于安全删除旧数据;


多版本机制:

    维护最近更新对象的多个版本,用于允许读者容忍并发的插入和删除新对象的多个版本;

FluxBB bbcode 测试

最后总结

最后,总结一下RCU锁的核心思想:

    读者无锁访问数据,标记进出临界区;
    写者读取,复制,更新;
    旧数据延迟回收;

RCU核心思想就三句话,产品经理都说简单,但Linux内核实现却不是这么简单。除了要实现基本功能,需要考虑很多复杂情况:

FluxBB bbcode 测试

内核的RCU系统可以说是内核最复杂系统之一,为了高性能和多核扩展性,设计了非常精巧的数据结构:

FluxBB bbcode 测试

同时巧妙实现了很多核心流程:

    检查当前CPU是否度过QS;
    QS report(汇报宽限期度过);
    宽限期的发起与完成;
    rcu callbacks处理;


其中很多实现都可以说是非常精巧,结合了预处理,批量处理,延后(异步)处理,多核并发,原子操作,异常处理,多场景精细优化等多种技术,性能好,可扩展性强,稳定性强,有一定的学习和参考价值,即使你的工作不是内核编程,里面体现很多编程思想和代码设计思想,也是值得大家学习的。

#12 进程模块 » Gentoo 之 CPU 隔离 » 2024-03-28 22:50:23

batsom
回复: 0

SUSE Labs 团队探索了 Kernel CPU 隔离及其核心组件之一:Full Dynticks(或 Nohz Full),并撰写了本系列文章:

1. CPU 隔离 – 简介

2. CPU 隔离 – Full Dynticks 深探

3. CPU 隔离 – Nohz_full

4. CPU 隔离 – 管理和权衡

5. CPU 隔离 – 实践

本文是第三篇。
NOHZ_FULL


“nohz_full=” 内核引导参数是当前用于配置 full dynticks 和 CPU 隔离的主接口。

CPU 列表参数传给 nohz_full 的作用是定义一组要隔离的 CPU。例如,假设您有 8 个 CPU,希望隔离 CPU 4、5、6、7:


nohz_full=4-7


关于 cpu-list 参数格式请参考:https://www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html#cpu-lists。


nohz_full 的作用


当一个 CPU 包含在 nohz_full 引导参数的 CPU 列表中,内核会试图从那个 CPU 中排除尽可能多的内核干扰。本系列的第二篇文章已经从理论上解释了关闭计时器 Tick 的准备工作,这就是最终需要执行的操作:


定时器中断


满足以下条件时,定时器可以停止:


    在一个 CPU 上运行的任务无法被抢占。这意味着在使用以下策略时,您不能有一个以上的任务同时运行:SCHED_OTHER、SCHED_BATC 和 SCHED_IDLE(https://man7.org/linux/man-pages/man2/sched_setscheduler.2.html)。
    如果两个或多个任务都拥有最高优先级,则这一规则同样适用于 SCHED_RR (https://man7.org/linux/man-pages/man2/s … ler.2.html)。在隔离 CPU 上运行单个任务才更不容易出错。
    任务不使用 posix-cpu-timers(https://man7.org/linux/man-pages/man2/timer_create.2.html)。
    任务不使用 perf 事件(https://man7.org/linux/man-pages/man2/perf_event_open.2.html)。
    如果您在 x86 上运行,您的机器必须有一个可靠的时间戳计数器(TSC: https://www.suse.com/c/cpu-isolation-no … 我们稍后介绍这一点。


残余的 1 Hz Tick(每秒钟中断)仍然存在,目的是为了维护调度程序内部统计。它以前在隔离的 CPU 上执行,但现在,这个事件使用一个未绑定的工作队列被卸载到 nohz_full 范围之外的 CPU。这意味着一个干净的设置可以在 CPU 上 100%无 Tick 运行。


定时器回调


未绑定定时器回调执行被移动到 nohz_full 范围之外的任何 CPU,因此,它们不会在错误的地方触发定时器 Tick。与此同时,被固定的定时器 Tick 不能转移到其他地方。我们稍后会探讨如何处理。


工作队列和其他内核线程


与定时器回调类似,未绑定的内核工作队列和 kthread 被移动到 nohz_full 范围之外的任何 CPU。但是,被固定的工作队列和 kthread 不能移动到其他地方。我们稍后会探讨如何处理。


RCU


大部分 RCU 处理任务都被卸载到隔离范围外的 CPU 上。CPU 设置为 nohz_full 在 NOCB 模式下运行(https://lwn.net/Articles/522262/),这意味着在这些 CPU 上排队的 RCU 回调是在非隔离的 CPU 上运行的未绑定 kthreads 中执行。不需要传递“rcu_nocbs=” 内核参数,因为这在传递“nohz_full=” 参数时自动处理。

CPU 也不需要通过 Tick 来积极报告静止状态,因为它在返回到用户空间时进入RCU扩展静止状态。


Cputime 记账


将 CPU 切换到 full dynticks cputime 记账,这样它就不再依赖周期性事件。
其他隔离设置


尽管 nohz_full 是整个隔离设置的重要组成部分,但也需要考虑其他细节,其中重要的两项包括:

用户任务仿射


如果您想运行一个不被干扰的任务,一定不希望其他线程或进程与其共享 CPU。full dynticks 最终只在单个任务中运行,因此,需要:


    将每个隔离任务仿射到 nohz_full 范围内的一个 CPU。每个 CPU 必须只有一个隔离任务。
    将其他所有任务仿射到 nohz_full 范围之外。

有多种方式可以将您的任务仿射到一组 CPU 上,从底层系统调用 sched_setaffinity() (https://man7.org/linux/man-pages/man2/s … ity.2.html) ,到 taskset 等命令行工具(https://man7.org/linux/man-pages/man1/taskset.1.html)。另外也建议使用强大的 cgroup 接口,例如 cpusets (https://www.kernel.org/doc/html/latest/ … usets.html)。


IRQ 仿射


硬件 IRQ(除计时器和其他特定的中断之外)可能会在任何 CPU 上运行,并打乱您的隔离集。产生的干扰可能不仅仅是占用 CPU 时间和破坏 CPU 缓存的中断,IRQ 可能会在 CPU 上启动进一步的异步工作:softirq、计时器、工作队列等。因此,将 IRQ 仿射到 nohz_full 范围之外的 CPU 通常是一个好想法。这种仿射可以通过文件而取消:

/proc/irq/$IRQ/smp_affinity

$IRQ 是向量号,更多细节可见内核文档:https://www.kernel.org/doc/Documentation/IRQ-affinity.txt

#13 进程模块 » Gentoo 之 WALT 负载计算 » 2024-03-27 23:33:34

batsom
回复: 0

WALT(Window Assisted Load Tracking窗口辅助负载跟踪算法)的核心算法思想是:以时间窗口长度window为单位,跟踪CPU使用情况,用前面N个窗口的CPU使用情况预测当前窗口的CPU需求。窗口默认长度是20ms,walt_ravg_window = (20000000 / TICK_NSEC) * TICK_NSEC,进程负载计算默认是5个历史窗口(#define RAVG_HIST_SIZE_MAX  5),CPU负载计算只用一个历史窗口。要理解WALT算法需要理解几个概念:WALT时间、cpu_scale、freq_scale。

FluxBB bbcode 测试

        WALT时间是什么呢,是进程占用CPU时间吗?想象一下,有两个进程,在一个时间窗口内,A进程在2.6G频率上运行了5ms,B进程在小核500M频率上运行了5ms。如果仅考虑CPU占用时间,那么它们的负载是相同的,这显然与实际情况不符。所以WALT时间,不仅要考虑CPU占用时间,还要考虑所在CPU的运算能力、运行频率。函数scale_exec_time()用于计算WALT时间,参数delta是CPU运行时间,rq是进程所在运行队列(与CPU对应),SCHED_CAPACITY_SHIFT是10。

static u64 scale_exec_time(u64 delta, struct rq *rq)
{
	unsigned long capcurr = capacity_curr_of(cpu_of(rq));
 
	return (delta * capcurr) >> SCHED_CAPACITY_SHIFT;
}
 
unsigned long capacity_curr_of(int cpu)
{
	return cpu_rq(cpu)->cpu_capacity_orig *
	       arch_scale_freq_capacity(NULL, cpu)
	       >> SCHED_CAPACITY_SHIFT;
} 

capacity_curr_of()中cpu_capacity_orig的值最终来源于per_cpu变量cpu_scale。

#define arch_scale_cpu_capacity scale_cpu_capacity
static void update_cpu_capacity(struct sched_domain *sd, int cpu)
{
	unsigned long capacity = arch_scale_cpu_capacity(sd, cpu);
	struct sched_group *sdg = sd->groups;
	struct max_cpu_capacity *mcc;
	unsigned long max_capacity;
	int max_cap_cpu;
	unsigned long flags;
 
	cpu_rq(cpu)->cpu_capacity_orig = capacity;
    ...............................................................
}
unsigned long scale_cpu_capacity(struct sched_domain *sd, int cpu)
{
	return per_cpu(cpu_scale, cpu);
}

capacity_curr_of()中arch_scale_freq_capacity()最终是获取的per_cpu变量freq_scale。

#define arch_scale_freq_capacity cpufreq_scale_freq_capacity
unsigned long cpufreq_scale_freq_capacity(struct sched_domain *sd, int cpu)
{
	return per_cpu(freq_scale, cpu);
}

        上面计算WALT时间的代码,可以简化为如下的运算公式:

FluxBB bbcode 测试

         cpu_scale是当前CPU运算能力尺度化。市面上有各种各样的CPU,它们的运算能力各不相同,同一系列的CPU也会迭代升级运算能力,如果以CPU实际运算能力为参数,很难做到算法的统一。内核用CPU运算能力尺度化来解决该问题,定义系统中最高运算能力核的cpu_scale为1024,其它核的cpu_scale为该(CPU运算能力/最大核运算能力)*1024。CPU各个核的cpu_scale都由SOC厂商给出,MTK平台可以cat各cpu目录下cpu_capacity节点查看cpu_scale。

FluxBB bbcode 测试

FluxBB bbcode 测试

        freq_scale是某CPU当前频率运算能力的瓷都化,系统定义当前核最高频率运行时的freq_scale为1024,其它频率的freq_scale为当前频率运算能力/最高频率运算能力*1024。为什么用频率的运算能力比,而不是频率比呢?是因为有些厂商的CPU是CONFIG_NONLINER_FREQ_CTL类型的,它的运算能力与频率不是正比关系。例如MTK的某些SOC就是这样的芯片,它的运算能力与频率不成正比,只能查表来看某频点运算能力。

FluxBB bbcode 测试

综上,我们可以把WALT时间的运算公式做如下表示:
FluxBB bbcode 测试

        从上面的公式可以看出,进程只有在最大核的最高频点上运行时,其CPU占用时间才会等于WALT时间,其它情况WALT时间都是CPU占用时间按一定比例缩短的结果。

1.进程负载计算

        进程的task_struct中有一个ravg类型的变量用于保存WALT相关的信息。其中mark_start是上一次统计的时间,sum指进程在当前窗口已经运行的WALT时间,数组sum_history[RAVG_HIST_SIZE_MAX]保存进程在前5个历史窗口期内的WALT时间,demand是由sum_history[]历史数据推算出的值。

struct ravg {
	u64 mark_start;
	u32 sum, demand;
	u32 sum_history[RAVG_HIST_SIZE_MAX];
	u32 curr_window, prev_window;
	u16 active_windows;
};

         在进程切换等情况下,会统计WALT时间,有三种情况。情况一,如果当前时间(wallclock)与上次统计时间(mark_start)在一个时间窗口内,只需要将wallclock - mark_start转换为WALT时间后累加到ravg.sum即可。情况二,如果当前时间(wallclock)与上次统计时间(mark_start)跨了一个窗口,首先计算mark_start到当前窗口起始位置这部分WALT时间,并累加到ravg.sum后调用update_history(),将ravg.sum存入历史窗口。然后计算当前窗口起始时间到现在(wallclock)的这部分WALT时间,并赋值到ravg.sum。情况三,如果当前时间(wallclock)与上次统计时间(mark_start)跨了多个窗口,首先计算mark_start到它下一个窗口起始位置这部分WALT时间,并累加到ravg.sum后调用update_history(),将ravg.sum存入历史窗口。然后计算中间跨过窗口的WALT时间并更新到历史窗口中,最后计算当前窗口起始时间到现在(wallclock)的这部分WALT时间,并赋值到ravg.sum。

FluxBB bbcode 测试

//进程切换时更新负载
static void __sched notrace __schedule(bool preempt)
{
	struct task_struct *prev, *next;
	unsigned long *switch_count;
	struct pin_cookie cookie;
	struct rq *rq;
	int cpu;
	u64 wallclock;
    ................................................................
	next = pick_next_task(rq, prev, cookie);
	wallclock = walt_ktime_clock();
	walt_update_task_ravg(prev, rq, PUT_PREV_TASK, wallclock, 0);
	walt_update_task_ravg(next, rq, PICK_NEXT_TASK, wallclock, 0);
	clear_tsk_need_resched(prev);
	clear_preempt_need_resched();
    ................................................................
}
 
void walt_update_task_ravg(struct task_struct *p, struct rq *rq,
	     int event, u64 wallclock, u64 irqtime)
{
	if (walt_disabled || !rq->window_start)
		return;
 
	lockdep_assert_held(&rq->lock);
    //更新窗口起始位置
	update_window_start(rq, wallclock);
 
	if (!p->ravg.mark_start)
		goto done;
    //更新进程负载
	update_task_demand(p, rq, event, wallclock);
	update_cpu_busy_time(p, rq, event, wallclock, irqtime);
 
done:
	trace_walt_update_task_ravg(p, rq, event, wallclock, irqtime);
 
	p->ravg.mark_start = wallclock;
}
 
//更新窗口起始位置
static void update_window_start(struct rq *rq, u64 wallclock)
{
	s64 delta;
	int nr_windows;
 
	delta = wallclock - rq->window_start;
    ..............................................................
	if (delta < walt_ravg_window)
		return;
 
	nr_windows = div64_u64(delta, walt_ravg_window);
	rq->window_start += (u64)nr_windows * (u64)walt_ravg_window;
 
	rq->cum_window_demand = rq->cumulative_runnable_avg;
}
 
static void update_task_demand(struct task_struct *p, struct rq *rq,
	     int event, u64 wallclock)
{
	u64 mark_start = p->ravg.mark_start;
	u64 delta, window_start = rq->window_start;
	int new_window, nr_full_windows;
	u32 window_size = walt_ravg_window;
 
	new_window = mark_start < window_start;
    ................................................................
	//情况一
	if (!new_window) {
		/* The simple case - busy time contained within the existing
		 * window. */
		add_to_task_demand(rq, p, wallclock - mark_start);
		return;
	}
    //情况二、三代码相同,只是情况二nr_full_windows为0
	delta = window_start - mark_start;
	nr_full_windows = div64_u64(delta, window_size);
	window_start -= (u64)nr_full_windows * (u64)window_size;
    //计算mark_start到它下一个窗口起始位置之间的WALT时间
	add_to_task_demand(rq, p, window_start - mark_start);
 
	/* Push new sample(s) into task's demand history */
	update_history(rq, p, p->ravg.sum, 1, event);
	//计算中间跨越窗口的WALT时间
	if (nr_full_windows)
		update_history(rq, p, scale_exec_time(window_size, rq),
			       nr_full_windows, event);
 
	/* Roll window_start back to current to process any remainder
	 * in current window. */
	window_start += (u64)nr_full_windows * (u64)window_size;
 
	/* 计算当前窗口起始时间到现在之间的WALT时间 */
	mark_start = window_start;
	add_to_task_demand(rq, p, wallclock - mark_start);
}

         demand值是在update_history()中更新的,有四种策略可选:WINDOW_STATS_RECENT,用本次更新进来的值;WINDOW_STATS_MAX,用所有历史记录中的最大值;WINDOW_STATS_AVG,用历史记录中的平均值;WINDOW_STATS_MAX_RECENT_AVG,本次更新进来的值与历史平均值中较大的那个。

#define WINDOW_STATS_RECENT		0
#define WINDOW_STATS_MAX		1
#define WINDOW_STATS_MAX_RECENT_AVG	2
#define WINDOW_STATS_AVG		3
#define WINDOW_STATS_INVALID_POLICY	4
 
static void update_history(struct rq *rq, struct task_struct *p,
			 u32 runtime, int samples, int event)
{
	u32 *hist = &p->ravg.sum_history[0];
	int ridx, widx;
	u32 max = 0, avg, demand;
	u64 sum = 0;
 
	/* Ignore windows where task had no activity */
	if (!runtime || is_idle_task(p) || exiting_task(p) || !samples)
			goto done;
 
	/* Push new 'runtime' value onto stack */
	widx = walt_ravg_hist_size - 1;
	ridx = widx - samples;
	for (; ridx >= 0; --widx, --ridx) {
		hist[widx] = hist[ridx];
		sum += hist[widx];
		if (hist[widx] > max)
			max = hist[widx];
	}
 
	for (widx = 0; widx < samples && widx < walt_ravg_hist_size; widx++) {
		hist[widx] = runtime;
		sum += hist[widx];
		if (hist[widx] > max)
			max = hist[widx];
	}
 
	p->ravg.sum = 0;
 
	if (walt_window_stats_policy == WINDOW_STATS_RECENT) {
		demand = runtime;
	} else if (walt_window_stats_policy == WINDOW_STATS_MAX) {
		demand = max;
	} else {
		avg = div64_u64(sum, walt_ravg_hist_size);
		if (walt_window_stats_policy == WINDOW_STATS_AVG)
			demand = avg;
		else
			demand = max(avg, runtime);
	}
    ....................................................................
	if (!task_has_dl_policy(p) || !p->dl.dl_throttled) {
		if (task_on_rq_queued(p))
			fixup_cumulative_runnable_avg(rq, p, demand);
		else if (rq->curr == p)
			fixup_cum_window_demand(rq, demand);
	}
 
	p->ravg.demand = demand;
    ....................................................................
}

        进程负载的计算公式如下,可以看出1024就是满负载时的值,进程满负载必须满足:在整个时间窗口内都处于运行状态,并且所在核是大核,运行频率是大核最高频率。

FluxBB bbcode 测试

static inline unsigned long task_util(struct task_struct *p)
{
#ifdef CONFIG_SCHED_WALT
	if (!walt_disabled && (sysctl_sched_use_walt_task_util ||
				p->prio < sched_use_walt_nice)) {
		unsigned long demand = p->ravg.demand;
		return (demand << SCHED_CAPACITY_SHIFT) / walt_ravg_window;
	}
#endif
	return p->se.avg.util_avg;
}

2.CPU负载计算

        CPU负载计算是在update_cpu_busy_time()中完成的,计算方法与进程负载类似。不同的是,CPU负载计算只用了一个历史窗口,就是运行队列中的prev_runnable_sum。

static inline unsigned long cpu_util_freq(int cpu)
{
	unsigned long util = cpu_rq(cpu)->cfs.avg.util_avg;
	unsigned long capacity = capacity_orig_of(cpu);
 
#ifdef CONFIG_SCHED_WALT
	if (!walt_disabled && sysctl_sched_use_walt_cpu_util)
		util = div64_u64(cpu_rq(cpu)->prev_runnable_sum,
				walt_ravg_window >> SCHED_CAPACITY_SHIFT);
#endif
	return (util >= capacity) ? capacity : util;
}

#14 进程模块 » Gentoo 之 WALT负载计算源码分析 » 2024-03-27 22:16:17

batsom
回复: 0

一、WALT简介

    WALT(Windows-Assist Load Tracing),从字面意思来看,是以window作为辅助项来跟踪cpu load,用来表现cpu当前的loading情况,用于后续任务调度、迁移、负载均衡等功能。在 load 的基础上,添加对于demand的记录用于之后的预测。只统计runable和running time。
    WALT由Qcom研发,主要用于移动设备对性能功耗要求比较高的场景,在与用户交互时需要尽快响应,要能及时反应负载的增加和减少以驱动频点及时的变化。当前的PELT负载跟踪算法更主要的是体现负载的连续性,对于突变性质的负载的反应不是很友好,负载上升慢,下降也慢。
    打开 CONFIG_SCHED_WALT 使能此feature。
    辅助计算项 window 的划分方法是将系统自启动开始以一定时间作为一个周期,分别统计不同周期内 Task 的 Loading 情况,并将其更新到Runqueue中;目前 Kernel 中是设置的一个 window 的大小是20ms,统计 5 个window内的Loading情况,当然,这也可以根据具体的项目需求进行配置。

二、相关数据结构

(1) 嵌入在 task_struct 中的 walt_task_struct

/*
 * 'mark_start' 标记窗口内事件的开始(任务唤醒、任务开始执行、任务被抢占)
 * 'sum' 表示任务在当前窗口内的可运行程度。它包含运行时间和等待时间,并按频率进行缩放。//就是在当前窗口的运行时间吧
 * 'sum_history' 跟踪在之前的 RAVG_HIST_SIZE 窗口中看到的 'sum' 的历史记录。任务完全休眠的窗口将被忽略。
 * 'demand' 表示在以前的 sysctl_sched_ravg_hist_size 窗口中看到的最大总和(根据window_policy选的)。 'demand'可以为任务驱动频率的改变。#######
 * 'curr_window_cpu' 代表任务对当前窗口各CPU上cpu繁忙时间的贡献
 * 'prev_window_cpu' 表示任务对前一个窗口中各个 CPU 上的 cpu 繁忙时间的贡献
 * 'curr_window' 表示 curr_window_cpu 中所有条目的总和
 * 'prev_window' 代表 prev_window_cpu 中所有条目的总和
 * 'pred_demand' 代表任务当前预测的cpu繁忙时间
 * 'busy_buckets' 将历史繁忙时间分组到用于预测的不同桶中
 * 'demand_scaled' 表示任务的需求缩放到 1024 //就是上面demand成员缩放到1024
 */
struct walt_task_struct {
    u64        mark_start;
    u32        sum, demand; //sum在 add_to_task_demand 中更新
    u32        coloc_demand; //存的是5个历史窗口的平均值
    u32        sum_history[RAVG_HIST_SIZE_MAX]; 
    u32        *curr_window_cpu, *prev_window_cpu; //这个是per-cpu的
    u32        curr_window, prev_window;
    u32        pred_demand;
    u8        busy_buckets[NUM_BUSY_BUCKETS]; //10个
    u16        demand_scaled;
    u16        pred_demand_scaled;
    u64        active_time; //is_new_task中判断此值是小于100ms就认为是新任务,rollover_task_window是唯一更新位置
    u32        unfilter; //update_history中对其进行赋值,colocate中选核时,是否需要跳过小核判断了它
    u64        cpu_cycles;
    ...
} 

(2) 嵌入在 rq 中的 walt_rq

struct walt_rq {
    ...
    struct walt_sched_stats walt_stats;
    u64            window_start;
    u32            prev_window_size;
    u64            task_exec_scale; //walt_sched_init_rq中初始化为1024
    u64            curr_runnable_sum;
    u64            prev_runnable_sum;
    u64            nt_curr_runnable_sum;
    u64            nt_prev_runnable_sum; //nt 应该是walt认为的new task的意思
    u64            cum_window_demand_scaled;
    struct group_cpu_time    grp_time;
    /*
     * #define DECLARE_BITMAP_ARRAY(name, nr, bits) unsigned long name[nr][BITS_TO_LONGS(bits)]
     * unsigned long top_tasks_bitmap[2][BITS_TO_LONGS(1000)]; //只跟踪curr和prev两个窗口的情况。
     */
    DECLARE_BITMAP_ARRAY(top_tasks_bitmap, NUM_TRACKED_WINDOWS, NUM_LOAD_INDICES);
    u8            *top_tasks[NUM_TRACKED_WINDOWS]; //2 指针数组
    u8            curr_table; //只使用两个window进行跟踪,标识哪个是curr的,curr和prev构成一个环形数组,不停翻转
    int            prev_top; //应该是rq->wrq.top_tasks[]中前一个窗最大值的下标
    int            curr_top; //是rq->wrq.top_tasks[]中当前窗最大值的下标
    u64            cycles;
    ...
};

struct walt_sched_stats {
    int nr_big_tasks;
    u64 cumulative_runnable_avg_scaled; //只统计runnable任务的,在update_window_start中赋值给rq->wrq.cum_window_demand_scaled
    u64 pred_demands_sum_scaled;
    unsigned int nr_rtg_high_prio_tasks;
}; 

三、负载计算函数

1. walt算法负载计算入口函数

 /* event 取 TASK_UPDATE 等,由于每个tick中断中都会调度,一般两次执行统计的 wc-ms 一般不会超过4ms */
void walt_update_task_ravg(struct task_struct *p, struct rq *rq, int event, u64 wallclock, u64 irqtime) //walt.c
{
    u64 old_window_start;

    /* 还没初始化或时间没更新,直接返回 */
    if (!rq->wrq.window_start || p->wts.mark_start == wallclock)
        return;

    lockdep_assert_held(&rq->lock);

    /* 更新ws,返回最新的ws */
    old_window_start = update_window_start(rq, wallclock, event);

    /* 对应还没初始化的情况, ws是per-rq的,ms是per-task的,wc是全局的 */
    if (!p->wts.mark_start) {
        update_task_cpu_cycles(p, cpu_of(rq), wallclock);
        goto done;
    }
    /*更新 rq->wrq.task_exec_scale 和 p->wts.cpu_cycles = cur_cycles; */
    update_task_rq_cpu_cycles(p, rq, event, wallclock, irqtime);

    /*更新任务的负载和历史记录,返回 wc-ms 的差值,也就是距离上次统计任务运行的时间值 */
    update_task_demand(p, rq, event, wallclock);

    /*更新任务和rq的window相关统计信息,记录per-rq的prev和curr两个窗口内任务负载分布情况 */
    update_cpu_busy_time(p, rq, event, wallclock, irqtime);

    /*更新预测需求*/
    update_task_pred_demand(rq, p, event);

    if (event == PUT_PREV_TASK && p->state) {
        p->wts.iowaited = p->in_iowait;
    }

    trace_sched_update_task_ravg(p, rq, event, wallclock, irqtime, &rq->wrq.grp_time);

    trace_sched_update_task_ravg_mini(p, rq, event, wallclock, irqtime, &rq->wrq.grp_time);

done:
    /* 更新per-task的 ms,ms是在动态变化的 */
    p->wts.mark_start = wallclock;

    /*构成一个内核线程,每个窗口执行一次*/
    run_walt_irq_work(old_window_start, rq);
}

此函数中的两个trace解析:
(1) trace_sched_update_task_ravg(p, rq, event, wallclock, irqtime, &rq->wrq.grp_time);

参数原型:
(struct task_struct *p, struct rq *rq, enum task_event evt, u64 wallclock, u64 irqtime, struct group_cpu_time *cpu_time)

打印内容:

<idle>-0     [004] d..2 50167.767150: sched_update_task_ravg: wc 50167994699141 ws 50167988000001 delta 6699140
event PICK_NEXT_TASK cpu 4 cur_freq 434 cur_pid 0 task 17043 (kworker/u16:5) ms 50167994687631 delta 11510 
demand 3340045 coloc_demand: 1315008 sum 1235016 irqtime 0 pred_demand 3340045 rq_cs 1112353 rq_ps 4085339 
cur_window 930130 (0 136431 573963 0 219736 0 0 0 ) prev_window 2138941 (222138 156646 973556 0 219811 566790 0 0 ) 
nt_cs 2513 nt_ps 20395 active_time 100000000 grp_cs 0 grp_ps 1691783, grp_nt_cs 0, grp_nt_ps: 0 curr_top 58 prev_top 13 

字段解析:
wc:为参数4 wallclock;
ws: 为 window_start,取自 rq->wrq.window_start;
delta:取自 wallclock - rq->wrq.window_start 的差值。
event:task_event_names[参数3], 字符串表示的事件类型
cpu:取自 rq->cpu
cur_freq:取自 rq->wrq.task_exec_scale,update_task_rq_cpu_cycles()中,若不使用 use_cycle_counter,赋值为 cpu_capaticy * (freq / maxfreq)
cur_pid: 取自 rq->curr->pid
task:取自参数1 p 的 p->pid
kworker/u16:5:取自参数1 p 的 p->comm
ms:是 mark_start 取自 p->wts.mark_start
delta:打印中有两个同名的delta,这是第二个,取自 wallclock - p->wts.mark_start
demand:取自 p->wts.demand,单位是ns,就是根据 p->wts.sum 取平均或和最近窗口两者之间的最大值
coloc_demand:取自 p->wts.coloc_demand
sum:取自 p->wts.sum,表示最近一个窗口运行时间之和,单位ns,在将其更新到history数组后,清0.
irqtime:取自参数4
pred_demand:取自 p->wts.pred_demand
rq_cs:取自 rq->wrq.curr_runnable_sum 表示
rq_ps:取自 rq->wrq.prev_runnable_sum 表示
cur_window:取自 p->wts.curr_window,表示任务在当前窗口中所有cpu上的运行时间之和,是后面数组的累加。
(0 136431 573963 0 219736 0 0 0 ):取自 p->wts.curr_window_cpu per-cpu的,表示任务在当前窗口中在每个cpu上运行的时间
prev_window:取自 p->wts.prev_window
(222138 156646 973556 0 219811 566790 0 0 ):取自 p->wts.prev_window_cpu 也是per-cpu的,表示任务在前一个窗口中在每个cpu上运行的时间
nt_cs:取自 rq->wrq.nt_curr_runnable_sum nt应该表示的是new task的缩写
nt_ps:取自 rq->wrq.nt_prev_runnable_sum
active_time:取自 p->wts.active_time is_new_task()中判断它,唯一更新位置rollover_task_window()中调用is_new_task()判断是新任务时 p->wts.active_time += task_rq(p)->wrq.prev_window_size;
grp_cs:取自 cpu_time ? cpu_time->curr_runnable_sum : 0 根据最后一个参数来判断是更新rq的还是更新rtg group的
grp_ps:取自 cpu_time ? cpu_time->prev_runnable_sum : 0
grp_nt_cs:取自 cpu_time ? cpu_time->nt_curr_runnable_sum : 0
grp_nt_ps:取自 cpu_time ? cpu_time->nt_prev_runnable_sum : 0
curr_top:取自 rq->wrq.curr_top 记录的是当前窗口中 rq->wrq.top_tasks[]中最大值的下标
prev_top:取自 rq->wrq.prev_top 记录的是前一个窗口中 rq->wrq.top_tasks[]中最大值的下标

(2) trace_sched_update_task_ravg_mini(p, rq, event, wallclock, irqtime, &rq->wrq.grp_time);

参数原型:
(struct task_struct *p, struct rq *rq, enum task_event evt, u64 wallclock, u64 irqtime, struct group_cpu_time *cpu_time)

打印内容:

<idle>-0     [005] d..2 280546.887141: sched_update_task_ravg_mini: wc 112233604355205 ws 112233596000001 delta 8355204
event PUT_PREV_TASK cpu 5 task 0 (swapper/5) ms 112233604337548 delta 17657 demand 2400000 rq_cs 1374618 rq_ps 1237818
cur_window 0 prev_window 0 grp_cs 0 grp_ps 0

字段解析:
wc:取自参数 wallclock
ws:取自 rq->wrq.window_start
delta:取自 wallclock - rq->wrq.window_start
event:取自 task_event_names[evt]
cpu:取自 rq->cpu
task:取自 p->pid
swapper/5:取自 p->comm
ms:取自 p->wts.mark_start
delta:两个同名,这是第二个,取自 wallclock - p->wts.mark_start
demand:取自 p->wts.demand
rq_cs:取自 rq->wrq.curr_runnable_sum
rq_ps:取自 rq->wrq.prev_runnable_sum
cur_window:取自 p->wts.curr_window
prev_window:取自 p->wts.prev_window
grp_cs:取自 cpu_time ? cpu_time->curr_runnable_sum : 0
grp_ps:取自 cpu_time ? cpu_time->prev_runnable_sum : 0

2. walt_update_task_ravg 的调用路径

tick_setup_sched_timer //tick_sched.c timer到期回调函数中指定 tick_sched_timer
        update_process_times //time.c tick中断中调用
            scheduler_tick //core.c 周期定时器中断,传参(rq->curr, rq, TASK_UPDATE, wallclock, 0)
        //任务显式阻塞或设置 TIF_NEED_RESCHED 并且在中断或返回用户空间调度点或preempt_enable()
            __schedule //core.c 在这个主调度器函数中调用了三次,若选出的prev != next,调用两次,分别传参(prev, rq, PUT_PREV_TASK, wallclock, 0)和(next, rq, PICK_NEXT_TASK, wallclock, 0),若选出的prev == next,传参(prev, rq, TASK_UPDATE, wallclock, 0)
__irq_enter //hardirq.h __handle_domain_irq()中调用,中断入口:handle_arch_irq=gic_handle_irq-->handle_domain_irq
__do_softirq //softirq.c
    account_irq_enter_time //vtime.h
    account_irq_exit_time //vtime.h
        irqtime_account_irq //cputime.c 若curr是idle task,并且是在硬中断或软中断上下文则调用,否则调用walt_sched_account_irqstart
            walt_sched_account_irqend //walt.c,传参(curr, rq, IRQ_UPDATE, wallclock, delta);
    move_queued_task
    __migrate_swap_task
    try_to_wake_up //core.c 当新选出的cpu和任务之前运行的不是同一个cpu调用
    dl_task_offline_migration
    push_dl_task
    pull_dl_task
    detach_task
    push_rt_task
    pull_rt_task
        set_task_cpu //core.c 若新选出的cpu和任务之前的cpu不是同一个cpu,对任务进行迁移,然后调用,此时task->on_rq = TASK_ON_RQ_MIGRATING
            fixup_busy_time //walt.c 连续调用三次,分别传参 (task_rq(p)->curr, task_rq(p), TASK_UPDATE, wallclock, 0)和(dest_rq->curr, dest_rq, TASK_UPDATE, wallclock, 0)和(p, task_rq(p), TASK_MIGRATE, wallclock, 0)
cpufreq_freq_transition_end //cpufreq.c set_cpu_freq()中在设置频点前调用cpufreq_freq_transition_begin,设置后调用这个函数
    cpufreq_notify_post_transition //cpufreq.c 相同参数调用两次      
        notifier_trans_block.notifier_call //回调,对应val=CPUFREQ_POSTCHANGE时通知
            cpufreq_notifier_trans //walt.c 两层循环,对freq_domain_cpumask中的每一个cpu,对cluster中的每一个cpu,都调用,传参(rq->curr, rq, TASK_UPDATE, wallclock, 0)
sync_cgroup_colocation //walt.c cpu_cgrp_subsys.attach=cpu_cgroup_attach-->walt_schedgp_attach中对每一个cpuset都调用
sched_group_id_write //qc_vas.c 对应/proc/<pid>/sched_group_id
    __sched_set_group_id //传参group_id=0才调用
        remove_task_from_group //walt.c 传参(rq, p->wts.grp, p, REM_TASK)
    __sched_set_group_id //传参group_id非0才调用
        add_task_to_group //walt.c 传参(rq, grp, p, ADD_TASK)
            transfer_busy_time //walt.c 连续调用两次,分别传参(rq->curr, rq, TASK_UPDATE, wallclock, 0)和(p, rq, TASK_UPDATE, wallclock, 0)
    fixup_busy_time    //当task的cpu和参数cpu不是同一个时调用
    walt_proc_user_hint_handler //walt.c /proc/sys/kernel/sched_user_hint作用load = load * (sched_user_hint / 100) 维持1s后清0
        walt_migration_irq_work.func //walt.c irq_work 结构的回调
walt_update_task_ravg //又回来了,work的响应函数中queue work,构成一个"内核线程不"停执行
    run_walt_irq_work //walt.c 若新的window_start和旧的不是同一个就调用
        walt_cpufreq_irq_work.func //walt.c irq_work 结构的回调
            walt_irq_work //walt.c 对每个cluster的每个cpu都调用,传参(rq->curr, rq, TASK_UPDATE, wallclock, 0)
    wake_up_q
    wake_up_process
    wake_up_state
    default_wake_function
        try_to_wake_up
            walt_try_to_wake_up //walt.h 连续调用两次,分别传参(rq->curr, rq, TASK_UPDATE, wallclock, 0)和(p, rq, TASK_WAKE, wallclock, 0)
                walt_update_task_ravg

walt_update_task_ravg 通过参数 event 可以控制哪些事件不更新负载。

3. update_window_start 函数

/* 唯一调用路径:walt_update_task_ravg --> this */
static u64 update_window_start(struct rq *rq, u64 wallclock, int event) //walt.c
{
    s64 delta;
    int nr_windows;
    u64 old_window_start = rq->wrq.window_start;

    delta = wallclock - rq->wrq.window_start;
    if (delta < 0) {
        printk_deferred("WALT-BUG CPU%d; wallclock=%llu is lesser than window_start=%llu", rq->cpu, wallclock, rq->wrq.window_start);
        SCHED_BUG_ON(1);
    }

    /* sched_ravg_window 默认是20ms, 不足一个窗口就不更新,直接退出*/
    if (delta < sched_ravg_window)
        return old_window_start;

    /* 下面是delta大于一个window的,计算历经的整窗的个数 */
    nr_windows = div64_u64(delta, sched_ravg_window);
    rq->wrq.window_start += (u64)nr_windows * (u64)sched_ravg_window; /* 更新ws */

    rq->wrq.cum_window_demand_scaled = rq->wrq.walt_stats.cumulative_runnable_avg_scaled;
    rq->wrq.prev_window_size = sched_ravg_window;

    return old_window_start;
}

可以看到,rq->wrq.window_start、rq->wrq.cum_window_demand_scaled 是最先更新的。然后返回旧的 window_start,

4. update_task_cpu_cycles 函数

static void update_task_cpu_cycles(struct task_struct *p, int cpu, u64 wallclock) //walt.c
{
    if (use_cycle_counter)
        p->wts.cpu_cycles = read_cycle_counter(cpu, wallclock);
}

在 p->wts.mark_start 为0的时候,调用这个函数,应该是做初始化的。

5. update_task_rq_cpu_cycles 函数
'

/* 唯一调用路径 walt_update_task_ravg --> this */
static void update_task_rq_cpu_cycles(struct task_struct *p, struct rq *rq, int event, u64 wallclock, u64 irqtime) //walt.c
{
    u64 cur_cycles;
    u64 cycles_delta;
    u64 time_delta;
    int cpu = cpu_of(rq);

    lockdep_assert_held(&rq->lock);

    if (!use_cycle_counter) {
        /* freq / maxfreq * cpu_capacity, arch_scale_cpu_capacity 为函数 topology_get_cpu_scale */
        rq->wrq.task_exec_scale = DIV64_U64_ROUNDUP(cpu_cur_freq(cpu) * arch_scale_cpu_capacity(cpu), rq->wrq.cluster->max_possible_freq);
        return;
    }

    cur_cycles = read_cycle_counter(cpu, wallclock); /*return rq->wrq.cycles;*/

    /*
     * 如果当前任务是空闲任务并且 irqtime == 0,CPU 确实空闲并且它的循环计数器可能没有增加。
     * 我们仍然需要估计的 CPU 频率来计算 IO 等待时间。 在这种情况下使用先前计算的频率。
     */
    if (!is_idle_task(rq->curr) || irqtime) {
        if (unlikely(cur_cycles < p->wts.cpu_cycles)) //这应该是溢出了
            cycles_delta = cur_cycles + (U64_MAX - p->wts.cpu_cycles);
        else
            cycles_delta = cur_cycles - p->wts.cpu_cycles;

        cycles_delta = cycles_delta * NSEC_PER_MSEC;

        if (event == IRQ_UPDATE && is_idle_task(p))
            /*
             * 在空闲任务的 mark_start 和 IRQ 处理程序进入时间之间的时间是 CPU 周期计数器停止时间段。
             * 在 IRQ 处理程序进入 walt_sched_account_irqstart() 时,补充空闲任务的 cpu 周期计数器,因
             * 此cycles_delta 现在表示 IRQ 处理程序期间增加的周期,而不是从进入空闲到 IRQ 退出之间的时间段。
             * 因此使用 irqtime 作为时间增量。
             */
            time_delta = irqtime;
        else
            time_delta = wallclock - p->wts.mark_start;
        SCHED_BUG_ON((s64)time_delta < 0);

        /* (cycles_delta * cpu_capacity) / (time_delta * max_freq) = cycles_delta/time_delta * cpu_capacity/max_freq*/
        rq->wrq.task_exec_scale = DIV64_U64_ROUNDUP(cycles_delta * arch_scale_cpu_capacity(cpu), time_delta * rq->wrq.cluster->max_possible_freq);

        trace_sched_get_task_cpu_cycles(cpu, event, cycles_delta, time_delta, p);
    }

    p->wts.cpu_cycles = cur_cycles;
}

其中Trace:

trace_sched_get_task_cpu_cycles(cpu, event, cycles_delta, time_delta, p);

参数原型:

(int cpu, int event, u64 cycles, u64 exec_time, struct task_struct *p)

打印内容:

shell svc 7920-7921  [006] d..4 53723.502493: sched_get_task_cpu_cycles: cpu=6 event=2 cycles=105682000000 exec_time=78229 freq=1350931 legacy_freq=2035200
max_freq=2035200 task=19304 (kworker/u16:5)

字段解析:

前4个字段直接来自参数,
freq:取自 cycles/exec_time, 其中 cycles 是乘以了 NSEC_PER_MSEC 的,exec_time 的单位是ns。
legacy_freq:取自 cpu_rq(cpu)->wrq.cluster->max_possible_freq,单位KHz
max_freq:取自 cpu_rq(cpu)->wrq.cluster->max_possible_freq * cpu_rq(cpu)->cpu_capacity_orig / SCHED_CAPACITY_SCALE
task:取自 p->pid
kworker/u16:5:取自 p->comm

6. update_history 解析

update_task_demand 中若判断不需要更新 task 的 p->wts.sum, 但是又有新窗口产生时,调用这个函数更新历史负载。

/*
 * 当一个任务的新窗口开始时调用,记录最近结束的窗口的 CPU 使用率。 通常'samples'应该是1。
 * 比如说,当一个实时任务同时运行而不抢占几个窗口时,它可以 > 1,也就是说连续运行3个窗口才
 * 更新的话,samples就传3。
 *
 * update_task_demand()调用传参:(rq, p, p->wts.sum, 1, event)  sum 是几5个窗口的
 */
static void update_history(struct rq *rq, struct task_struct *p, u32 runtime, int samples, int event) //walt.c
{
    u32 *hist = &p->wts.sum_history[0];
    int ridx, widx;
    u32 max = 0, avg, demand, pred_demand;
    u64 sum = 0;
    u16 demand_scaled, pred_demand_scaled;

    /* Ignore windows where task had no activity */
    if (!runtime || is_idle_task(p) || !samples)
        goto done;

    /* Push new 'runtime' value onto stack */
    /* hist[5]中的元素向后移动samples个位置,runtime值插入到hist[0]中,hist[0]是最新的时间 */
    widx = sched_ravg_hist_size - 1; /* 5-1=4 */
    ridx = widx - samples; //widx=4, samples=1, ridx=3; samples=2, ridx=2
    for (; ridx >= 0; --widx, --ridx) {
        hist[widx] = hist[ridx];
        sum += hist[widx];  //此循环 sum = hist[4] + hist[3] + hist[2] + hist[1]
        if (hist[widx] > max)
            max = hist[widx]; //max保存最近4个窗中的最大值
    }

    /*
     * 若samples=1, hist[0] = runtime
     * 若samples=2, hist[0] = runtime, hist[1] = runtime
     * ...
     */
    for (widx = 0; widx < samples && widx < sched_ravg_hist_size; widx++) {
        hist[widx] = runtime; //hist[0]中存放的是最近的一个窗中运行的时间
        sum += hist[widx]; //sum再加上hist[0]
        if (hist[widx] > max)
            max = hist[widx]; //max保存的是最近5个窗中最大的值了
    }

    /* 将p->wts.sum放入history数组后就清0了, 也说明这个sum是一个窗的sum值 */
    p->wts.sum = 0;

    /*可以通过 sched_window_stats_policy 文件进行配置下面4种window policy */
    if (sysctl_sched_window_stats_policy == WINDOW_STATS_RECENT) { //为0,返回最近一个窗口的运行时间值
        demand = runtime;
    } else if (sysctl_sched_window_stats_policy == WINDOW_STATS_MAX) { //为1,返回最近5个窗口运行时间的最大值
        demand = max;
    } else {
        avg = div64_u64(sum, sched_ravg_hist_size); //求最近5个窗口运行时间的平均值
        if (sysctl_sched_window_stats_policy == WINDOW_STATS_AVG) //为3,返回最近5个窗口平均运行时间值
            demand = avg;
        else
            demand = max(avg, runtime); //为2,默认配置,返回最近5个窗口平均运行时间值 与 最近1个窗口运行时间值中的较大的那个
    }

    pred_demand = predict_and_update_buckets(p, runtime);

    /* demand_scaled = demand/(window_size/1024) == (demand / window_size) * 1024
     * 传参demand可以认为是p的负载了
     */
    demand_scaled = scale_demand(demand);
    /* pred_demand_scaled = pred_demand/(window_size/1024) == (pred_demand / window_size) * 1024 */
    pred_demand_scaled = scale_demand(pred_demand);

    /*
     * 限流的deadline调度类任务出队列时不改变p->on_rq。 由于出队递减 walt stats 避免再次递减它。
     * 当窗口滚动时,累积窗口需求被重置为累积可运行平均值(来自运行队列上的任务的贡献)。如果当前任务已经出队,
     * 则它的需求不包括在累积可运行平均值中。所以将任务需求单独添加到累积窗口需求中。
     */
    /*这里增加的是rq上的统计值,不是per-entity的了*/
    if (!task_has_dl_policy(p) || !p->dl.dl_throttled) {
        if (task_on_rq_queued(p)) {
            fixup_walt_sched_stats_common(rq, p, demand_scaled, pred_demand_scaled); /*这里加的是demand_scaled的差值*/
        } else if (rq->curr == p) {
            walt_fixup_cum_window_demand(rq, demand_scaled);
        }
    }

    /*赋值给per-entiry上的统计值,demand_scaled 对 p->wts.demand_scaled 的赋值一定要保证,这是walt负载跟踪算法重要的部分*/
    p->wts.demand = demand; /* 对应一个窗中运行的时间(根据window policy不同而有差异) */
    p->wts.demand_scaled = demand_scaled; /* 对应一个窗中运行的时间(根据window policy不同而有差异)缩放到1024 */ #############
    p->wts.coloc_demand = div64_u64(sum, sched_ravg_hist_size); /*5个窗口运行时间之和除以5,即5个窗口的平均运行时间*/
    p->wts.pred_demand = pred_demand;
    p->wts.pred_demand_scaled = pred_demand_scaled;

    /* demand_scaled 大于指定的阈值时,会做一些事情 */
    if (demand_scaled > sysctl_sched_min_task_util_for_colocation) {
        p->wts.unfilter = sysctl_sched_task_unfilter_period; /*单位是ns,默认值是100ms*/
    } else {
        if (p->wts.unfilter)
            p->wts.unfilter = max_t(int, 0, p->wts.unfilter - rq->wrq.prev_window_size); //相当于衰减一个窗口的大小
    }

done:
    trace_sched_update_history(rq, p, runtime, samples, event);
}

其中Trace:

trace_sched_update_history(rq, p, runtime, samples, event);

参数原型:

(struct rq *rq, struct task_struct *p, u32 runtime, int samples, enum task_event evt)

打印内容:

sched_update_history: 24647 (kworker/u16:15): runtime 279389 samples 1 event TASK_WAKE demand 717323
coloc_demand 717323 pred_demand 279389 (hist: 279389 88058 520130 1182596 1516443) cpu 1 nr_big 0

字段解析:

24647:取自 p->pid
kworker/u16:15:取自 p->comm
runtime:来自参数3,表示最近一个窗口中的运行时间,也是 p->wts.sum 的值
samples:来自参数4,表示更新几个窗的历史
event:取自 task_event_names[event]
demand:取自 p->wts.demand,是scale之前的根据不同window policy计算出来的负载值
coloc_demand:取自 p->wts.coloc_demand,即5个窗口的平均值
pred_demand:取自 p->wts.pred_demand,表示预测的负载需求
(hist: 279389 88058 520130 1182596 1516443):取自 p->wts.sum_history[5],是任务在最近5个窗口中分别运行的时间
cpu:取自 rq->cpu
nr_big:取自 rq->wrq.walt_stats.nr_big_tasks

用于预测任务的 demand 的 bucket 相关更新:

static inline u32 predict_and_update_buckets(struct task_struct *p, u32 runtime) //walt.c
{

    int bidx;
    u32 pred_demand;

    if (!sched_predl) //为1
        return 0;

    /* 根据传入的时间值获得一个桶的下标,桶一共有10个成员 */
    bidx = busy_to_bucket(runtime);
    /* 使用 p->wts.busy_buckets 用于计算 */
    pred_demand = get_pred_busy(p, bidx, runtime);
    /* 更新 p->wts.busy_buckets */
    bucket_increase(p->wts.busy_buckets, bidx);

    return pred_demand;
}

static inline int busy_to_bucket(u32 normalized_rt)
{
    int bidx;

    bidx = mult_frac(normalized_rt, NUM_BUSY_BUCKETS, max_task_load()); /*args1*10/16; arg1*arg2/arg3*/
    bidx = min(bidx, NUM_BUSY_BUCKETS - 1); //min(p->wts.sum * 10 / 16, 9) 运行一个满窗是桶10,运行1ms-2ms返回1

    /* 合并最低的两个桶。 最低频率落入第二桶,因此继续预测最低桶是没有用的。*/
    if (!bidx)
        bidx++;

    return bidx;
}

/*
 * get_pred_busy - 计算运行队列上的任务的预测需求
 *
 * @p:正在更新预测的任务
 * @start: 起始桶。 返回的预测不应低于此桶。
 * @runtime:任务的运行时间。 返回的预测不应低于此运行时。
 * 注意:@start 可以从@runtime 派生。 传入它只是为了在某些情况下避免重复计算。
 *
 * 根据传入的@runtime 为任务@p 返回一个新的预测繁忙时间。该函数搜索表示繁忙时间等于或大于@runtime
 * 的桶,并尝试找到用于预测的桶。 一旦找到,它会搜索历史繁忙时间并返回落入桶中的最新时间。 如果不
 * 存在这样的繁忙时间,则返回该桶的中间值。
 */
/*假设传参是p->wts.sum=8ms,那么传参就是(p, 5, 8),*/
static u32 get_pred_busy(struct task_struct *p, int start, u32 runtime)
{
    int i;
    u8 *buckets = p->wts.busy_buckets; //10个元素
    u32 *hist = p->wts.sum_history; //5个元素
    u32 dmin, dmax;
    u64 cur_freq_runtime = 0;
    int first = NUM_BUSY_BUCKETS, final; //从最大值10开始找
    u32 ret = runtime;

    /* skip prediction for new tasks due to lack of history */
    /* 由于累积运行时间小于100ms的新任务缺少历史运行时间,不对其进行预测 */
    if (unlikely(is_new_task(p)))
        goto out;

    /* find minimal bucket index to pick */
    /* 找到最小的桶下标进行pick, 只要桶中有数据就选择 */
    for (i = start; i < NUM_BUSY_BUCKETS; i++) {
        if (buckets[i]) {
            first = i;
            break;
        }
    }

    /* 若没找到桶下标,就直接返回 runtime,注意 runtime 可能大于10 */
    if (first >= NUM_BUSY_BUCKETS)
        goto out;

    /* 计算用于预测的桶 */
    final = first;

    /* 确定预测桶的需求范围 */
    if (final < 2) {
        /* 最低的两个桶合并 */
        dmin = 0;
        final = 1;
    } else {
        dmin = mult_frac(final, max_task_load(), NUM_BUSY_BUCKETS); //final * 20 / 10, max_task_load返回一个满窗
    }
    dmax = mult_frac(final + 1, max_task_load(), NUM_BUSY_BUCKETS); //(final + 1) * 20 / 10

    /*
     * search through runtime history and return first runtime that falls
     * into the range of predicted bucket.
     * 搜索运行历史并返回落在预测桶范围内的第一个运行。在最近的5个窗口中查找
     */
    for (i = 0; i < sched_ravg_hist_size; i++) {
        if (hist[i] >= dmin && hist[i] < dmax) {
            ret = hist[i];
            break;
        }
    }
    /* no historical runtime within bucket found, use average of the bin 
     * 若找不到存储桶内的历史运行时间,就使用垃圾桶的平均值 */
    if (ret < dmin)
        ret = (dmin + dmax) / 2;
    /*
     * 在窗口中间更新时,运行时间可能高于所有记录的历史记录。 始终至少预测运行时间。
     */
    ret = max(runtime, ret);
out:
    /* 由于 cur_freq_runtime 是0,所以 pct 恒为0 */
    trace_sched_update_pred_demand(p, runtime, mult_frac((unsigned int)cur_freq_runtime, 100,  sched_ravg_window), ret);

    return ret;
}

/*
 * bucket_increase - 更新所有桶的计数
 *
 * @buckets:跟踪任务繁忙时间的桶数组
 * @idx: 要被递增的桶的索引
 *
 * 每次完成一个完整的窗口时,运行时间落入 (@idx) 的桶计数增加。 所有其他桶的计数都会衰减。 
 * 根据桶中的当前计数,增加和衰减的速率可能不同。
 */
/*传参: (p->wts.busy_buckets, bidx)*/
static inline void bucket_increase(u8 *buckets, int idx)
{
    int i, step;

    for (i = 0; i < NUM_BUSY_BUCKETS; i++) { //10
        if (idx != i) { //不相等就衰减
            if (buckets[i] > DEC_STEP) //2
                buckets[i] -= DEC_STEP; //2
            else
                buckets[i] = 0;
        } else { //相等
            step = buckets[i] >= CONSISTENT_THRES ? INC_STEP_BIG : INC_STEP; //16 16 8
            if (buckets[i] > U8_MAX - step) //255-step
                buckets[i] = U8_MAX; //255
            else
                buckets[i] += step; //就是加step,上面判断是为了不要溢出
        }
    }
}

其中Trace:

trace_sched_update_pred_demand(p, runtime, mult_frac((unsigned int)cur_freq_runtime, 100,  sched_ravg_window), ret);

参数原型:

(struct task_struct *p, u32 runtime, int pct, unsigned int pred_demand)

打印内容:

sched_update_pred_demand: 1174 (Binder:1061_2): runtime 556361 pct 0 cpu 1 pred_demand 556361 (buckets: 0 255 0 0 0 0 0 0 0 0)

字段解析:

1174:取自 p->pid
Binder:1061_2:取自 p->comm
runtime:取自参数2
pct:取自参数3
cpu:取自task_cpu(p)
pred_demand:取自参数4
(buckets: 0 255 0 0 0 0 0 0 0 0):取自 p->wts.busy_buckets[10]

/* 
 * update_history --> this,如果task在rq上才会调用传参: (rq, p, demand_scaled, pred_demand_scaled),参数是缩放到0--1024后的
 * 也就是说这个函数里面计算的包含 runnable 的
 */
static void fixup_walt_sched_stats_common(struct rq *rq, struct task_struct *p, u16 updated_demand_scaled, u16 updated_pred_demand_scaled)
{
    /* p->wts.demand_scaled 约是由 p->wts.sum scale后得来的(window plicy策略影响), 后者是一个窗口中任务运行的时长。新的减旧的,结果处于[-1024,1024] */
    s64 task_load_delta = (s64)updated_demand_scaled - p->wts.demand_scaled;
    /* p->wts.pred_demand_scaled 是由桶算法预测得来的 */
    s64 pred_demand_delta = (s64)updated_pred_demand_scaled - p->wts.pred_demand_scaled;

    /* 直接加上传入的增量,注意增量可能是负数,一个进程的负载变低了,差值就是负数了*/
    fixup_cumulative_runnable_avg(&rq->wrq.walt_stats, task_load_delta, pred_demand_delta);
    /*累加demand_scaled的增量*/
    walt_fixup_cum_window_demand(rq, task_load_delta); /*上下两个函数都是对rq->wrq.中的成员赋值*/
}

/*
 * 如果task在rq上调用路径:update_history --> fixup_cumulative_runnable_avg 传参:(&rq->wrq.walt_stats, task_load_delta, pred_demand_delta)
 * 传参为时间差值。
 */
static inline void fixup_cumulative_runnable_avg(struct walt_sched_stats *stats, s64 demand_scaled_delta, s64 pred_demand_scaled_delta)
{
    /*
     * 增量差值可正可负,rq 的 cumulative_runnable_avg_scaled 初始化后就只有在这里有赋值了。
     * 这里根据根据当前窗口负载值快速变化。
     */
    stats->cumulative_runnable_avg_scaled += demand_scaled_delta;
    BUG_ON((s64)stats->cumulative_runnable_avg_scaled < 0);

    stats->pred_demands_sum_scaled += pred_demand_scaled_delta;
    BUG_ON((s64)stats->pred_demands_sum_scaled < 0);
}

说明 rq->wrq.walt_stats.cumulative_runnable_avg_scaled 和 rq->wrq.walt_stats.pred_demands_sum_scaled 统计的只是 runnable 状态的负载值。这里加上有符号的delta值,可以快速的反应runnable状态的负载的变化。

/* 
 * 如果task在rq上调用路径:update_history --> fixup_walt_sched_stats_common --> this 传参:(rq, task_load_delta)
 * 如果rq->curr == p 时调用路径:update_history --> this 传参:(rq, demand_scaled)
 * 说明这里面更新的成员统计的级包括 runnable 的分量,也保留 running 的分量
 */
static inline void walt_fixup_cum_window_demand(struct rq *rq, s64 scaled_delta)
{
    rq->wrq.cum_window_demand_scaled += scaled_delta;

    if (unlikely((s64)rq->wrq.cum_window_demand_scaled < 0))
        rq->wrq.cum_window_demand_scaled = 0;
}

rq->wrq.cum_window_demand_scaled 统计的既包括 runnable 的又包括 running 的。runnable 的累加的是差值,而 running 的累加的直接是 demand_scaled 的值,若是一部分 runnable 的任务变成 running 了,前者减少,后者增加,体现在结果上可能是不变的。

7. update_task_demand 函数

/*
 * 计算任务的cpu需求和/或更新任务的cpu需求历史
 *
 * ms = p->wts.mark_start
 * wc = wallclock
 * ws = rq->wrq.window_start
 *
 * 三种可能:
 *  a) 任务事件包含在一个窗口中。 16ms per-window, window_start < mark_start < wallclock
 *       ws    ms    wc
 *       |    |    |
 *       V    V    V
 *       |---------------|
 *
 * 在这种情况下,如果事件是合适的 p->wts.sum 被更新(例如:event == PUT_PREV_TASK)
 *
 * b) 任务事件跨越两个窗口。mark_start < window_start < wallclock
 *
 *       ms    ws     wc
 *       |    |     |
 *       V    V     V
 *      ------|-------------------
 *
 * 在这种情况下,如果事件是合适的 p->wts.sum 更新为 (ws - ms) ,然后记录一个新的窗口的采样,如果事件是合
 * 适的然后将 p->wts.sum 设置为 (wc - ws) 。
 *
 * c) 任务事件跨越两个以上的窗口。
 *
 *        ms ws_tmp                   ws  wc
 *        |  |                       |   |
 *        V  V                       V   V
 *        ---|-------|-------|-------|-------|------
 *           |                   |
 *           |<------ nr_full_windows ------>|
 *
 * 在这种情况下,如果事件是合适的,首先 p->wts.sum 更新为 (ws_tmp - ms) ,p->wts.sum 被记录,然后,如果
 * event 是合适的 window_size 的 'nr_full_window' 样本也被记录,最后如果 event 是合适的,p->wts.sum 更新
 * 到 (wc - ws)。
 *
 * 重要提示:保持 p->wts.mark_start 不变,因为 update_cpu_busy_time() 依赖它!
 *
 */
/* walt_update_task_ravg-->this 唯一调用位置 */
static u64 update_task_demand(struct task_struct *p, struct rq *rq, int event, u64 wallclock) //walt.c
{
    u64 mark_start = p->wts.mark_start; //进来时还没更新
    u64 delta, window_start = rq->wrq.window_start; //进来时已经更新了
    int new_window, nr_full_windows;
    u32 window_size = sched_ravg_window; //20ms
    u64 runtime;

    new_window = mark_start < window_start; //若为真说明经历了新窗口
    /* 若判断不需要更新负载,直接更新历史 p->wts.sum_history[],而没有更新 p->wts.sum */
    if (!account_busy_for_task_demand(rq, p, event)) {
        if (new_window) {
            /*
             * 如果计入的时间没有计入繁忙时间,并且新的窗口开始,
             * 则只需要关闭前一个窗口与预先存在的需求。 多个窗口
             * 可能已经过去,但由于空窗口被丢弃,因此没有必要考虑这些。
             *
             * 如果被累积的时间没有被计入繁忙时间,并且有新的窗口开始,
             * 则只需要与预先存在需求的前一个窗口被关闭。 虽然可能有多
             * 个窗口已经流逝了,但由于WALT算法是空窗口会被丢弃掉,因
             * 此没有必要考虑这些。
             */
            update_history(rq, p, p->wts.sum, 1, event);
        }
        return 0;
    }
    /* 下面是需要更新的情况了 */

    /* (1) 还是同一个窗口,对应上面的情况a */
    if (!new_window) {
        /* 简单的情况 - 包含在现有窗口中的繁忙时间。*/
        return add_to_task_demand(rq, p, wallclock - mark_start);
    }

    /* (2) 下面就是跨越了窗口,先求情况b */
    /* 繁忙时间至少跨越两个窗口。 暂时将 window_start 倒回到 mark_start 之后的第一个窗口边界。*/
    delta = window_start - mark_start;
    nr_full_windows = div64_u64(delta, window_size);
    window_start -= (u64)nr_full_windows * (u64)window_size;

    /* Process (window_start - mark_start) first */
    /* 这里累加的是  情况b/情况c 中ws_tmp-ms这段的delta值 */
    runtime = add_to_task_demand(rq, p, window_start - mark_start);

    /* Push new sample(s) into task's demand history */
    /* 将最开始的不足一个window窗口大小的delta计算出来的p->wts.sum放入历史数组中 */
    update_history(rq, p, p->wts.sum, 1, event);

    /* (3) 下面就对应情况c了,由于c和b都有最开始不足一个窗口的一段,在上面计算b时一并计算了 */
    if (nr_full_windows) {
        u64 scaled_window = scale_exec_time(window_size, rq); //等于直接return window_size

        /* 一下子更新 nr_full_windows 个窗口的负载到历史窗口负载中,每个窗口都是满窗 */
        update_history(rq, p, scaled_window, nr_full_windows, event);
        /* runtime 累积运行时间进行累加 ==>只要搞清什么时候标记ms和什么时候调用这个函数计算负载,就可以知道计算的是哪段的 ######## */
        runtime += nr_full_windows * scaled_window;
    }

    /* 将 window_start 滚回当前以处理当前窗口,以便于计算当前窗口中的剩余部分。*/
    window_start += (u64)nr_full_windows * (u64)window_size;

    /* 这里是计算情况b和情况c的wc-ws段 */
    mark_start = window_start;

    runtime += add_to_task_demand(rq, p, wallclock - mark_start); //runtime 继续累加

    /* 返回值表示此次 update_task_demand 更新的时间值,是 wc-ms 的差值 */
    return runtime;
}

此函数中始终没有更新回去 p->wts.mark_start,其是在 walt_update_task_ravg 函数最后更新的。rq->wrq.window_start 在上面第一个函数中就更新了。

/* update_task_demand --> this */
static int account_busy_for_task_demand(struct rq *rq, struct task_struct *p, int event) //walt.c
{
    /* (1) 不需要统计 idle task 的 demand,直接返回*/
    if (is_idle_task(p))
        return 0;

    /*
     * 当一个任务被唤醒时,它正在完成一段非繁忙时间。 同样,如果等待时间
     * 不被视为繁忙时间,那么当任务开始运行或迁移时,它并未运行并且正在完成
     * 一段非繁忙时间。
     */
    /*就是这些情况跳过统计,!SCHED_ACCOUNT_WAIT_TIME 恒为假,所以是只判断了 TASK_WAKE */
    /* (2) 是唤醒事件 或 不需要计算walit事件并且事件是pick和migrate, 不需要更新 */
    if (event == TASK_WAKE || (!SCHED_ACCOUNT_WAIT_TIME && (event == PICK_NEXT_TASK || event == TASK_MIGRATE)))
        return 0;

    /* (3) idle进程退出的时候也不需要统计 */
    if (event == PICK_NEXT_TASK && rq->curr == rq->idle)
        return 0;

    /*
     * TASK_UPDATE can be called on sleeping task, when its moved between related groups
     */
    /*context_switch()的时候更改的rq->curr*/
    /* (4) 若是update事件,且p是curr任务,需要更新。否则若p在队列上需要更新,不在队列上不需要更新 */
    if (event == TASK_UPDATE) {
        if (rq->curr == p)
            return 1;

        return p->on_rq ? SCHED_ACCOUNT_WAIT_TIME : 0; //这里可调整是否记录任务在rq上的等待的时间
    }

    /* (5) 都不满足,默认是需要更新 */
    return 1;
}

p是idle task,或 事件是 TASK_WAKE,或idle任务退出时的 PICK_NEXT_TASK 事件,或事件是 TASK_UPDATE 但是 p 不是curr任务也没有在rq上,就不需要计算busy time。只有事件是 TASK_UPDATE,且任务p是 rq->curr 任务或者 p是在rq 上等待,则需要更新。若不需要更新的话,又产生了新的窗口,那就调用 update_history()更新负载历史就退出了。

/* update_task_demand --> this 唯一调用路径也是在 walt_update_task_ravg 中 */
static u64 add_to_task_demand(struct rq *rq, struct task_struct *p, u64 delta) //walt.c
{
    /* delta = (delta * rq->wrq.task_exec_scale) >> 10, 由于 rq->wrq.task_exec_scale 初始化为1024,所以还是delta*/
    delta = scale_exec_time(delta, rq);
    /* 这里更新了 p->wts.sum,并将最大值钳位在一个窗口大小*/
    p->wts.sum += delta;
    if (unlikely(p->wts.sum > sched_ravg_window))
        p->wts.sum = sched_ravg_window;

    return delta;
}

更新 p->wts.sum 值,并且返回 delta 值。这也是 sum 的唯一更新位置,唯一调用路径也是从 walt_update_task_ravg 函数调用下来的。

8. update_cpu_busy_time 函数

/* walt_update_task_ravg --> this 这是唯一调用路径,传参(p, rq, event, wallclock, irqtime)*/
static void update_cpu_busy_time(struct task_struct *p, struct rq *rq, int event, u64 wallclock, u64 irqtime)
{
    int new_window, full_window = 0;
    int p_is_curr_task = (p == rq->curr);
    u64 mark_start = p->wts.mark_start;
    u64 window_start = rq->wrq.window_start; //walt_update_task_ravg-->update_window_start 最先更新的rq->wrq.window_start
    u32 window_size = rq->wrq.prev_window_size;
    u64 delta;
    u64 *curr_runnable_sum = &rq->wrq.curr_runnable_sum;
    u64 *prev_runnable_sum = &rq->wrq.prev_runnable_sum;
    u64 *nt_curr_runnable_sum = &rq->wrq.nt_curr_runnable_sum;
    u64 *nt_prev_runnable_sum = &rq->wrq.nt_prev_runnable_sum;
    bool new_task;
    struct walt_related_thread_group *grp;
    int cpu = rq->cpu;
    u32 old_curr_window = p->wts.curr_window;

    new_window = mark_start < window_start;
    if (new_window)
        full_window = (window_start - mark_start) >= window_size;

    /* 处理每个任务的窗口翻转。 我们不关心空闲任务。*/
    if (!is_idle_task(p)) {
        if (new_window)
            /* 将 p->wts 的 curr_window 赋值给 prev_window,然后将 curr_window 清0 */
            rollover_task_window(p, full_window);
    }

    new_task = is_new_task(p); //运行时间小于5个窗口的任务

    /* p是curr任务并且有了个新窗口才执行 */
    if (p_is_curr_task && new_window) {
        /* rq的一些成员,prev_*_sum=curr_*_sum, 然后将 curr_*_sum 赋值为0 */
        rollover_cpu_window(rq, full_window);
        rollover_top_tasks(rq, full_window); //这里面已经更新了rq->wrq.curr_table ############
    }

    /* 判断是否需要记录 */
    if (!account_busy_for_cpu_time(rq, p, irqtime, event))
        goto done;
    /*----下面就是需要计算的了----*/

    grp = p->wts.grp;
    if (grp) {
        struct group_cpu_time *cpu_time = &rq->wrq.grp_time;
        /* 注意:指向更改了! */
        curr_runnable_sum = &cpu_time->curr_runnable_sum;
        prev_runnable_sum = &cpu_time->prev_runnable_sum;

        nt_curr_runnable_sum = &cpu_time->nt_curr_runnable_sum;
        nt_prev_runnable_sum = &cpu_time->nt_prev_runnable_sum;
    }

    if (!new_window) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。 
         * 没有翻转,因为我们没有启动一个新窗口。 这方面的一个例子是当
         * 任务开始执行然后在同一窗口内休眠时。
         */
        if (!irqtime || !is_idle_task(p) || cpu_is_waiting_on_io(rq))
            delta = wallclock - mark_start;
        else
            delta = irqtime;
        delta = scale_exec_time(delta, rq); //等于直接return delta
        *curr_runnable_sum += delta;
        if (new_task)
            *nt_curr_runnable_sum += delta;

        if (!is_idle_task(p)) {
            p->wts.curr_window += delta;
            p->wts.curr_window_cpu[cpu] += delta;
        }

        goto done;
    }
    /*----下面就是有一个新窗口的情况了----*/

    if (!p_is_curr_task) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。
         * 一个新窗口也已启动,但 p 不是当前任务,因此窗口不会翻转 
         * - 只需拆分并根据需要将计数分为 curr 和 prev。 仅在为当前任
         * 务处理新窗口时才会翻转窗口。
         *
         * irqtime 不能由不是当前正在运行的任务的任务计算。
         */

        if (!full_window) {
            /* 一个完整的窗口还没有过去,计算对上一个完成的窗口的部分贡献。*/
            delta = scale_exec_time(window_start - mark_start, rq);
            p->wts.prev_window += delta;
            p->wts.prev_window_cpu[cpu] += delta;
        } else {
            /* 由于至少一个完整的窗口已经过去,对前一个窗口的贡献是一个完整的窗口(window_size) */
            delta = scale_exec_time(window_size, rq);
            p->wts.prev_window = delta;
            p->wts.prev_window_cpu[cpu] = delta;
        }

        *prev_runnable_sum += delta;
        if (new_task)
            *nt_prev_runnable_sum += delta;

        /* 只占当前窗口的一部分繁忙时间 */
        delta = scale_exec_time(wallclock - window_start, rq);
        *curr_runnable_sum += delta;
        if (new_task)
            *nt_curr_runnable_sum += delta;

        p->wts.curr_window = delta; /*对当前窗的贡献直接复制给当前窗*/
        p->wts.curr_window_cpu[cpu] = delta;

        goto done;
    }
    /*----下面p是当前任务的情况了----*/

    if (!irqtime || !is_idle_task(p) || cpu_is_waiting_on_io(rq)) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。 一个新窗口已经启动, 
         * p 是当前任务,因此需要翻转。 如果以上三个条件中的任何一个为真,那么这个繁忙的时
         * 间就不能算作 irqtime。
         *
         * 空闲任务的繁忙时间不需要计算。
         *
         * 一个例子是一个任务开始执行,然后在新窗口开始后休眠。
         */

        if (!full_window) {
            /* 一个完整的窗口还没有过去,计算对上一个完整的窗口的部分贡献。*/
            delta = scale_exec_time(window_start - mark_start, rq); //等效直接返回window_start - mark_start
            if (!is_idle_task(p)) {
                p->wts.prev_window += delta;
                p->wts.prev_window_cpu[cpu] += delta;
            }
        } else {
            /* 由于至少一个完整的窗口已经过去,对前一个窗口的贡献是完整的窗口(window_size)*/
            delta = scale_exec_time(window_size, rq);
            if (!is_idle_task(p)) {
                p->wts.prev_window = delta;
                p->wts.prev_window_cpu[cpu] = delta;
            }
        }

        /* 在这里通过覆盖 prev_runnable_sum 和 curr_runnable_sum 中的值来完成翻转。*/
        *prev_runnable_sum += delta;
        if (new_task)
            *nt_prev_runnable_sum += delta;

        /* 计算在当前窗口忙时的一片时间 */
        delta = scale_exec_time(wallclock - window_start, rq);
        *curr_runnable_sum += delta;
        if (new_task)
            *nt_curr_runnable_sum += delta;

        if (!is_idle_task(p)) {
            p->wts.curr_window = delta;
            p->wts.curr_window_cpu[cpu] = delta;
        }

        goto done;
    }
    /*---- 下面就对应 irqtime && is_idle_task(p) && !cpu_is_waiting_on_io(rq) 的情况了,并且累积上面的条件 ----*/

    if (irqtime) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。
         * 一个新窗口已经启动,p 是当前任务,因此需要翻转。 当前任务必
         * 须是空闲任务,因为不为其他任何任务计算irqtime。
         *
         * 空闲一段时间后,每次我们处理 IRQ 活动时都会计算 Irqtime,因
         * 此我们知道 IRQ 繁忙时间为 wallclock - irqtime。
         */

        SCHED_BUG_ON(!is_idle_task(p));
        mark_start = wallclock - irqtime;

        /*
         * 滚动窗口。 如果 IRQ 繁忙时间只是在当前窗口中,那么这就是所有需要计算的。
         */
        if (mark_start > window_start) {
            *curr_runnable_sum = scale_exec_time(irqtime, rq); //等效于直接返回irqtime,因为是idle线程,之前应该是0的
            return;
        }
        /*---下面是ms<=ws---*/

        /*
         * IRQ 繁忙时间跨越多个窗口。 先处理当前窗口开始前的忙时间。
         */
        delta = window_start - mark_start;
        if (delta > window_size)
            delta = window_size;
        delta = scale_exec_time(delta, rq);
        *prev_runnable_sum += delta; //这直接加不会超过一个窗的大小吗?

        /* Process the remaining IRQ busy time in the current window.  处理当前窗口中剩余的 IRQ 忙时间。*/
        delta = wallclock - window_start;
        rq->wrq.curr_runnable_sum = scale_exec_time(delta, rq);

        return;
    }

done:
    if (!is_idle_task(p))
        update_top_tasks(p, rq, old_curr_window, new_window, full_window);
}

值更新当前窗口和前一个窗口的busy时间,主要用于更新任务的: p->wts.curr_window、p->wts.curr_window_cpu[cpu],更新rq 的 rq->wrq.curr_runnable_sum、rq->wrq.prev_runnable_sum,若是一个walt认为的新任务,还更新 rq->wrq.nt_curr_runnable_sum、rq->wrq.nt_prev_runnable_sum。然后是更新 top-task 的一些成员

下面分别是对 task、cpu、top_tasks 维护的 window 进行更新。有一个新的窗口到来时更新,若更新时已经经历了一个或多个完整的window,那么对prev和curr window 相关的描述结构进行清理备用。

static u32 empty_windows[NR_CPUS];
/* 将 p->wts 的 curr_window 赋值给 prev_window,然后将 curr_window 清0 */
static void rollover_task_window(struct task_struct *p, bool full_window)
{
    u32 *curr_cpu_windows = empty_windows; //数组,每个cpu一个
    u32 curr_window;
    int i;

    /* Rollover the sum */
    curr_window = 0;

    /* 若经历了一个full_window, prev和curr window都清理待用 */
    if (!full_window) {
        curr_window = p->wts.curr_window;
        curr_cpu_windows = p->wts.curr_window_cpu;
    }

    p->wts.prev_window = curr_window;
    p->wts.curr_window = 0;

    /* Roll over individual CPU contributions 滚动每个 CPU 的贡献 */
    for (i = 0; i < nr_cpu_ids; i++) {
        p->wts.prev_window_cpu[i] = curr_cpu_windows[i];
        p->wts.curr_window_cpu[i] = 0;
    }

    if (is_new_task(p))
        p->wts.active_time += task_rq(p)->wrq.prev_window_size; //active_time 的唯一更新位置, walt认为的新任务
}

清理的是任务的 p->wts.prev_window_cpu、p->wts.curr_window、p->wts.prev_window_cpu[]、p->wts.curr_window_cpu[]

/*
 * rq的一些成员,prev_*_sum=curr_*_sum, 然后将 curr_*_sum 赋值为0,将curr赋值给prev,
 * 若是有经历了多个窗口curr和prev窗口都需要清理待用。
 */
static void rollover_cpu_window(struct rq *rq, bool full_window)
{
    u64 curr_sum = rq->wrq.curr_runnable_sum;
    u64 nt_curr_sum = rq->wrq.nt_curr_runnable_sum;
    u64 grp_curr_sum = rq->wrq.grp_time.curr_runnable_sum;
    u64 grp_nt_curr_sum = rq->wrq.grp_time.nt_curr_runnable_sum;

    if (unlikely(full_window)) {
        curr_sum = 0;
        nt_curr_sum = 0;
        grp_curr_sum = 0;
        grp_nt_curr_sum = 0;
    }

    rq->wrq.prev_runnable_sum = curr_sum;
    rq->wrq.nt_prev_runnable_sum = nt_curr_sum;
    rq->wrq.grp_time.prev_runnable_sum = grp_curr_sum;
    rq->wrq.grp_time.nt_prev_runnable_sum = grp_nt_curr_sum;

    rq->wrq.curr_runnable_sum = 0;
    rq->wrq.nt_curr_runnable_sum = 0;
    rq->wrq.grp_time.curr_runnable_sum = 0;
    rq->wrq.grp_time.nt_curr_runnable_sum = 0;
}

清理的是 rq->wrq 的 和 rq->wrq.grp_time 的 prev_runnable_sum、curr_runnable_sum、nt_prev_runnable_sum、nt_curr_runnable_sum

static void rollover_top_tasks(struct rq *rq, bool full_window)
{
    /* 跟踪的是2个,构成一个环形数组 */
    u8 curr_table = rq->wrq.curr_table;
    u8 prev_table = 1 - curr_table;
    int curr_top = rq->wrq.curr_top;

    /*将prev window的数据结构清理后待用*/
    clear_top_tasks_table(rq->wrq.top_tasks[prev_table]); //memset(arg, 0, NUM_LOAD_INDICES * sizeof(u8));
    clear_top_tasks_bitmap(rq->wrq.top_tasks_bitmap[prev_table]);//将bit数组的内容清0,然后将 NUM_LOAD_INDICES bit设置为1

    /*若是已经经历了多个window,那么之前标记的curr window也是旧窗口了,需要清理待用*/
    if (full_window) {
        curr_top = 0;
        clear_top_tasks_table(rq->wrq.top_tasks[curr_table]);
        clear_top_tasks_bitmap(rq->wrq.top_tasks_bitmap[curr_table]);
    }

    /*两个window的下标进行翻转,curr-->prev,prev-->curr*/
    rq->wrq.curr_table = prev_table;
    rq->wrq.prev_top = curr_top;
    rq->wrq.curr_top = 0;
}

清理的是 rq->wrq 的 top_task 相关的成员。

然后调用 account_busy_for_cpu_time 判断清理后任务的和cpu的是否还需要更新上去

/* update_cpu_busy_time-->this, 传参(rq, p, irqtime, event) */
static int account_busy_for_cpu_time(struct rq *rq, struct task_struct *p, u64 irqtime, int event)
{
    if (is_idle_task(p)) {
        /* TASK_WAKE && TASK_MIGRATE is not possible on idle task!  idle task不可能出现唤醒和迁移 */
        if (event == PICK_NEXT_TASK)
            return 0;

        /* PUT_PREV_TASK, TASK_UPDATE && IRQ_UPDATE are left */
        return irqtime || cpu_is_waiting_on_io(rq);
    }

    if (event == TASK_WAKE)
        return 0;

    if (event == PUT_PREV_TASK || event == IRQ_UPDATE)
        return 1;

    /*
     * TASK_UPDATE can be called on sleeping task, when its moved between related groups
     * TASK_UPDATE 当它在相关组之间移动时可能在睡眠的任务上调用,
     */
    if (event == TASK_UPDATE) {
        if (rq->curr == p)
            return 1;

        return p->on_rq ? SCHED_FREQ_ACCOUNT_WAIT_TIME : 0; //在rq上和或正在迁移是1,但是冒号前后都是0
    }

    /* TASK_MIGRATE, PICK_NEXT_TASK left */
    return SCHED_FREQ_ACCOUNT_WAIT_TIME; //0
}

top_task 维护的窗口更新:

/* 
 * update_cpu_busy_time-->this 若p不是idle任务,就调用,传参(p, rq, old_curr_window, new_window, full_window) 
 * @ old_curr_window:取自 p->wts.curr_window,表示p在窗口翻转前在当前窗口的运行时间
 * @ new_window:bool值,若ms<ws为真
 * @ full_window:bool值,若ws-ms>window_size为真
 */
static void update_top_tasks(struct task_struct *p, struct rq *rq, u32 old_curr_window, int new_window, bool full_window)
{
    /* 只使用两个窗口进行跟踪,当前是0,perv就是1,当前是1,prev就是0,两个数据结构构成一个环形缓存区 */
    u8 curr = rq->wrq.curr_table;
    u8 prev = 1 - curr;
    u8 *curr_table = rq->wrq.top_tasks[curr];
    u8 *prev_table = rq->wrq.top_tasks[prev];
    int old_index, new_index, update_index;
    u32 curr_window = p->wts.curr_window;
    u32 prev_window = p->wts.prev_window;
    bool zero_index_update;

    /* 两个窗的运行时间相等或新窗口还没有到来 */
    if (old_curr_window == curr_window && !new_window)
        return;

    /* 在一个窗中运行的时间越长,index就越大, 参数是一个窗口中的运行时长*/
    old_index = load_to_index(old_curr_window);
    new_index = load_to_index(curr_window);

    if (!new_window) {
        zero_index_update = !old_curr_window && curr_window;
        if (old_index != new_index || zero_index_update) {
            if (old_curr_window)
                curr_table[old_index] -= 1; //上一个窗口的累计值衰减
            if (curr_window)
                curr_table[new_index] += 1; //新窗口的累计值增加
            if (new_index > rq->wrq.curr_top)
                rq->wrq.curr_top = new_index; //更新rq->wrq.curr_top成员
        }

        if (!curr_table[old_index])
            __clear_bit(NUM_LOAD_INDICES - old_index - 1, rq->wrq.top_tasks_bitmap[curr]); //这个bit数组表示此运行时间下有没有计数值

        if (curr_table[new_index] == 1)
            __set_bit(NUM_LOAD_INDICES - new_index - 1, rq->wrq.top_tasks_bitmap[curr]);

        return;
    }
    /*---下面是new_window!=0的情况了---*/

    /*
     * 对于此任务来说窗口已经翻转。 当我们到达这里时,curr/prev 交换已经发生。 
     * 所以我们需要对新索引使用 prev_window 。
     */
    update_index = load_to_index(prev_window);

    if (full_window) { //至少有一个满窗
        /*
         * 这里有两个案例。 要么'p' 运行了整个窗口,要么根本不运行。 在任何一种情况下,
         * prev 表中都没有条目。 如果 'p' 运行整个窗口,我们只需要在 prev 表中创建一个
         * 新条目。 在这种情况下,update_index 将对应于 sched_ravg_window,因此我们可
         * 以无条件地更新顶部索引。
         */
        if (prev_window) {
            prev_table[update_index] += 1;
            rq->wrq.prev_top = update_index;
        }

        if (prev_table[update_index] == 1)
            __set_bit(NUM_LOAD_INDICES - update_index - 1, rq->wrq.top_tasks_bitmap[prev]);
    } else { //产生了新窗口,但是还没达到一个满窗
        zero_index_update = !old_curr_window && prev_window;
        if (old_index != update_index || zero_index_update) {
            if (old_curr_window)
                prev_table[old_index] -= 1;

            prev_table[update_index] += 1;

            if (update_index > rq->wrq.prev_top)
                rq->wrq.prev_top = update_index;

            /* 减为0是清理对应bit,首次设置为1时设置相应bit。top_tasks_bitmap[]在任务迁移时有使用 */
            if (!prev_table[old_index])
                __clear_bit(NUM_LOAD_INDICES - old_index - 1, rq->wrq.top_tasks_bitmap[prev]);
            if (prev_table[update_index] == 1)
                __set_bit(NUM_LOAD_INDICES - update_index - 1, rq->wrq.top_tasks_bitmap[prev]);
        }
    }

    if (curr_window) {
        curr_table[new_index] += 1;

        if (new_index > rq->wrq.curr_top)
            rq->wrq.curr_top = new_index;

        if (curr_table[new_index] == 1)
            __set_bit(NUM_LOAD_INDICES - new_index - 1, rq->wrq.top_tasks_bitmap[curr]);
    }
}

top_tasks 的维护中也使用到了桶,新窗运行时间对应的 curr_table[]成员加1,之前窗口运行时间对应的 prev_table[] 成员减1。

9. update_task_pred_demand 函数

/*
 * 在窗口翻转时计算任务的预测需求。如果任务当前窗口繁忙时间超过预测需求,则在此处更新以反映任务需求。
 */
void update_task_pred_demand(struct rq *rq, struct task_struct *p, int event)
{
    u32 new, old;
    u16 new_scaled;

    if (!sched_predl) //1
        return;

    if (is_idle_task(p))
        return;

    if (event != PUT_PREV_TASK && event != TASK_UPDATE &&
            (!SCHED_FREQ_ACCOUNT_WAIT_TIME || (event != TASK_MIGRATE && event != PICK_NEXT_TASK)))
        return;

    /*
     * 当它在相关组之间移动时,TASK_UPDATE 可以在睡眠任务上调用。
     */
    if (event == TASK_UPDATE) {
        if (!p->on_rq && !SCHED_FREQ_ACCOUNT_WAIT_TIME)
            return;
    }

    new = calc_pred_demand(p);
    old = p->wts.pred_demand;

    if (old >= new)
        return;
    /*---下面就是 new > old 的情况---*/

    new_scaled = scale_demand(new); //new/window_size*1024
    /* p是on rq的状态并且不是已经被throttle的deadline任务 */
    if (task_on_rq_queued(p) && (!task_has_dl_policy(p) || !p->dl.dl_throttled))
        fixup_walt_sched_stats_common(rq, p, p->wts.demand_scaled, new_scaled);

    p->wts.pred_demand = new;
    p->wts.pred_demand_scaled = new_scaled;
}

注意,这里再次调用了 fixup_walt_sched_stats_common,在 walt_update_task_ravg 函数中,在 update_history 中已经调用过一次,进入条件也相同,也是p在队列上。

static inline u32 calc_pred_demand(struct task_struct *p)
{
    /* 预测的需求比当前窗口的大,就返回预测的需求 */
    if (p->wts.pred_demand >= p->wts.curr_window)
        return p->wts.pred_demand;

    return get_pred_busy(p, busy_to_bucket(p->wts.curr_window), p->wts.curr_window);
}

get_pred_busy 和 busy_to_bucket 两个函数上面都有列出。

10. run_walt_irq_work 函数

static inline void run_walt_irq_work(u64 old_window_start, struct rq *rq) //walt.c
{
    u64 result;

    /*若是还是同一个窗,直接退出*/
    if (old_window_start == rq->wrq.window_start)
        return;

    /* 
     * atomic64_cmpxchg(*ptr, old, new) 函数功能是:将old和ptr指向的内容比较,如果相等,
     * 则将new写入到ptr指向的内存中,并返回old,如果不相等,则返回ptr指向的内容。
     */
    result = atomic64_cmpxchg(&walt_irq_work_lastq_ws, old_window_start, rq->wrq.window_start);
    if (result == old_window_start) {
        walt_irq_work_queue(&walt_cpufreq_irq_work); //触发回调 walt_irq_work(),构成一个"内核线程",循环往复执行

        trace_walt_window_rollover(rq->wrq.window_start);
    }
}

walt_irq_work_queue 会触发 walt_irq_work() 被调用,这个函数中又会调用 walt_update_task_ravg,walt_update_task_ravg 函数会在每个tick中调用,这里这样实现可能是针对没有tick的场景使用。

其中Trace:

trace_walt_window_rollover(rq->wrq.window_start);

参数原型:

(u64 window_start)

打印内容:

//20ms间隔执行一次
<idle>-0     [002] d..2 48262.320451: walt_window_rollover: window_start=48262548000001
<idle>-0     [001] d.h2 48262.340457: walt_window_rollover: window_start=48262568000001

字段解析:

window_start 就是打印 rq->wrq.window_start 的记录的时间值,单位是ns.

四、总结

    WALT负载计算算法是基于窗口的,对window有一个rollover的操作,只跟踪curr和prev两个窗口,curr窗口的下标由 wrq.curr_table 指向,两个窗口构成一个唤醒缓冲区,prev和curr进行不断切换。
    walt_update_task_ravg 函数通过其 event 成员决定对哪些事件计算负载,再根据其调用路径和其调用函数对是否是在rq上,是否是p=rq->curr可以判断统计的是哪部分的负载。
    预测负载这块,对于任务和CPU都使用了桶,任务是10个桶,对于cpu的curr和prev两个窗口分别是1000个成员,命中累加,不命中衰减。
    walt_update_task_ravg 在tick的调用路径中有调用,应该是为了无tick情况下walt仍然能正常工作,使用irq_work构成一个内核线程以一个窗口的周期来更新窗口。

五、补充

    task util的获取:task_util() WALT: p->wts.demand_scaled;PELT: p->se.avg.util_avg
    cpu util的获取:cpu_util_cum() WALT: rq->wrq.cum_window_demand_scaled;PELT: rq->cfs.avg.util_avg
    task_util() 函数


static inline unsigned long task_util(struct task_struct *p)
{
#ifdef CONFIG_SCHED_WALT
    if (likely(!walt_disabled && sysctl_sched_use_walt_task_util))
        return p->wts.demand_scaled;
#endif
    return READ_ONCE(p->se.avg.util_avg);
}

#15 进程模块 » Gentoo 之 NUMA 多核架构中的多线程调度开销与性能优化 » 2024-03-26 21:12:51

batsom
回复: 0

NOTE:本文中所指 “线程” 均为可执行调度单元 Kernel Thread。
NUMA 体系结构

NUMA(Non-Uniform Memory Access,非一致性存储器访问)的设计理念是将 CPU 和 Main Memory 进行分区自治(Local NUMA node),又可以跨区合作(Remote NUMA node),以这样的方式来缓解单一内存总线存在的瓶颈。

FluxBB bbcode 测试

不同的 NUMA node 都拥有几乎相等的资源,在 Local NUMA node 内部会通过自己的存储总线访问 Local Memory,而 Remote NUMA node 则可以通过主板上的共享总线来访问其他 Node 上的 Remote Memory。

显然的,CPU 访问 Local Memory 和 Remote Memory 所需要的耗时是不一样的,所以 NUMA 才得名为 “非一致性存储器访问"。同时,因为 NUMA 并非真正意义上的存储隔离,所以 NUMA 同样只会保存一份操作系统和数据库系统的副本。也就是说,默认情况下,耗时的远程访问是很可能存在的。

这种做法使得 NUMA 具有一定的伸缩性,更加适合应用在服务器端。但也由于 NUMA 没有实现彻底的主存隔离,所以 NUMA 的扩展性也是有限的,最多可支持几百个 CPU/Core。这是为了追求更高的并发性能所作出的妥协。

FluxBB bbcode 测试

基本对象概念

    Node(节点):一个 Node 可以包含若干个 Socket,通常是一个。
    Socket(插槽):一颗物理处理器 SoC 的封装。
    Core(核心):一个 Socket 封装的若干个物理处理器核心(Physical processor)。
    Hyper-Thread(超线程):每个 Core 可以被虚拟为若干个(通常为 2 个)逻辑处理器(Virtual processors)。逻辑处理器会共享大多数物理处理器资源(e.g. 内存缓存、功能单元)。
    Processor(逻辑处理器):操作系统层面的 CPU 逻辑处理器对象。
    Siblings:操作系统层面的 Physical processor 和下属 Virtual processors 之间的从属关系。

下图所示为一个 NUMA Topology,表示该服务器具有 2 个 Node,每个 Node 含有一个 Socket,每个 Socket 含有 6 个 Core,每个 Core 又被超线程为 2 个 Thread,所以服务器总共的 Processor = 2 x 1 x 6 x 2 = 24 颗,其中 Siblings[0] = [cpu0, cpu1]。
FluxBB bbcode 测试

查看 Host 的 NUMA Topology

#!/usr/bin/env python
# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2010-2014 Intel Corporation
# Copyright(c) 2017 Cavium, Inc. All rights reserved.

from __future__ import print_function
import sys
try:
    xrange # Python 2
except NameError:
    xrange = range # Python 3

sockets = []
cores = []
core_map = {}
base_path = "/sys/devices/system/cpu"
fd = open("{}/kernel_max".format(base_path))
max_cpus = int(fd.read())
fd.close()
for cpu in xrange(max_cpus + 1):
    try:
        fd = open("{}/cpu{}/topology/core_id".format(base_path, cpu))
    except IOError:
        continue
    except:
        break
    core = int(fd.read())
    fd.close()
    fd = open("{}/cpu{}/topology/physical_package_id".format(base_path, cpu))
    socket = int(fd.read())
    fd.close()
    if core not in cores:
        cores.append(core)
    if socket not in sockets:
        sockets.append(socket)
    key = (socket, core)
    if key not in core_map:
        core_map[key] = []
    core_map[key].append(cpu)

print(format("=" * (47 + len(base_path))))
print("Core and Socket Information (as reported by '{}')".format(base_path))
print("{}\n".format("=" * (47 + len(base_path))))
print("cores = ", cores)
print("sockets = ", sockets)
print("")

max_processor_len = len(str(len(cores) * len(sockets) * 2 - 1))
max_thread_count = len(list(core_map.values())[0])
max_core_map_len = (max_processor_len * max_thread_count)  \
                      + len(", ") * (max_thread_count - 1) \
                      + len('[]') + len('Socket ')
max_core_id_len = len(str(max(cores)))

output = " ".ljust(max_core_id_len + len('Core '))
for s in sockets:
    output += " Socket %s" % str(s).ljust(max_core_map_len - len('Socket '))
print(output)

output = " ".ljust(max_core_id_len + len('Core '))
for s in sockets:
    output += " --------".ljust(max_core_map_len)
    output += " "
print(output)

for c in cores:
    output = "Core %s" % str(c).ljust(max_core_id_len)
    for s in sockets:
        if (s,c) in core_map:
            output += " " + str(core_map[(s, c)]).ljust(max_core_map_len)
        else:
            output += " " * (max_core_map_len + 1)
    print(output)

OUTPUT:

$ python cpu_topo.py
======================================================================
Core and Socket Information (as reported by '/sys/devices/system/cpu')
======================================================================

cores =  [0, 1, 2, 3, 4, 5]
sockets =  [0, 1]

       Socket 0    Socket 1
       --------    --------
Core 0 [0]         [6]
Core 1 [1]         [7]
Core 2 [2]         [8]
Core 3 [3]         [9]
Core 4 [4]         [10]
Core 5 [5]         [11]

上述输出的意义:

    有两个 Socket(物理 CPU)
    每个 Socket 有 6 个 Core(物理核),总计 12 个

Output:

$ python cpu_topo.py
======================================================================
Core and Socket Information (as reported by '/sys/devices/system/cpu')
======================================================================

cores =  [0, 1, 2, 3, 4, 5]
sockets =  [0, 1]

       Socket 0        Socket 1
       --------        --------
Core 0 [0, 12]         [6, 18]
Core 1 [1, 13]         [7, 19]
Core 2 [2, 14]         [8, 20]
Core 3 [3, 15]         [9, 21]
Core 4 [4, 16]         [10, 22]
Core 5 [5, 17]         [11, 23]

    有两个 Socket(物理 CPU)。
    每个 Socket 有 6 个 Core(物理核),总计 12 个。
    每个 Core 有两个 Virtual Processor,总计 24 个。

NUMA 架构中的多线程性能开销
1、跨 Node 的 Memory 访问开销

NUMA(非一致性存储器访问)的意思是 Kernel Thread 访问 Local Memory 和 Remote Memory 所需要的耗时是不一样的。

NUMA 的 CPU 分配策略有下 2 种:

    cpu-node-bind:约束 Kernel Thread 运行在指定的若干个 NUMA Node 上。
    phys-cpu-bind:约束 Kernel Thread 运行在指定的若干个 CPU Core 上。

NUMA 的 Memory 分配策略有下列 4 种:

    local-alloc:约束 Kernel Thread 只能访问 Local Node Memory。
    preferred:宽松地为 Kernel Thread 指定一个优先 Node,如果优先 Node 上没有足够的 Memory 资源,则允许运行在访问 Remote Node Memory。
    mem-bind:规定 Kernel Thread 只能请求指定的若干个 Node 上的 Memory,但并不严格规定只能访问 Local NUMA Memory。
    inter-leave:规定 Kernel Thread 可以使用 RR 算法轮转地从指定的若干个 Node 上请求访问 Memory。

2、跨 Core 的多线程 Cache 同步开销

NUMA Domain Scheduler 是 Kernel 针对 NUMA 体系架构实现的 Kernel Thread 调度器,目的是为了让 NUMA 中的每个 Core 都尽量均衡的忙碌。

根据 NUMA Topology 的特性呈一颗树状结构。NUMA Domain Scheduling,从叶节点向上根节点遍历,直到所有的 NUMA Domain 中的负载都是均衡的。当然,用户可以对不同的 Domain 设置相应的调度策略。

FluxBB bbcode 测试

但这种针对所有 Cores 的均衡优化是有代价的,比如:将同一个 User Process 对应若干个 Kernel Thread 均衡到不同的 Cores 上执行,会使得 Core Cache 失效,造成性能下降。

    Cache 可见性(并发安全)问题:分别在 Core1 和 Core2 上运行的 Kernel Thread 都会在各自的 L1/L2 Cache 中缓存数据,但这些数据对彼此是不可见的,即:如果在 Core1 不将 Cache 中的数据写回到 Main Memory 的前提下,Core2 永远看不见 Core1 对某个变量数值的修改。继而会导致多线程共享数据不一致的情况。
    Cache 一致性(并发性能)问题:如果多个 Kernel Thread 运行在多个 Cores 上,同时这些 Threads 之间存在共享数据,而这些数据有存储在 Cache 中,那么就存在 Cache 一致性数据同步的必要。例如:分别在 Core1 和 Core2 上运行的 Kernel Thread 希望保证共享数据是一致的,那么就需要强制性的将 Core1 Cache 中对变量数值的修改写回到 Main Memory,然后 Core1 通知 Core2 数值更新了,再让 Core2 从 Main Memory 获取到最新的数值,并加载到 Core2 Cache 中。为了维护 Cache 数据的一致性所产生的流量会为主存数据总线带来压力,继而影响到 CPU 的性能。
    Cache 失效性(并发性能)问题:如果出于均衡的考虑,调度器会主动出发线程切换,例如:将在 Core1 上运行的 Kernel Thread 动态的调度到另一个空闲的 Core2 上运行,那么在 Core1 Cache 上的数据就需要先写回到 Memory,然后再进行调度。如果此时 Core1 和 Core2 分属于不同的 NUMA Node,那么就会出现更加耗时的 Remote Memory 访问。

FluxBB bbcode 测试

如下图所示,在不同的 Domain 中存在着不同的 Cache 成本。虽然 NUMA Domain Scheduling 自身也具有软亲和特性,但其到底是侧重于 NUMA Cores 的均衡调度,而不是保证应用程序的执行性能。

可见,NUMA Domain Scheduler 的均衡调度机制和高并发性能是相悖的。
FluxBB bbcode 测试

3、多线程上下文切换开销

在 Core 执行任务期间,需要将线程的执行现场信息存储在 Core 的 Register 和 Cache 中,这些数据集称为 Context(上下文),有下列 3 种类型:

    User Level Context:PC 程序计数器、寄存器、线程栈等。
    Register Context:通用寄存器、PC 程序寄存器、处理器状态寄存器、栈指针等。
    Kernel Level Context:进程描述符(task_struct)、PC 程序计数器、寄存器、虚拟地址空间等。

多线程的 Context Switch(上下文切换)也可以分为 2 个层面:

    User Level Thread 层面:由高级编程语言线程库实现的 Multiple User Threads,在单一个 Core 上进行时间分片轮训被动切换,或协作式自动切换。由于 User Thread 的 User Level Context 非常轻量,且共享同一个 User Process 的虚拟地址空间,所以 User Level 层面的 Context Switch 开销小,速度快。
    Kernel Level Thread 层面:Multiple Kernel Threads 由 Kernel 中的 NUMA Domain Scheduler 在多个 Cores 上进行调度和切换。由于 Kernel Thread 的 Context 更大(Kernel Thread 执行现场,包括:task_struct 结构体、寄存器、程序计数器、线程栈等),且涉及跨 Cores 之间的数据同步和主存访问,所以 Kernel Level 层面的 Context Switch 开销大,速度慢。

进行线程切换的过程中,首先会将一个线程的 Context 存储在相应的用户或内核堆栈中,然后把下一个要运行的线程的 Context 加载到 Core 的 Register 和 Cache 中。

FluxBB bbcode 测试

可见,多线程的 Context Switch 势必会导致处理器性能的下降。并且 User Level 和 Kernel Level 切换很可能是同时出现的,这些都是应用多线程模式所需要付出的代价。

使用 vmstat 指令查看当前系统的上下文切换情况:

$ vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  1      0 4505784 313592 7224876    0    0     0    23    1    2  2  1 94  3  0

    r:CPU 运行队列的长度和正在运行的线程数。
    b:正在阻塞的进程数。
    swpd:虚拟内存已使用的大小,如果大于 0,表示机器的物理内存不足了。如果不是程序内存泄露的原因,那么就应该升级内存或者把耗内存的任务迁移到其他机器上了。
    si:每秒从磁盘读入虚拟内存的大小,如果大于 0,表示物理内存不足或存在内存泄露,应该杀掉或迁移耗内存大的进程。
    so:每秒虚拟内存写入磁盘的大小,如果大于 0,同上。
    bi:块设备每秒接收的块数量,这里的块设备是指系统上所有的磁盘和其他块设备,默认块大小是 1024Byte。
    bo:块设备每秒发送的块数量,例如读取文件时,bo 就会大于 0。bi 和 bo 一般都要接近 0,不然就是 I/O 过于频繁,需要调整。
    in:每秒 CPU 中断的次数,包括时间中断。
    cs:每秒上下文切换的次数,这个值要越小越好,太大了,要考虑减少线程或者进程的数目。上下文切换次数过多表示 CPU 的大部分时间都浪费在上下文切换了而不是在执行任务。
    st:CPU 在虚拟化环境上在其他租户上的开销。

4、CPU 运行模式切换开销

CPU 运行模式切换同样会对执行性能造成影响,不过相对于上下文切换会更低一些,因为模式切换最主要的任务只是切换线程寄存器的上下文。

Linux 系统中的以下操作会触发 CPU 运行模式切换:

    系统调用 / 软中断:当应用程序需要访问 Kernel 资源时,需要通过 SCI 进入内核模式执行相应的内核代码,完成所需操作后再返回到用户模式。
    中断处理:当外设发生中断事件时,会向 CPU 发出中断信号,此时 Kernel 需要立即响应中断,进入内核模式执行相应的中断处理程序,处理完后再返回用户模式。
    异常处理:当 Kernel 出现运行时错误或其他异常情况,如:页错误、除零错误、非法操作等,操作系统需要进入内核模式执行相应的异常处理程序,进行错误恢复或提示,然后再返回用户模式。
    Kernel Thread 切换:当 User Process 下属的 Kernel Thread 进行切换时,首先需要切换相应的 Kernel Level Context 并执行,最后再返回用户模式下执行 User Process 的代码。

FluxBB bbcode 测试

5、中断处理的开销

硬件中断(HW Interrupt)是一种外设(e.g. 网卡、磁盘控制器、鼠键、串行适配卡等)和 CPU 交互通信的机制,让 CPU 能够及时掌握外设发生的事件,并视乎于中断的类型来决定是否放下当前任务,尽快处理紧急的外设事件(e.g. 以太网数据帧到达,键盘输入)。

硬件中断的本质是一个 IRQ(中断请求信号)电信号。Kernel 为每个外设分配了一个 IRQ Number,以此来区分发出中断的设备类型。IRQ Number 又会映射到 Kernel ISR(中断服务路由列表)中的一个中断处理程序(通常又外设驱动提供)。

硬件中断是 Kernel 调度优先级最高的任务类型之一,进行抢占式调度,所以硬件中断通常都伴随着任务切换,将当前任务切换到中断处理程序的上下文。

一次中断处理,首先需要将 CPU 的状态寄存器数据保存到虚拟内存空间中的堆栈,然后运行中断服务程序,最后再将状态寄存器数据从堆栈中夹在到 CPU。整个过程需要至少 300 个 CPU 时钟周期。并且在多核处理器计算平台中,每个 Core 都有可能执行硬件中断处理程序,所以还存在着跨 Core 处理要面对的 Cache 一致性流量的问题。

可见,大量的中断处理,尤其是硬件中断处理会非常消耗 CPU 资源。
6、TLB 缓存失效的开销

因为 TLB(地址映射表高速缓存)的空间非常有限,在使用 4K 小页的操作系统中,出现 Kernel Thread 频繁切换时,会导致 TLB 缓存的虚拟地址空间映射条目频繁变更,产生大量的缓存缺失。
7、内存拷贝的开销

在网络报文处理场景中,NIC Driver 运行在内核态,当 Driver 收到的报文后,首先会拷贝到 TCP/IP Stack 处理,然后再拷贝到用户空间的应用程序缓冲区。这些拷贝处理的时间会占报文处理总时长的 57.1%。
NUMA 架构中的性能优化:使用多核编程代替多线程

为了解决上述问题,在 NUMA 架构中进一步提升多核处理器平台的性能,应该广泛采用 “多核编程代替多线程编程” 的思想,通过将 Kernel Threrad 与 NUMA Node 或 Core 建立亲和性,以此来避免多线程调度带来的开销。
NUMA 亲和性:避免 CPU 跨 NUMA 访问内存

在 Linux Shell 上,可以使用 numastat 指令来查看 NUMA Node 的内存分配统计数据;可以使用 numactl 指令可以将 User Process 绑定到指定的 NUMA Node,还可以绑定到指定的 NUMA Core 上。
CPU 亲和性:避免跨 CPU Cores 的 Kernel Thread 切换

CPU 亲和性(CPU Affinity)是 Kernel 的一种 Kernel Thread 调度属性(Scheduling Property),指定 Kernel Thread 要在特定的 CPU 上尽量长时间地运行而不被调度到其他的 CPU 上。在 NUMA 架构中,设置 Kernel Thread 的 CPU 亲和性,能够有效提高 Thread 的 CPU Cache 命中率,减少 Remote NUMA Memory 访问的损耗,以获得更高的性能。

    软 CPU 亲和性:是 Linux Scheduler 的默认调度策略,调度器会积极的让 Kernel Thread 在同一个 CPU 上运行。
    硬 CPU 亲和性:是 Linux Kernel 提供的可编程 CPU 亲和性,用户程序可以显式地指定 User Process 对应的 Kernel Thread 在哪个或哪些 CPU 上运行。

硬 CPU 亲和性通过扩展 task_struct(进程描述符)结构体来实现,引入 cpus_allowed 字段来表示 CPU 亲和位掩码(BitMask)。cpus_allowed 由 n 位组成,对应系统中的 n 个 Processor。最低位表示第一个 Processor,最高位表示最后一个 Processor,通过对掩码位置 1 来指定 Processors 亲和,当有多个掩码位被置 1 时表示运行进程在多个 Processor 间迁移,缺省为全部位置 1。进程的 CPU 亲和特性会传递给子线程。

在 Linux Shell 上,可以使用 taskset 指令来设定 User Process 的 CPU 亲和性,但不能保证 NUMA 亲和性的内存分配。
IRQ(中断请求)亲和性

Linux Kernel 提供了 irqbalance 程序来进行中断负载优化,在大部分场景中,irqbalance 提供的中断分配优化都是可以起到积极作用的,irqbalance 会自动收集系统数据来分析出使用模式,并依据系统负载状况将工作状态调整为以下 2 种模式:

    Performance mode:irqbalance 会将中断尽可能均匀地分发给各个 CPU 的 Core,以充分提升性能。
    Power-save mode:irqbalance 会将中断处理集中到第一个 CPU,保证其它空闲 CPU 的睡眠时间,降低能耗。

当然,硬件中断处理也具有亲和性属性,用于指定运行 IRP 对应的 ISR 的 CPU。在 Linux Shell 上,可以修改指定 IRQ Number 的 smp_affinity。注意,手动指定 IRQ 亲和性首先需要关闭 irqbalance 守护进程。

#16 进程模块 » Gentoo 实现原理 — 进程调度与策略配置 » 2024-03-25 22:32:59

batsom
回复: 0

进程调度

进程调度,即 Linux Kernel Scheduler 如何将多个 User Process 调度给 CPU 执行,从而实现多任务执行环境的公平竞争以及合理分配 CPU 资源。

在古早的单核环境中,Linux Scheduler 的主要目的是通过 "时间片轮转算法" 和 “优先级调度算法“ 来实现调度。而在现代多核环境中,Linux Scheduler 则需要考虑更多的复杂因素,如:CPU 负载均衡、Cache 亲和性、多核互斥等。所以本文主要讨论的是多核环境中的进程调度。

为了应对不同应用场景中的进程调度需求,Linux Kernel 实现了多种 Scheduler 类型,常见的有:

    CFS(Completely Fair Scheduler,完全公平调度器)
    RT(Real-time Scheduler,实时调度器)
    DS(Deadline Scheduler,最后期限调度器)

FluxBB bbcode 测试

这些 Scheduler 会被作用于每个 CPU Cores 的 “就绪队列“ 中,且具有各自不同的调度算法和优先级策略。

FluxBB bbcode 测试

在操作系统层面用户可以操作的只有用户进程实体,所以我们能够看见并使用的大多数调度配置都是针对 User Process 而言。

如下图,Kernel 将进程分为 2 大类型,对应各自的优先级区域以及不同的调度算法。

    实时进程:具有实时性要求,共有 0~99 这 100 个优先级。
    普通进程:没有实时性要求,共有 100~139 这 40 个级别。

FluxBB bbcode 测试

但实际上,实时进程的优先级是初设后不可更改的。也就是说,从系统管理员的角度(Shell)只能配置普通进程的优先级。

针对普通进程的优先级配置,Linux 引入了 Nice level 的设计,Nice 值的范围是 -20~19 刚好对应到普通进程的 40 个优先级。其中,普通用户可以配置的范围是 0~19,而 Root 管理员则可以配置 -20~19。

FluxBB bbcode 测试

CFS 完全公平调度器

Linux CFS(Completely Fair Scheduler,完全公平调度器)是 Kernel 默认使用的进程调度器,常用于通用计算场景。

CFS 的 “完全公平“ 并不是简单粗暴的按照 CPU 时间片大小来进行调度,而是会根据进程的运行时间来计算出优先级,运行时间较短的进程会拥有更高的优先级,从而保证了每个进程都能够获得公平的 CPU 时间。

具体而言,CFS 是一种基于红黑树的调度算法,它的目标是让所有进程都可以获得相同的 CPU 时间片。实现原理如下:

    CFS 在每个 CPU 上都有一棵红黑树,每个节点对应一个普通进程的 PCB(task_struct)和一个 Key。这个 Key 是进程的一个 VRT(虚拟运行时间),反应了进程在 CPU 上的运行时间。运行时间越长,VRT 就越大,优先级就越小。
    当一个新的普通进程被创建时,它会被加入到红黑树中,并且初始的 VRT 值为 0,表示拥有最高调度优先级。
    当 CPU 空闲时,就查询红黑树,并将 VRT 最小的就绪进程调度执行,完毕后增加其 VRT,降低其下次调度的优先级。

可见,CFS 的优点让每个进程都获得了公平的 CPU 时间。然而,CFS 的缺点是由于红黑树的操作复杂度较高,对于高并发的场景可能会影响系统的性能。

FluxBB bbcode 测试

SCHED_NORMAL(普通进程调度算法)

SCHED_NORMAL 是 CFS 的基本实现,采用了上文中提到的 “时间片轮转“ 和 “动态优先级“ 调度机制。

    动态优先级:普通进程具有一个 nice 值来表示其优先级,nice 值越小,进程优先级越高。
    时间片轮转:如果有多个普通进程的优先级相同,则采用轮流执行的方式。

SCHED_BATCH(批量调度算法)

SCHED_BATCH 是一种针对 CPU 密集型批处理作业的调度算法。它的主要目的是在系统空闲时间运行一些需要大量 CPU 时间的后台任务。

区别于 SCHED_NORMAL,它并不使用时间片轮转和动态优先级调度机制,而是采用了一种基于进程组的批量处理策略。该算法会将所有的后台任务进程加入到一个进程组中,该进程组会共享一个可调度时间片。

在 SCHED_BATCH 中,进程组会被赋予更高的优先级,以确保后台任务能够在系统空闲时间得到足够的 CPU 时间。
RTS 实时调度器

Linux RTS(Real-Time Scheduler,实时调度器)采用固定优先级调度算法,优先级高的进程会获得更多的 CPU 时间。RTS 是 RT-Kernel 的默认调度算法,常用于对实时性要求高的计算场景。

FluxBB bbcode 测试

RTS 的主要目的是保证实时任务的响应性和可预测性。固定优先级调度算法,总是可以让高优先级任务先运行,同时还实现了基于抢占的调度策略,以保证实时任务能够在预定的时间内运行完成。实现原理如下:

    RTS 优先级数值范围从 1(最高)~99(最低),其中 0 保留给 Kernel 使用。
    RTS 还实现了基于抢占的调度策略。当一个高优先级的任务到来时,它可以抢占当前正在运行的任务,并且直到运行完毕。
    RTS 使用了多队列的方法来管理实时进程。RTS 在每个 CPU 上维护 2 级就绪队列,一个是实时队列,一个是普通队列。并采用了不同的调度算法和优先级策略来进行调度。例如:实时进程采用 SCHED_FIFO 调度算法,普通进程采用 SCHED_RR。
    调度器每次选择下一个要运行的进程时,会先从实时队列中选择进程,如果实时队列为空,则从普通队列中选择进程。这样可以保证实时进程的优先级高于普通进程,同时也避免了实时进程长时间等待的情况。

RTS 的优点是能够保证实时任务的响应性和可预测性,但缺点是对于普通任务来说可能会出现长时间等待的情况。

FluxBB bbcode 测试

SCHED_FIFO(先到先服务调度算法)

SCHED_FIFO 调度算法会按照进程的提交顺序来分配 CPU 时间,当一个进程获得 CPU 时间后,它会一直运行直到完成或者被更高优先级的进程抢占。因此,该算法可能导致低优先级进程的饥饿情况,因为高优先级进程可能会一直占用 CPU 时间。
SCHED_RR(时间片轮转调度算法)

与 SCHED_FIFO 类似,SCHED_RR 调度算法也会按照进程的提交顺序来分配 CPU 时间。不同之处在于,每个进程都被赋予一个固定的时间片,当时间片用完后,该进程就会被放回就绪队列的尾部,等待下一次调度。该算法可以避免低优先级进程饥饿的问题,因为每个进程都能够获得一定数量的 CPU 时间,而且高优先级进程也不能一直占用 CPU 时间。
DS 最后期限调度器

Linux DS(Deadline Scheduling,最后期限调度器)是一种基于最后期限(Deadline)的调度器。实现原理如下:

    DS 与 CFS 类似的采用了红黑树,但主要区别在于 DS 的树节点 Key 是 Deadline 值,而不是 VRT。
    DS 为每个进程赋予一个 Deadline,DS 会按照进程的最后期限的顺序,安排进程的执行顺序。进程的最后期限越近,其优先级就越高。
    当 CPU 空闲时,就查询红黑树,并将 Deadline 离与当前时间最近的就绪进程调度执行。

FluxBB bbcode 测试

SCHED_DEADLINE(最后期限调度算法)

SCHED_DEADLINE 调度算法是 DS 调度器的默认调度算法,主要用于实时任务的调度。
进程调度策略的配置
ps 指令

我们在配置一个进程的调度策略之前,常常需要使用 ps 指令查看进程的状态信息。
查看进程资源使用信息

 $ ps aux

USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root          1  0.1  0.0  78088  9188 ?        Ss   04:26   0:03 /sbin/init maybe-ubiquity
...
stack      2152  0.1  0.8 304004 138160 ?       S    04:27   0:04 nova-apiuWSGI worker 1
stack      2153  0.1  0.8 304004 138212 ?       S    04:27   0:04 nova-apiuWSGI worker 2
...

FluxBB bbcode 测试

查看指定进程的 CPU 资源详细使用信息

$ pidstat -p 12285

02:53:02 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:53:02 PM     0     12285    0.00    0.00    0.00    0.00     5  python 

    PID:进程 ID。
    %usr:进程在用户态运行所占 CPU 的时间比率。
    %system:进程在内核态运行所占 CPU 的时间比率。
    %CPU:进程运行所占 CPU 的时间比率。
    CPU:进程在哪个核上运行。
    Command:创建进程对应的命令。

查看进程优先级信息

$ ps -le

F S   UID    PID   PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
4 S     0      1      0  0  80   0 - 19522 ep_pol ?        00:00:03 systemd
1 S     0      2      0  0  80   0 -     0 kthrea ?        00:00:00 kthreadd
1 I     0      4      2  0  60 -20 -     0 worker ?        00:00:00 kworker/0:0H
1 I     0      6      2  0  60 -20 -     0 rescue ?        00:00:00 mm_percpu_wq
... 

    UID:进程执行者 ID。
    PID:进程 ID。
    PPID:父进程 ID。
    PRI:进程优先级,值越小优先级越高。
    NI:进程的 nice 值。

查看系统中所有的实时进程

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
    7     7 FF      99   - 139   0  0.0 FF  S    smpboot_thread migration/0
   10    10 FF      99   - 139   0  0.0 FF  S    smpboot_thread watchdog/0
   11    11 FF      99   - 139   1  0.0 FF  S    smpboot_thread watchdog/1
   12    12 FF      99   - 139   1  0.0 FF  S    smpboot_thread migration/1

查看 nice 不为 0 的普通进程

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm|awk '$4 ~ /-/ &&$5 !~/0/ {print $0}'

   63    63 TS       -   5  14   2  0.0 TS  SN   ksm_scan_threa ksmd
   64    64 TS       -  19   0   2  0.0 TS  SN   khugepaged     khugepaged
12995 12995 TS       -  -4  23   1  0.0 TS  S<sl ep_poll        auditd

查看进程运行状态及其内核函数名称

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:34,nwchan,pcpu,comm

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN                               WCHAN %CPU COMMAND
    1     1 TS       -   0  19   4  0.0 TS  Ssl  ep_poll                            ffffff  0.0 systemd
    2     2 TS       -   0  19   0  0.0 TS  S    kthreadd                            b1066  0.0 kthreadd
    3     3 TS       -   0  19   0  0.0 TS  S    smpboot_thread_fn                   b905d  0.0 ksoftirqd/0
...
   44    44 TS       -   0  19   7  0.0 TS  R    -                                       -  0.0 kworker/7:0

    wchan:显示进程处于休眠状态的内核函数名称,如果进程正在运行则为 -,如果进程具有多线程且 ps 指令未显示,则为 *。
    nwchan:显示进程处于休眠状态的内核函数地址,正在运行的任务将在此列中显示短划线 -。

nice 指令

nice 指令用于修改普通进程的 nice 值。
设定即将启动的普通进程的 nice 值

nice -n -5 service httpd start

修改已经存在的普通进程的 nice 值

$ ps -le | grep nova-compute
4 S  1000  9301     1  2  80   0 - 530107 ep_pol ?       00:02:50 nova-compute

$ renice -10 9301
9301 (process ID) old priority 0, new priority -10

$ ps -le | grep nova-compute
4 S  1000  9301     1  2  70 -10 - 530107 ep_pol ?       00:02:54 nova-compute

chrt 指令

chrt 指令可用于修改进程的调度算法和优先级。

$ chrt --help
Show or change the real-time scheduling attributes of a process.

Set policy:
 chrt [options] <priority> <command> [<arg>...]
 chrt [options] --pid <priority> <pid>

Get policy:
 chrt [options] -p <pid>

Policy options:
 -b, --batch          set policy to SCHED_BATCH
 -d, --deadline       set policy to SCHED_DEADLINE
 -f, --fifo           set policy to SCHED_FIFO
 -i, --idle           set policy to SCHED_IDLE
 -o, --other          set policy to SCHED_OTHER
 -r, --rr             set policy to SCHED_RR (default)

修改进程的调度算法

$ chrt -r 10 bash

$ chrt -p $$
pid 13360's current scheduling policy: SCHED_RR
pid 13360's current scheduling priority: 10

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
13360 13360 RR      10   -  50   7  0.0 RR  S    do_wait        bash

修改实时进程的优先级

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
   27    27 FF      99   - 139   4  0.0 FF  S    smpboot_thread migration/4

$ chrt -p 31
pid 31's current scheduling policy: SCHED_FIFO
pid 31's current scheduling priority: 99

$ chrt -f -p 50 31

$ chrt -p 31
pid 31's current scheduling policy: SCHED_FIFO
pid 31's current scheduling priority: 50

#17 内核模块 » Gentoo 之 RCU基础 » 2024-03-25 21:03:09

batsom
回复: 0

一、前言

关于RCU的文档包括两份,一份讲基本的原理(也就是本文了),一份讲linux kernel中的实现。第二章描述了为何有RCU这种同步机制,特别是在cpu core数目不断递增的今天,一个性能更好的同步机制是如何解决问题的,当然,再好的工具都有其适用场景,本章也给出了RCU的一些应用限制。第三章的第一小节描述了RCU的设计概念,其实RCU的设计概念比较简单,比较容易理解,比较困难的是产品级别的RCU实现,我们会在下一篇文档中描述。第三章的第二小节描述了RCU的相关操作,其实就是对应到了RCU的外部接口API上来。最后一章是参考文献,perfbook是一本神奇的数,喜欢并行编程的同学绝对不能错过的一本书,强烈推荐。和perfbook比起来,本文显得非常的丑陋(主要是有些RCU的知识还是理解不深刻,可能需要再仔细看看linux kernel中的实现才能了解其真正含义),除了是中文表述之外,没有任何的优点,英语比较好的同学可以直接参考该书。

二、为何有RCU这种同步机制呢?

前面我们讲了spin lock,rw spin lock和seq lock,为何又出现了RCU这样的同步机制呢?这个问题类似于问:有了刀枪剑戟这样的工具,为何会出现流星锤这样的兵器呢?每种兵器都有自己的适用场合,内核同步机制亦然。RCU在一定的应用场景下,解决了过去同步机制的问题,这也是它之所以存在的基石。本章主要包括两部分内容:一部分是如何解决其他内核机制的问题,另外一部分是受限的场景为何?

1、性能问题

我们先回忆一下spin lcok、RW spin lcok和seq lock的基本原理。对于spin lock而言,临界区的保护是通过next和owner这两个共享变量进行的。线程调用spin_lock进入临界区,这里包括了三个动作:

(1)获取了自己的号码牌(也就是next值)和允许哪一个号码牌进入临界区(owner)

(2)设定下一个进入临界区的号码牌(next++)

(3)判断自己的号码牌是否是允许进入的那个号码牌(next == owner),如果是,进入临界区,否者spin(不断的获取owner的值,判断是否等于自己的号码牌,对于ARM64处理器而言,可以使用WFE来降低功耗)。

注意:(1)是取值,(2)是更新并写回,因此(1)和(2)必须是原子操作,中间不能插入任何的操作。

线程调用spin_unlock离开临界区,执行owner++,表示下一个线程可以进入。

RW spin lcok和seq lock都类似spin lock,它们都是基于一个memory中的共享变量(对该变量的访问是原子的)。我们假设系统架构如下:

FluxBB bbcode 测试

当线程在多个cpu上争抢进入临界区的时候,都会操作那个在多个cpu之间共享的数据lock(玫瑰色的block)。cpu 0操作了lock,为了数据的一致性,cpu 0的操作会导致其他cpu的L1中的lock变成无效,在随后的来自其他cpu对lock的访问会导致L1 cache miss(更准确的说是communication cache miss),必须从下一个level的cache中获取,同样的,其他cpu的L1 cache中的lock也被设定为invalid,从而引起下一次其他cpu上的communication cache miss。

RCU的read side不需要访问这样的“共享数据”,从而极大的提升了reader侧的性能。

2、reader和writer可以并发执行

spin lock是互斥的,任何时候只有一个thread(reader or writer)进入临界区,rw spin lock要好一些,允许多个reader并发执行,提高了性能。不过,reader和updater不能并发执行,RCU解除了这些限制,允许一个updater(不能多个updater进入临界区,这可以通过spinlock来保证)和多个reader并发执行。我们可以比较一下rw spin lock和RCU,参考下图:

FluxBB bbcode 测试

rwlock允许多个reader并发,因此,在上图中,三个rwlock reader愉快的并行执行。当rwlock writer试图进入的时候(红色虚线),只能spin,直到所有的reader退出临界区。一旦有rwlock writer在临界区,任何的reader都不能进入,直到writer完成数据更新,立刻临界区。绿色的reader thread们又可以进行愉快玩耍了。rwlock的一个特点就是确定性,白色的reader一定是读取的是old data,而绿色的reader一定获取的是writer更新之后的new data。RCU和传统的锁机制不同,当RCU updater进入临界区的时候,即便是有reader在也无所谓,它可以长驱直入,不需要spin。同样的,即便有一个updater正在临界区里面工作,这并不能阻挡RCU reader的步伐。由此可见,RCU的并发性能要好于rwlock,特别如果考虑cpu的数目比较多的情况,那些处于spin状态的cpu在无谓的消耗,多么可惜,随着cpu的数目增加,rwlock性能不断的下降。RCU reader和updater由于可以并发执行,因此这时候的被保护的数据有两份,一份是旧的,一份是新的,对于白色的RCU reader,其读取的数据可能是旧的,也可能是新的,和数据访问的timing相关,当然,当RCU update完成更新之后,新启动的RCU reader(绿色block)读取的一定是新的数据。

3、适用的场景

我们前面说过,每种锁都有自己的适用的场景:spin lock不区分reader和writer,对于那些读写强度不对称的是不适合的,RW spin lcok和seq lock解决了这个问题,不过seq lock倾向writer,而RW spin lock更照顾reader。看起来一切都已经很完美了,但是,随着计算机硬件技术的发展,CPU的运算速度越来越快,相比之下,存储器件的速度发展较为滞后。在这种背景下,获取基于counter(需要访问存储器件)的锁(例如spin lock,rwlock)的机制开销比较大。而且,目前的趋势是:CPU和存储器件之间的速度差别在逐渐扩大。因此,那些基于一个multi-processor之间的共享的counter的锁机制已经不能满足性能的需求,在这种情况下,RCU机制应运而生(当然,更准确的说RCU一种内核同步机制,但不是一种lock,本质上它是lock-free的),它克服了其他锁机制的缺点,但是,甘蔗没有两头甜,RCU的使用场景比较受限,主要适用于下面的场景:

(1)RCU只能保护动态分配的数据结构,并且必须是通过指针访问该数据结构

(2)受RCU保护的临界区内不能sleep(SRCU不是本文的内容)

(3)读写不对称,对writer的性能没有特别要求,但是reader性能要求极高。

(4)reader端对新旧数据不敏感。

三、RCU的基本思路

1、原理

RCU的基本思路可以通过下面的图片体现:

FluxBB bbcode 测试

RCU涉及的数据有两种,一个是指向要保护数据的指针,我们称之RCU protected pointer。另外一个是通过指针访问的共享数据,我们称之RCU protected data,当然,这个数据必须是动态分配的  。对共享数据的访问有两种,一种是writer,即对数据要进行更新,另外一种是reader。如果在有reader在临界区内进行数据访问,对于传统的,基于锁的同步机制而言,reader会阻止writer进入(例如spin lock和rw spin lock。seqlock不会这样,因此本质上seqlock也是lock-free的),因为在有reader访问共享数据的情况下,write直接修改data会破坏掉共享数据。怎么办呢?当然是移除了reader对共享数据的访问之后,再让writer进入了(writer稍显悲剧)。对于RCU而言,其原理是类似的,为了能够让writer进入,必须首先移除reader对共享数据的访问,怎么移除呢?创建一个新的copy是一个不错的选择。因此RCU writer的动作分成了两步:

(1)removal。write分配一个new version的共享数据进行数据更新,更新完毕后将RCU protected pointer指向新版本的数据。一旦把RCU protected pointer指向的新的数据,也就意味着将其推向前台,公布与众(reader都是通过pointer访问数据的)。通过这样的操作,原来read 0、1、2对共享数据的reference被移除了(对于新版本的受RCU保护的数据而言),它们都是在旧版本的RCU protected data上进行数据访问。

(2)reclamation。共享数据不能有两个版本,因此一定要在适当的时机去回收旧版本的数据。当然,不能太着急,不能reader线程还访问着old version的数据的时候就强行回收,这样会让reader crash的。reclamation必须发生在所有的访问旧版本数据的那些reader离开临界区之后再回收,而这段等待的时间被称为grace period。

顺便说明一下,reclamation并不需要等待read3和4,因为write端的为RCU protected pointer赋值的语句是原子的,乱入的reader线程要么看到的是旧的数据,要么是新的数据。对于read3和4,它们访问的是新的共享数据,因此不会reference旧的数据,因此reclamation不需要等待read3和4离开临界区。

2、基本RCU操作

对于reader,RCU的操作包括:

(1)rcu_read_lock,用来标识RCU read side临界区的开始。

(2)rcu_dereference,该接口用来获取RCU protected pointer。reader要访问RCU保护的共享数据,当然要获取RCU protected pointer,然后通过该指针进行dereference的操作。

(3)rcu_read_unlock,用来标识reader离开RCU read side临界区

对于writer,RCU的操作包括:

(1)rcu_assign_pointer。该接口被writer用来进行removal的操作,在witer完成新版本数据分配和更新之后,调用这个接口可以让RCU protected pointer指向RCU protected data。

(2)synchronize_rcu。writer端的操作可以是同步的,也就是说,完成更新操作之后,可以调用该接口函数等待所有在旧版本数据上的reader线程离开临界区,一旦从该函数返回,说明旧的共享数据没有任何引用了,可以直接进行reclaimation的操作。

(3)call_rcu。当然,某些情况下(例如在softirq context中),writer无法阻塞,这时候可以调用call_rcu接口函数,该函数仅仅是注册了callback就直接返回了,在适当的时机会调用callback函数,完成reclaimation的操作。这样的场景其实是分开removal和reclaimation的操作在两个不同的线程中:updater和reclaimer。

以上转自:http://www.wowotech.net/kernel_synchronization/rcu_fundamentals.html

以下使用内核input子系统来介绍其具体应用:

static void evdev_events(struct input_handle *handle,
			 const struct input_value *vals, unsigned int count)
{
	struct evdev *evdev = handle->private;
	struct evdev_client *client;
	ktime_t time_mono, time_real;
 
	time_mono = ktime_get();
	time_real = ktime_mono_to_real(time_mono);
 
	rcu_read_lock();
 
	client = rcu_dereference(evdev->grab);
 
	if (client)
		evdev_pass_values(client, vals, count, time_mono, time_real);
	else
		list_for_each_entry_rcu(client, &evdev->client_list, node)
			evdev_pass_values(client, vals, count,
					  time_mono, time_real);
 
	rcu_read_unlock();
} 

#18 内核模块 » Gentoo 之 RCU Subsystem » 2024-03-24 23:37:47

batsom
回复: 0

1、简介:

RCU(Read-Copy Update)是数据同步的一种方式,在当前的Linux内核中发挥着重要的作用。

RCU主要针对的数据对象是链表,目的是提高遍历读取数据的效率,为了达到目的使用RCU机制读取数据的时候不对链表进行耗时的加锁操作。这样在同一时间可以有多个线程同时读取该链表,并且允许一个线程对链表进行修改(修改的时候,需要加锁)。
2、应用场景:

RCU适用于需要频繁的读取数据,而相应修改数据并不多的情景,例如在文件系统中,经常需要查找定位目录,而对目录的修改相对来说并不多,这就是RCU发挥作用的最佳场景。
3、相应资料:

Linux内核源码当中,关于RCU的文档比较齐全,你可以在 /Documentation/RCU/ 目录下找到这些文件。

Paul E. McKenney 是内核中RCU源码的主要实现者,他也写了很多RCU方面的文章。他把这些文章和一些关于RCU的论文的链接整理到了一起。相应链接如下:
https://www2.rdrop.com/users/paulmck/RCU/
4、实现过程:

在RCU的实现过程中,我们主要解决以下问题:

1,在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)。

2,在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)。

3, 保证读取链表的完整性。新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点。
4.1 宽限期:

通过例子,方便理解这个内容。以下例子修改于Paul的文章。

struct foo{
     int a;
     char b;
     long c;
 };
 
 DEFINE_SPINLOCK(foo_mutex);
 
 void foo_read(void)
 {
     foo *fp = gbl_foo;
     if( fp != NULL )
     {
         dosomthing(fp->a, fp->b, fp->c);
     }
 }
 
 void foo_update(foo * new_fp)
 {
     spin_lock(&foo_mutex);
     foo *old_fp = gbl_foo;
     gbl_foo = new_fp;
     spin_unlock(&foo_mutex);
 } 

如上的程序,是针对于全局变量gbl_foo的操作。假设以下场景。有两个线程同时运行 foo_ read和foo_update的时候,当foo_ read执行完赋值操作后,线程发生切换;此时另一个线程开始执行foo_update并执行完成。当foo_ read运行的进程切换回来后,运行dosomething 的时候,fp已经被删除,这将对系统造成危害。为了防止此类事件的发生,RCU里增加了一个新的概念叫宽限期(Grace period)。如下图所示:
FluxBB bbcode 测试

图中每行代表一个线程,最下面的一行是删除线程,当它执行完删除操作后,线程进入了宽限期。宽限期的意义是,在一个删除动作发生后,它必须等待所有在宽限期开始前已经开始的读线程结束,才可以进行销毁操作。这样做的原因是这些线程有可能读到了要删除的元素。图中的宽限期必须等待1和2结束;而读线程5在宽限期开始前已经结束,不需要考虑;而3,4,6也不需要考虑,因为在宽限期结束后开始后的线程不可能读到已删除的元素。为此RCU机制提供了相应的API来实现这个功能。

void foo_read(void)
 {
     rcu_read_lock();
     foo *fp = gbl_foo;
     if( fp != NULL )
         dosomthing(fp->a, fp->b, fp->c);
     rcu_read_unlock();
 }
 
 void foo_update(foo *new_fp)
 {
     spin_lock(&foo_mutex);
     foo *old_fp = gbl_foo;
     gbl_foo = new_fp;
     spin_unlock(&foo_mutex);
     synchronize_rcu();
     kfree(old_fp);
 } 

其中foo_read中增加了rcu_read_lock和rcu_read_unlock,这两个函数用来标记一个RCU读过程的开始和结束。其实作用就是帮助检测宽限期是否结束。foo_update增加了一个函数synchronize_rcu(),调用该函数意味着一个宽限期的开始,而直到宽限期结束,该函数才会返回。我们再对比着图看一看,线程1和2,在synchronize_rcu之前可能得到了旧的gbl_foo,也就是foo_update中的old_fp,如果不等它们运行结束,就调用kfee(old_fp),极有可能造成系统崩溃。而3,4,6在synchronize_rcu之后运行,此时它们已经不可能得到old_fp,此次的kfee将不对它们产生影响。

宽限期是RCU实现中最复杂的部分,原因是在提高读数据性能的同时,删除数据的性能也不能太差。
4.2 订阅——发布机制:

当前使用的编译器大多会对代码做一定程度的优化,CPU也会对执行指令做一些优化调整,目的是提高代码的执行效率,但这样的优化,有时候会带来不期望的结果。如例:

 void foo_update(foo *new_fp)
 {
     spin_lock(&foo_mutex);
     foo *old_fp = gbl_foo;
     
     new_fp->a = 1;
     new_fp->b = 'b';
     new_fp->c = 100;
     
     gbl_foo = new_fp;
     spin_unlock(&foo_mutex);
     synchronize_rcu();
     kfree(old_fp);
 } 

这段代码中,我们期望的是6,7,8行的代码在第10行代码之前执行。但优化后的代码并不对执行顺序做出保证。在这种情形下,一个读线程很可能读到 new_fp,但new_fp的成员赋值还没执行完成。当读线程执行dosomething(fp->a, fp->b , fp->c ) 的 时候,就有不确定的参数传入到dosomething,极有可能造成不期望的结果,甚至程序崩溃。可以通过优化屏障来解决该问题,RCU机制对优化屏障做了包装,提供了专用的API来解决该问题。这时候,第十行不再是直接的指针赋值,而应该改为 :

rcu_assign_pointer(gbl_foo,new_fp);

rcu_assign_pointer的实现比较简单,如下:

  #define rcu_assign_pointer(p, v) \
     __rcu_assign_pointer((p), (v), __rcu)


 #define RCU_INIT_POINTER(p, v) \
         p = (typeof(*v) __force __rcu *)(v)

在DEC Alpha CPU机器上还有一种更强悍的优化,如下所示:

 void foo_read(void)
 {
     rcu_read_lock();
     foo *fp = gbl_foo;
     if( fp != NULL )
         dosomthing(fp->a, fp->b, fp->c);
     rcu_read_unlock();
 } 

第六行的 fp->a,fp->b,fp->c会在第3行还没执行的时候就预先判断运行,当他和foo_update同时运行的时候,可能导致传入dosomething的一部分属于旧的gbl_foo,而另外的属于新的。这样导致运行结果的错误。为了避免该类问题,RCU还是提供了宏来解决该问题:

#define rcu_dereference_check(p, c) \
     __rcu_dereference_check((p), rcu_read_lock_held() || (c), __rcu)
 
 #define __rcu_dereference_check(p, c, space) \
     ({ \
         typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); \
         rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" \
                       " usage"); \
         rcu_dereference_sparse(p, space); \
         smp_read_barrier_depends(); \
         ((typeof(*p) __force __kernel *)(_________p1)); \
     })
 
 static inline int rcu_read_lock_held(void)
 {
     if (!debug_lockdep_rcu_enabled())
         return 1;
     if (rcu_is_cpu_idle())
         return 0;
     if (!rcu_lockdep_current_cpu_online())
         return 0;
     return lock_is_held(&rcu_lock_map);
 } 

在赋值后加入优化屏障smp_read_barrier_depends()。

我们之前的第四行代码改为 foo *fp = rcu_dereference(gbl_foo);,就可以防止上述问题。
4.3 数据读取的完整性:

还是通过例子来说明这个问题:

FluxBB bbcode 测试

如图我们在原list中加入一个节点new到A之前,所要做的第一步是将new的指针指向A节点,第二步才是将Head的指针指向new。这样做的目的是当插入操作完成第一步的时候,对于链表的读取并不产生影响,而执行完第二步的时候,读线程如果读到new节点,也可以继续遍历链表。如果把这个过程反过来,第一步head指向new,而这时一个线程读到new,由于new的指针指向的是Null,这样将导致读线程无法读取到A,B等后续节点。从以上过程中,可以看出RCU并不保证读线程读取到new节点。如果该节点对程序产生影响,那么就需要外部调用做相应的调整。如在文件系统中,通过RCU定位后,如果查找不到相应节点,就会进行其它形式的查找,相关内容等分析到文件系统的时候再进行叙述。

我们再看一下删除一个节点的例子:
FluxBB bbcode 测试

如图我们希望删除B,这时候要做的就是将A的指针指向C,保持B的指针,然后删除程序将进入宽限期检测。由于B的内容并没有变更,读到B的线程仍然可以继续读取B的后续节点。B不能立即销毁,它必须等待宽限期结束后,才能进行相应销毁操作。由于A的节点已经指向了C,当宽限期开始之后所有的后续读操作通过A找到的是C,而B已经隐藏了,后续的读线程都不会读到它。这样就确保宽限期过后,删除B并不对系统造成影响。
5、小结:

RCU的原理并不复杂,应用也很简单。但代码的实现确并不是那么容易,难点都集中在了宽限期的检测上,后续分析源代码的时候,我们可以看到一些极富技巧的实现方式。

#19 进程模块 » Gentoo 之 Core Scheduling » 2024-03-24 22:46:00

batsom
回复: 0

Core Scheduling要解决什么问题?

core scheduling是v5.14中新增的功能,下图是内核数据结构为该功能所添加的字段。

FluxBB bbcode 测试

为什么有core scheduling呢?因为当开启超线程(HyperThreading)时,一个物理核就变成了两个逻辑核,但,这两个逻辑核还是要共享物理核的某些缓存,这种共享会带来安全问题(例如:MDS、L1TF等)。core scheduling就是要解决 开启超线程(HyperThreading)时所带来的安全问题。

假设,在两个逻辑核上同时在运行进程P1和P2,由于两个逻辑核来源于同一个物理核,两个逻辑核会共享某些资源,这样,P1就可能泄露数据给P2(反之亦然)。怎么解决呢?很简单,core scheduling添加了这种功能:cpu调度器确保, 在同一时刻,绝不让 “不互相信任的进程” 运行在同一个物理核上。对于上面的例子:

1. 如果用户指定了P1和P2互相信任,则,cpu调度器允许,在同一时刻,P1和P2可以运行在同一个物理核上。

2. 如果用户不指定P1和P2互相信任,则,cpu调度器绝不会让P1和P2,在同一个时刻,运行在同一个物理核上。
解释几个名称

本文翻译自 Core Scheduling — The Linux Kernel documentation,文档中出现了“core”, “siblings”等,这里的”core”是指物理核, “siblings”是指开启HT时,所产生的逻辑核。

当开启HT时,一个“core”就变成了两个“siblings”,即,一个物理核会变成两个逻辑核,所以才有我们说的“4核8线程”、“8核16线程”。

此外,当说到“thread”时,其实也是指逻辑核。

可以 cat /proc/cpuinfo 来查看cpu信息

FluxBB bbcode 测试
1. processor:每一个核(包括物理核以及由于HT而传送的逻辑核)。在kernel看来,无论物理核还是逻辑核,都是同等的核。

2. physical id:物理socket。同一个physical id上可以有多个物理核。

3. siblings:在该物理socket内,核的总个数(包括物理核以及由于HT而产生的逻辑核)。

4. core id:在该物理socket内,某个物理核的编号。跨物理socket时,core id会重复。

5. cpu cores:在当前物理socket内,物理核的个数。

怎么判断是否开启了HT呢,很简单:如果siblings == 2*(cpu cores),则开启了HT。

还可以 lscpu 来查看

FluxBB bbcode 测试

lscpu可以查看:

1. 物理socket 的个数。

2. 每个物理socket上 物理核的个数。

3. 每个物理核内逻辑核的个数。

Thread(s) per core:  1;每个物理核内,线程的个数。线程就是逻辑核。若开启HT,此处为2。我这里显示1,即并未开启HT。

Core(s) per socket:  2;每个物理socket内,物理核的个数。

Socket(s):           4;物理socket的个数。

再总结下

1. 同一个物理核会因为开启HT而变为两个核(即:两个逻辑核)。

2. 同一个物理核产生的两个逻辑核是可以并行执行某些指令的,但,两个逻辑核还会共享物理核的某些资源。

3. 在同一个时刻,当两个逻辑核执行的指令需要共享的资源时,那么,只能一个逻辑核运行,另一个等待。

以下翻译 Core Scheduling — The Linux Kernel documentation


正文开始

core scheduling允许userspace定义一组task,这一组task共享同一个core。用户或因为安全用例而分组(组内task 不信任 组外task),或因为性能用例而分组(有些workloads可能会从“运行在同一个core上”而受益,因为它们不需要被共享core上的同种硬件资源,或者,有些workloads更倾向于“运行在不同core上”,因为它们需要共享所需的硬件资源)。本文档仅描述安全用例。
Security usecase

cross-HT attack包含了attacker和victim,它们运行在同一个core的不同超线程上。MDS(Microarchitectural Data Sampling) 和 L1TF(L1 Terminal Fault)就是此类攻击。cross-HT attack唯一的彻底解决方案就是禁用超线程(Hyper Threading)。

core scheduling是scheduler的一个特性,它可以减缓(并不是根除)cross-HT attack。通过确保“只有位于用户指定信任组内的task才能够共享同一个core”,core scheudling就可以安全的开启HT。这种 core共享 还可以提高性能,尽管很多现实世界的实际workloads都显示core scheduling确实提高了性能,但,并不能100%确保它总是可以提高性能的。理论上,core scheduling的性能表现至少应该和HT被禁用时一样好。在实践中,绝大部情况也确实如此,但,也并非100%,这是因为在同一个core中,跨2个或更多个cpu 同步(synchronizing)调度决策引入了额外的overhead,尤其是当系统负载较轻时(lightly loaded),overhead就更为明显。相比于SMT-disabled(即关闭HT),total_threads <= N_CPUS/2时core scheduling引入的额外overhead会使得core scheduling表现更差,这里N_CPUS是cpu的总个数。所以,决定开启core scheduling前,你应该先测试workloads的表现。

Usage

core scheduling通过内核选项CONFIG_SCHED_CORE来开启或禁用。利用该特性,userspace定义一组task,希望这些task总是在同一core上被调度(同一个物理核,多个逻辑核)。利用这些信息,scheduler在满足系统调度需求的同时,确保,在同一个时刻,组内task 和 组外task 永远不会在同一个core上同时运行。

可以通过PR_SCHED_CORE prctl接口来开启core scheduling。该接口用于创建core scheduling组,以及从组中增删task。

 #include <sys/prctl.h>
 
int prctl(int option, unsigned long arg2, unsigned long arg3,
        unsigned long arg4, unsigned long arg5);
 
option:
 
    PR_SCHED_CORE
arg2:
 
    Command for operation, must be one off:
 
        PR_SCHED_CORE_GET – get core_sched cookie of pid.
 
        PR_SCHED_CORE_CREATE – create a new unique cookie for pid.
 
        PR_SCHED_CORE_SHARE_TO – push core_sched cookie to pid.
 
        PR_SCHED_CORE_SHARE_FROM – pull core_sched cookie from pid.
 
arg3:
 
    pid of the task for which the operation applies.
arg4:
 
    pid_type for which the operation applies. 
    It is one of PR_SCHED_CORE_SCOPE_-prefixed macro constants. 
    For example, if arg4 is PR_SCHED_CORE_SCOPE_THREAD_GROUP, 
    then the operation of this command will be performed
    for all tasks in the task group of pid.
arg5:
 
    userspace pointer to an unsigned long for
    storing the cookie returned by PR_SCHED_CORE_GET command.
    Should be 0 for all other commands.

进程必须得开启”PTRACE_MODE_READ_REALCREDS”的ptrace访问模式,这样,进程 才能够pull a cookie from a process或 push a cookie to a process。

Building hierarchies of tasks

The simplest way to build hierarchies of threads/processes which share a cookie and thus a core is to rely on the fact that the core-sched cookie is inherited across forks/clones and execs, thus setting a cookie for the ‘initial’ script/executable/daemon will place every spawned child in the same core-sched group.【写的什么鬼,看不懂】。

对于初始的’script/executable/daemon’设置cookie值,则由它们fork/clone/exec的子进程也就位于同一个core-sched组里面啦。

Cookie Transferral

可以在当前task和其他task之间传递cookie。使用PR_SCHED_CORE_SHARE_FROM 或 PR_SCHED_CORE_SHARE_TO 从一个特定的task继承cookie,或者向某个task共享cookie。结合起来,这允许一个简单的程序从位于core-sched的一个task中拖取cookie,然后将该cookie共享给已在运行的task。

Design/Implementation

每个被标记的task都在内核内部分配了一个 cookie。 如Usage中所述,具有相同 cookie 值的task相互信任,并共享同一个core。

基本思想是,每个调度事件都尝试为 core的所有siblings 选择task,以便,在任何时间点,所有选定的运行在同一个core上的task都是可信任的(即:具有相同的 cookie)。 kernel thread被认为总是可信任的。idle task也被认为是特殊的,因为它信任一切,一切也都信任它。

在任何一个core的siblings的调度事件期间,该siblings上的最高优先级的task被挑选出来并分配给调用 schedule()的siblings,如果该siblings有task入队。【写是什么鬼,看不懂。During a schedule() event on any sibling of a core, the highest priority task on the sibling’s core is picked and assigned to the sibling calling schedule(), if the sibling has the task enqueued. 】。对于core的其余siblings,如果siblings在它们自己的rq中有一个可运行的task,则选择具有相同 cookie 的最高优先级的task。如果具有相同 cookie 的task不可用,则选择idle task。 idle task是全局都被信任的。

一旦为core的所有siblings选择了一个task,就会给 选择了新task 的siblings 发送 IPI 。 收到 IPI 的siblings将立即切换到新task。 如果为siblings选择了idle task,则认为该siblings处于force idle状态。force idle的意思是,它的rq上可能有task要运行,但它仍然必须得执行idle task。下一节将详细介绍这一点。

Forced-idling of hyperthreads

scheduler尽其所能的找到彼此信任的task,从而,被选中调度所有task都是core内优先级最高的task。但,某些rq所持有tasks 与 core上最高优先级tasks并不兼容。相比于fairness,优选security,如果siblings的最高优先级的task 和 core范围内(一个core会有多个siblings)最高优先级task无法彼此信任,则,1或多个siblings可能会被强制选择一个低优先级的task。如果某个siblings不存在任何一个可信任的task,则,该siblings就被强制idle。

当选中的最高优先级的task运行时,reschedule-IPI事件会被发送到siblings,强制siblings进入idle。根据VM或普通usermode进程是否运行在HT,这会产生4种情况。

       HT1 (attack)            HT2 (victim)
A      idle -> user space      user space -> idle
B      idle -> user space      guest -> idle
C      idle -> guest           user space -> idle
D      idle -> guest           guest -> idle 

注意,为了更好的性能,我们并不会等待destination cpu(victim)进入idle模式。这是因为发送IPI会使得destination cpu立即从userspace进入kernel模式,或者,如果是guests虚拟机则立即进入VMEXIT。最多,这只会泄露一些无关紧要的调度元信息。还有可能,在某些架构上,IPI收到的可能会非常晚,不过,在x86上,这种情况尚未被观测到。

Trust model

通过给组内的task分配一个标记,即值相同的cookie值,core scheduling在组内的task间维护了信任关系。当启用core scheduling的系统启动时,所有task都被认为是信任彼此的。这是因为core scheduling并没有关于信任关系的任何信息,直到userspace使用上述的接口,kernel才用于组的信任关系。换句话说,所有task都有默认cookie值0,因此所有任务都是彼此信任的。需要避免forced-idling的siblings运行cookie值为0的task。

一旦userspace使用上述接口将task放入某个组,位于同一个组的task就信任彼此,这些task不再信任组外task,组外task也不会再信任它们。

Limitations of core-scheduling

core scheduling尝试确保只有被信任的task才可以同时运行在同一个core上。但,会有一个小的时间窗口,在此期间,不被信任的task也会同时运行在同一个core上,或,kernel和另外一个不被kernel所信任的task同时运行在同一个core上。

IPI processing delays

core scheduling只选择信任的task,让它们在同一个core上同时运行。IPI用于通知siblings要切换到新task上。但,在某些架构上,收取IPI可能存在硬件延迟,在x86上,这尚未被观察到。假设,cpu 1发送IPI,cpu 2是cpu 1的siblings,cpu 1发送了IPI,然后cpu 1上的attacker task开始运行了,但,cpu 2收到IPI太晚了,这就造成了,cpu 1和cpu 2上同时运行的并不是彼此信任的task。尽管,在进入user mode时,cache会被flush,在attacker开始运行之后,siblings上的victim task可能会在cache 和 micro architectural buffers中放置数据(于是attacker就可以访问到),这可能导致数据泄露。

Open cross-HT issues that core scheduling does not solve

1. For MDS

针对 “siblings同时运行user mode代码 和 kernel mode代码” 的攻击,core scheduling无法防护。即使所有的siblings运行的都是彼此信任的task,当kernel代表某个task执行代码时,kernel无法信任运行在siblings上的代码。对于任何siblings cpu组合模式(host模式 或guest模式),这种攻击是可能的。

2. For L1TF

针对 “L1TF guest attackter利用guest或host victim” 的攻击,core scheduling无法防护。这是因为guest attacker 可以构建无效的PTE,由于guest kernel的脆弱性 这种无效的PTE不能被反转。唯一的解决方式就是禁用EPT(Extended Page Tables)。

对于MDS和L1TF,如果guest vCPU被配置为不信任彼此(通过分开打tag的方式),那么,guest到guest的攻击就不存在啦。或者,设置系统管理策略 将guest to guest攻击当做是guest自己的问题。

另外一种解决方式是,让系统中的每一个 不被信任的task 不信任 任何其他 不被信任的task。当然,这会降低 不被信任task 的并行度,不过,这确实 在允许被信任task的进程共享同一个core的同时,解决上述问题。

3. Protecting the kernel (IRQ, syscall, VMEXIT)

不幸的是,core scheduling并不能保护 运行在siblings超线程上的kernel context免受 运行在其他siblings上的kernel context的打扰。解决方式的原型代码已被发布到LKML,以期解决该问题,但,这种窗口能否被实际利用,其所引入的性能overhead是否值的(更不用说,这增加了代码复杂度),依然值得商榷。

Other Use cases

core scheduling的主要用例是在SMT开启的前提下,减轻cross-HT的脆弱性。但,core scheduling还可以用在下面地方

1. 隔离 需要整个core的 task。这方面的例子包括realtime task,使用SIMD指令的task等。

2. 团伙scheduling(gang scheduling),一组task需要一起调度,这种需求也可以使用core scheduling。VM的vCPU就是该方面的一个例子。

#20 内核模块 » Gentoo 之 Preemption Model » 2024-03-23 16:47:18

batsom
回复: 0

今天要分享的是抢占相关的基础知识。本文以内核抢占为引子,概述一下 Linux 抢占的图景。我尽量避开细节问题和源码分析。

什么是内核抢占?

别急,咱们慢慢来。

先理解抢占 (preemption) 这个概念:

involuntarily suspending a running process is called preemption

夺取一个进程的 cpu 使用权的行为就叫做抢占。

根据是否可以支持抢占,多任务操作系统 (multitasking operating system) 分为 2 类:

1、cooperative multitasking os

这种 os,进程会一直运行直到它自愿停下来。这种自愿停止运行自己的行为称为 yielding。协作式多任务系统,一听就知道这是一个乌托邦式的系统,只有当所有进程都很 nice 并乐意经常 yielding 时,系统才能正常工作。如果某个进程太傻或者太坏,系统很快就完蛋了。

2、preemptive multitasking os

这种 os,会有一个调度器 (scheduler,其实就是一段用于调度进程的程序),scheduler 决定进程何时停止运行以及新进程何时开始运行。当一个进程的 cpu 使用权被 scheduler 分配给另一个进程时,就称前一个进程被抢占了。

你可以把 sheduler 想象成非常智能的交警,交警按照一定的交通规则、当前的交通状况以及车辆的优先级 (救护车之类的),决定了哪些车可以行驶、哪些车要停下来等待。

很明显,现阶段,preemptive os 优于 cooperative os。所以 Linux 被设计成 preemptive。

抢占的核心操作包括 2 个步骤:

1、从用户态陷入到内核态 (trap kernel),3 个路径:

a. 系统调用,本质是 soft interrupt,通常就是一条硬件指令 (x86 的 int 0x80)。

b. 硬件中断,最典型的就是会周期性发生的 timer 中断,或者其他各种外设中断。

c. exception,例如 page fault、div 0。

2、陷入到内核态后,在合适的时机下,调用 sheduler 选出一个最重要的进程,如果被选中的不是当前正在运行的进程的话,就会执行 context switch 切换到新的进程。

根据抢占时机点的不同,抢占分为 2 种类型:

1、user preemption

这里的 user 并不是指在 user-space 里进行抢占,而是指在返回 user-space 前进行抢占,具体的:

When returning to user-space from a system call

When returning to user-space from an interrupt handler

即从 system call 和 interrupt handler 返回到 user-space 前进行抢占,这时仍然是在 kernel-space 里,抢占是需要非常高的权限的事情,user-space 没权利也不应该干这事。

2、kernel preemption

Linux 2.6 之前是不支持内核抢占的。这意味着当处于用户空间的进程请求内核服务时,在该进程阻塞(进入睡眠)等待某事(通常是 I/O)或系统调用完成之前,不能调度其他进程。支持内核抢占意味着当一个进程在内核里运行时,另一个进程可以抢占第一个进程并被允许运行,即使第一个进程尚未完成其在内核里的工作。

支持内核抢占 vs 不支持内核抢占

在上图中,进程 A 已经通过系统调用进入内核,也许是对设备或文件的 write() 调用。内核代表进程 A 执行时,具有更高优先级的进程 B 被中断唤醒。内核抢占进程 A 并将 CPU 分配给进程 B,即使进程 A 既没有阻塞也没有完成其在内核里的工作。

内核抢占的时机:

When an interrupt handler exits, before returning to kernel-space

When kernel code becomes preemptible again

If a task in the kernel explicitly calls schedule()

If a task in the kernel blocks (which results in a call to schedule() )

为什么要引入内核抢占?

根本原因:

trade-offs between latency and throughput

在系统延迟和吞吐量之间进行权衡。

并不是说内核抢占就是绝对的好,使用什么抢占机制最优是跟你的应用场景挂钩的。如果不是为了满足用户,内核其实是完全不想进行进程切换的,因为每一次 context switch,都会有 overhead,这些 overhead 就是对 cpu 的浪费,意味着吞吐量的下降。

但是,如果你想要系统的响应性好一点,就得尽量多的允许抢占的发生,这是 Linux 作为一个通用操作系统所必须支持的。当你的系统做到随时都可以发生抢占时,系统的响应性就会非常好。

为了让用户根据自己的需求进行配置,Linux 提供了 3 种 Preemption Model。

CONFIG_PREEMPT_NONE=y:不允许内核抢占,吞吐量最大的 Model,一般用于 Server 系统。

CONFIG_PREEMPT_VOLUNTARY=y:在一些耗时较长的内核代码中主动调用cond_resched()让出CPU,对吞吐量有轻微影响,但是系统响应会稍微快一些。

CONFIG_PREEMPT=y:除了处于持有 spinlock 时的 critical section,其他时候都允许内核抢占,响应速度进一步提升,吞吐量进一步下降,一般用于 Desktop / Embedded 系统。

另外,还有一个没有合并进主线内核的 Model: CONFIG_PREEMPT_RT,这个模式几乎将所有的 spinlock 都换成了 preemptable mutex,只剩下一些极其核心的地方仍然用禁止抢占的 spinlock,所以基本可以认为是随时可被抢占。

抢占前的检查

这里的检查是同时针对所有的 preemption 的。如果你理解了前面的 4 种 preempiton model 的话,应该能感觉到其实是不用太严格区分 user / kernel preemption,所有抢占的作用和性质都一样:降低 lantency,完全可以将它们一视同仁。

抢占的发生要同时满足两个条件:

需要抢占;

能抢占;

1、是否需要抢占?

判断是否需要抢占的依据是:thread_info 的成员 flags 是否设置了 TIF_NEED_RESCHED 标志位。

相关的 API:

set_tsk_need_resched() 用于设置该 flag。

tif_need_resched() 被用来判断该 flag 是否置位。

resched_curr(struct rq *rq),标记当前 runqueue 需要抢占。

2、是否能抢占?

抢占发生的前提是要确保此次抢占是安全的 (preempt-safe)。什么才是 preempt-safe:不产生 race condition / deadlock。

值得注意的是,只有 kernel preemption 才有被禁止的可能,而 user preemption 总是被允许,因此这时马上就要返回 user space 了,肯定是处于一个可抢占的状态了。

在引入内核抢占机制的同时引入了为 thread_info 添加了新的成员:preempt_count ,用来保证抢占的安全性,获取锁时会增加 preempt_count,释放锁时则会减少。抢占前会检查 preempt_count 是否为 0,为 0 才允许抢占。

相关的 API:

preempt_enable(),使能内核抢占,可嵌套调用。

preempt_disable(),关闭内核抢占,可嵌套调用。

preempt_count(),返回 preempt_count。

什么场景会设置需要抢占 (TIF_NEED_RESCHED = 1)

通过 grep resched_curr 可以找出大多数标记抢占的场景。

下面列举的是几个我比较关心的场景。

1、周期性的时钟中断

时钟中断处理函数会调用 scheduler_tick(),它通过调度类(scheduling class) 的 task_tick 方法 检查进程的时间片是否耗尽,如果耗尽则标记需要抢占:

// kernel/sched/core.c

void scheduler_tick(void)

{

[。..]

curr-》sched_class-》task_tick(rq, curr, 0);

[。..]

}

Linux 的调度策略被封装成调度类,例如 CFS、Real-Time。CFS 调度类的 task_tick() 如下:

// kernel/sched/fair.c

task_tick_fair()

-》 entity_tick()

-》 resched_curr(rq_of(cfs_rq));

2、唤醒进程的时候

当进程被唤醒的时候,如果优先级高于 CPU 上的当前进程,就会触发抢占。相应的内核代码中,try_to_wake_up() 最终通过 check_preempt_curr() 检查是否标记需要抢占:

// kernel/sched/core.c

void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags)

{

const struct sched_class *class;

if (p-》sched_class == rq-》curr-》sched_class) {

rq-》curr-》sched_class-》check_preempt_curr(rq, p, flags);

} else {

for_each_class(class) {

if (class == rq-》curr-》sched_class)

break;

if (class == p-》sched_class) {

resched_curr(rq);

break;

}

}

}

[。..]

}

参数 “p” 指向被唤醒进程,“rq” 代表抢占的 CPU。如果 p 的调度类和 rq 当前的调度类相同,则调用 rq 当前的调度类的 check_preempt_curr() (例如 cfs 的 check_preempt_wakeup()) 来判断是否要标记需要抢占。

如果 p 的调度类 》 rq 当前的调度类,则用 resched_curr() 标记需要抢占,反之,则不标记。

3、新进程创建的时候

如果新进程的优先级高于 CPU 上的当前进程,会需要触发抢占。相应的代码是 sched_fork(),它再通过调度类的 task_fork() 标记需要抢占:

// kernel/sched/core.c

int sched_fork(unsigned long clone_flags, struct task_struct *p)

{

[。..]

if (p-》sched_class-》task_fork)

p-》sched_class-》task_fork(p);

[。..]

}

// kernel/sched/fair.c

static void task_fork_fair(struct task_struct *p)

{

[。..]

if (sysctl_sched_child_runs_first && curr && entity_before(curr, se)) {

resched_curr(rq);

}

[。..]

}

4、进程修改 nice 值的时候

如果修改进程 nice 值导致优先级高于 CPU 上的当前进程,也要标记需要抢占,代码见 set_user_nice()。

// kernel/sched/core.c

void set_user_nice(struct task_struct *p, long nice)

{

[。..]

// If the task increased its priority or is running and lowered its priority, then reschedule its CPU

if (delta 《 0 || (delta 》 0 && task_running(rq, p)))

resched_curr(rq);

}

还有很多场景,这里就不一一列举了。

什么场景下要禁止内核抢占 (preempt_count 》 0)

有几种场景是明确需要关闭内核抢占的。

1、访问 Per-CPU data structures 的时候

看下面这个例子:

struct this_needs_locking tux[NR_CPUS];

tux[smp_processor_id()] = some_value;

/* task is preempted here.。. */

something = tux[smp_processor_id()];

如果抢占发生在注释所在的那一行,当进程再次被调度时,smp_processor_id() 值可能已经发生变化了,这种场景下需要通过禁止内核抢占来做到 preempt safe。

2、访问 CPU state 的时候

这个很好理解,你正在操作 CPU 相关的寄存器以进行 context switch 时,肯定是不能再允许抢占。

asmlinkage __visible void __sched schedule(void)

{

struct task_struct *tsk = current;

sched_submit_work(tsk);

do {

// 调度前禁止内核抢占

preempt_disable();

__schedule(false);

sched_preempt_enable_no_resched();

} while (need_resched());

sched_update_worker(tsk);

}

3、持有 spinlock 的时候

支持内核抢占,这意味着进程有可能与被抢占的进程在相同的 critical section 中运行。为防止这种情况,当持有自旋锁时,要禁止内核抢占。

static inline void __raw_spin_lock(raw_spinlock_t *lock)

{

preempt_disable();

spin_acquire(&lock-》dep_map, 0, 0, _RET_IP_);

LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);

}

还有很多场景,这里就不一一列举了。

真正执行抢占的地方

这部分是 platform 相关的,下面以 ARM64 Linux-5.4 为例,快速看下执行抢占的具体代码。

执行 user preemption

系统调用和中断返回用户空间的时候:

它们都是在 ret_to_user() 里判断是否执行用户抢占。

// arch/arm64/kernel/entry.S

ret_to_user() // 返回到用户空间

work_pending()

do_notify_resume()

schedule()

// arch/arm64/kernel/signal.c

asmlinkage void do_notify_resume(struct pt_regs *regs,

unsigned long thread_flags)

{

do {

[。..]

// 检查是否要需要调度

if (thread_flags & _TIF_NEED_RESCHED) {

local_daif_restore(DAIF_PROCCTX_NOIRQ);

schedule();

} else {

[。..]

} while (thread_flags & _TIF_WORK_MASK);

}

执行 kernel preemption

中断返回内核空间的时候:

// arch/arm64/kernel/entry.S

el1_irq

irq_handler

arm64_preempt_schedule_irq

preempt_schedule_irq

__schedule(true)

// kernel/sched/core.c

/* This is the entry point to schedule() from kernel preemption */

asmlinkage __visible void __sched preempt_schedule_irq(void)

{

[。..]

do {

preempt_disable();

local_irq_enable();

__schedule(true);

local_irq_disable();

sched_preempt_enable_no_resched();

} while (need_resched());

exception_exit(prev_state);

}

内核恢复为可抢占的时候:

前面列举了集中关闭抢占的场景,当离开这些场景时,会恢复内核抢占。

例如 spinlock unlock 时:

static inline void __raw_spin_unlock(raw_spinlock_t *lock)

{

spin_release(&lock-》dep_map, 1, _RET_IP_);

do_raw_spin_unlock(lock);

preempt_enable(); // 使能抢占时,如果需要,就会执行抢占

}

// include/linux/preempt.h

#define preempt_enable()

do {

barrier();

if (unlikely(preempt_count_dec_and_test()))

__preempt_schedule();

} while (0)

内核显式地要求调度的时候:

内核里有大量的地方会显式地要求进行调度,最常见的是:cond_resched() 和 sleep()类函数,它们最终都会调用到 __schedule()。

内核阻塞的时候:

例如 mutex,sem,waitqueue 获取不到资源,或者是等待 IO。这种情况下进程会将自己的状态从TASK_RUNNING 修改为 TASK_INTERRUPTIBLE,然后调用 schedule() 主动让出 CPU 并等待唤醒。

// block/blk-core.c

static struct request *get_request(struct request_queue *q, int op,

int op_flags, struct bio *bio,

gfp_t gfp_mask)

{

[。..]

prepare_to_wait_exclusive(&rl-》wait[is_sync], &wait,

TASK_UNINTERRUPTIBLE);

io_schedule(); // 会调用 schedule();

[。..]

}

#21 内核模块 » Gentoo 之 bpf 源码阅读 » 2024-03-23 14:24:05

batsom
回复: 0

以下基于 linux kernel 4.20.12


首先看入口 kernel/bpf/syscall.c


 SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size)
{
	union bpf_attr attr = {};
	int err;

	if (sysctl_unprivileged_bpf_disabled && !capable(CAP_SYS_ADMIN))
		return -EPERM;

	err = bpf_check_uarg_tail_zero(uattr, sizeof(attr), size);
	if (err)
		return err;
	size = min_t(u32, size, sizeof(attr));

	/* copy attributes from user space, may be less than sizeof(bpf_attr) */
	if (copy_from_user(&attr, uattr, size) != 0)
		return -EFAULT;

	err = security_bpf(cmd, &attr, size);
	if (err < 0)
		return err;

	switch (cmd) {
	case BPF_MAP_CREATE:
		err = map_create(&attr);
		break;
	case BPF_MAP_LOOKUP_ELEM:
		err = map_lookup_elem(&attr);
		break;
	case BPF_MAP_UPDATE_ELEM:
		err = map_update_elem(&attr);
		break;
	case BPF_MAP_DELETE_ELEM:
		err = map_delete_elem(&attr);
		break;
	case BPF_MAP_GET_NEXT_KEY:
		err = map_get_next_key(&attr);
		break;
	case BPF_PROG_LOAD:
		err = bpf_prog_load(&attr);
		break;
	case BPF_OBJ_PIN:
		err = bpf_obj_pin(&attr);
		break;
	case BPF_OBJ_GET:
		err = bpf_obj_get(&attr);
		break;
	case BPF_PROG_ATTACH:
		err = bpf_prog_attach(&attr);
		break;
	case BPF_PROG_DETACH:
		err = bpf_prog_detach(&attr);
		break;
	case BPF_PROG_QUERY:
		err = bpf_prog_query(&attr, uattr);
		break;
	case BPF_PROG_TEST_RUN:
		err = bpf_prog_test_run(&attr, uattr);
		break;
	case BPF_PROG_GET_NEXT_ID:
		err = bpf_obj_get_next_id(&attr, uattr,
					  &prog_idr, &prog_idr_lock);
		break;
	case BPF_MAP_GET_NEXT_ID:
		err = bpf_obj_get_next_id(&attr, uattr,
					  &map_idr, &map_idr_lock);
		break;
	case BPF_PROG_GET_FD_BY_ID:
		err = bpf_prog_get_fd_by_id(&attr);
		break;
	case BPF_MAP_GET_FD_BY_ID:
		err = bpf_map_get_fd_by_id(&attr);
		break;
	case BPF_OBJ_GET_INFO_BY_FD:
		err = bpf_obj_get_info_by_fd(&attr, uattr);
		break;
	case BPF_RAW_TRACEPOINT_OPEN:
		err = bpf_raw_tracepoint_open(&attr);
		break;
	case BPF_BTF_LOAD:
		err = bpf_btf_load(&attr);
		break;
	case BPF_BTF_GET_FD_BY_ID:
		err = bpf_btf_get_fd_by_id(&attr);
		break;
	case BPF_TASK_FD_QUERY:
		err = bpf_task_fd_query(&attr, uattr);
		break;
	case BPF_MAP_LOOKUP_AND_DELETE_ELEM:
		err = map_lookup_and_delete_elem(&attr);
		break;
	default:
		err = -EINVAL;
		break;
	}

	return err;
}

SYSCALL_DEFINE3,所有系统调用再内核的入口都是 SYSCALL_DEFINEx,x 代表了系统调用的参数个数,其实这是一个宏定义,可以阅读 include/linux/syscall.h

 #ifndef SYSCALL_DEFINE0
#define SYSCALL_DEFINE0(sname)					\
	SYSCALL_METADATA(_##sname, 0);				\
	asmlinkage long sys_##sname(void);			\
	ALLOW_ERROR_INJECTION(sys_##sname, ERRNO);		\
	asmlinkage long sys_##sname(void)
#endif /* SYSCALL_DEFINE0 */

#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)

#define SYSCALL_DEFINE_MAXARGS	6

#define SYSCALL_DEFINEx(x, sname, ...)				\
	SYSCALL_METADATA(sname, x, __VA_ARGS__)			\
	__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

不断展开之后,其实就是

asmlinkage long sys_bpf(int cmd, union bpf_attr *attr, unsigned int size); 

1. 检查权限

进入函数后,先检查权限

 if (sysctl_unprivileged_bpf_disabled && !capable(CAP_SYS_ADMIN))
		return -EPERM;

CAP_SYS_ADMIN 21 允许执行系统管理任务,包括:加载/卸载文件系统、设置磁盘配额、开/关交换设备和文件等,具体可以参考 /usr/src/linux/include/linux/capability.h 文件

# kernel/bpf/syscall.c 中有以下定义
int sysctl_unprivileged_bpf_disabled __read_mostly;  

经常需要被读取的数据定义为 __read_mostly 类型,这样 Linux 内核被加载时,该数据将自动被存放到 Cache 中,以提高整个系统的执行效率。另一方面,如果所在的平台没有 Cache,或者虽然有 Cache,但并不提供存放数据的接口(也就是并不允许人工放置数据在Cache 中),这样定义为 __read_mostly 类型的数据将不能存放在Linux内核中,甚至也不能够被加载到系统内存去执行,将造成 Linux 内核启动失败
2. 大小检测

如果我们处理了一个比我们所知的更大的结构,需要确保所有未知位都是0,即新的用户空间不依赖于任何我们不知道的内核特性扩展

在这个函数调用和下面的 copy_from_user() 调用之间存在一个 ToCToU(time-of-check-to-time-of-use) 。然而,这不是一个问题,因为这个函数是为了将来对位进行校对

err = bpf_check_uarg_tail_zero(uattr, sizeof(attr), size);

/*
 * If we're handed a bigger struct than we know of, ensure all the unknown bits
 * are 0 - i.e. new user-space does not rely on any kernel feature extensions
 * we don't know about yet.
 *
 * There is a ToCToU between this function call and the following
 * copy_from_user() call. However, this is not a concern since this function is
 * meant to be a future-proofing of bits.
 */
int bpf_check_uarg_tail_zero(void __user *uaddr,
			     size_t expected_size,
			     size_t actual_size)
{
	unsigned char __user *addr;
	unsigned char __user *end;
	unsigned char val;
	int err;

	if (unlikely(actual_size > PAGE_SIZE))	/* silly large */
		return -E2BIG;

	if (unlikely(!access_ok(VERIFY_READ, uaddr, actual_size)))
		return -EFAULT;

	if (actual_size <= expected_size)
		return 0;

	addr = uaddr + expected_size;
	end  = uaddr + actual_size;

	for (; addr < end; addr++) {
		err = get_user(val, addr);
		if (err)
			return err;
		if (val)
			return -E2BIG;
	}

	return 0;
} 

3 内存空间分配

static int bpf_prog_load(union bpf_attr *attr)
{
	enum bpf_prog_type type = attr->prog_type;
	struct bpf_prog *prog;
	int err;
	char license[128];
	bool is_gpl;

	if (CHECK_ATTR(BPF_PROG_LOAD))
		return -EINVAL;

	if (attr->prog_flags & ~BPF_F_STRICT_ALIGNMENT)
		return -EINVAL;

	/* copy eBPF program license from user space */
	// 根据 attr->license 地址,从用户空间拷贝 license 字符串到内核
	if (strncpy_from_user(license, u64_to_user_ptr(attr->license),
			      sizeof(license) - 1) < 0)
		return -EFAULT;
	license[sizeof(license) - 1] = 0;

	/* eBPF programs must be GPL compatible to use GPL-ed functions */
	// ebpf 程序必须符合 GPL 协议
	is_gpl = license_is_gpl_compatible(license);

	// 判断BPF的总指令数是否超过 BPF_MAXINSNS(4k)
	if (attr->insn_cnt == 0 || attr->insn_cnt > BPF_MAXINSNS)
		return -E2BIG;

	// 如果加载 BPF_PROG_TYPE_KPROBE 类型的 BPF 程序,指定的内核版本需要和当前内核版本匹配。不然由于内核的改动,可能会附加到错误的地址上
	if (type == BPF_PROG_TYPE_KPROBE &&
	    attr->kern_version != LINUX_VERSION_CODE)
		return -EINVAL;

	// 对 BPF_PROG_TYPE_SOCKET_FILTER 和 BPF_PROG_TYPE_CGROUP_SKB 以外的 BPF 程序加载,需要管理员权限
	if (type != BPF_PROG_TYPE_SOCKET_FILTER &&
	    type != BPF_PROG_TYPE_CGROUP_SKB &&
	    !capable(CAP_SYS_ADMIN))
		return -EPERM;

	bpf_prog_load_fixup_attach_type(attr);
	if (bpf_prog_load_check_attach_type(type, attr->expected_attach_type))
		return -EINVAL;

	/* plain bpf_prog allocation */
	// 根据 BPF 指令数分配 bpf_prog 空间,和 bpf_prog->aux 空间
	prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);
	if (!prog)
		return -ENOMEM;

	prog->expected_attach_type = attr->expected_attach_type;

	prog->aux->offload_requested = !!attr->prog_ifindex;

	// security_bpf_prog_alloc -> bpf_prog_alloc_security: 初始化 bpf 程序中的安全字段
	err = security_bpf_prog_alloc(prog->aux);
	if (err)
		goto free_prog_nouncharge;

	// 把整个 bpf_prog 空间在当前进程的 memlock_limit 中锁定
	err = bpf_prog_charge_memlock(prog);
	if (err)
		goto free_prog_sec;

	prog->len = attr->insn_cnt;

	err = -EFAULT;
	// 把 bpf 代码从用户空间地址 attr->insns,拷贝到内核空间地址 prog->insns
	if (copy_from_user(prog->insns, u64_to_user_ptr(attr->insns),
			   bpf_prog_insn_size(prog)) != 0)
		goto free_prog;

	prog->orig_prog = NULL;
	prog->jited = 0;

	atomic_set(&prog->aux->refcnt, 1);
	prog->gpl_compatible = is_gpl ? 1 : 0;

	if (bpf_prog_is_dev_bound(prog->aux)) {
		err = bpf_prog_offload_init(prog, attr);
		if (err)
			goto free_prog;
	}

	/* find program type: socket_filter vs tracing_filter */
	// 根据 attr->prog_type 指定的 type 值,找到对应的bpf_prog_types,给 bpf_prog->aux->ops 赋值,这个 ops 是一个函数操作集
	err = find_prog_type(type, prog);
	if (err < 0)
		goto free_prog;

	prog->aux->load_time = ktime_get_boot_ns();

	// 复制获得目标名,成功返回0,否则 <0
	err = bpf_obj_name_cpy(prog->aux->name, attr->prog_name);
	if (err)
		goto free_prog;

	/* run eBPF verifier */
	// 使用 verifer 对 BPF 程序进行合法性扫描
	err = bpf_check(&prog, attr);
	if (err < 0)
		goto free_used_maps;
 
	// 尝试对 bpf 程序进行 JIT 转换
	prog = bpf_prog_select_runtime(prog, &err);
	if (err < 0)
		goto free_used_maps;

	// 给 BPF 程序分配一个 id,在 1 到 INT_MAX 之间,把 BPF 程序发送给用户空间,并且用户空间可以通过 BPF_PROG_GET_FD_BY_ID 引用 
	err = bpf_prog_alloc_id(prog);
	if (err)
		goto free_used_maps;

	// 给 BPF 程序分配一个文件句柄 fd
	err = bpf_prog_new_fd(prog);
	if (err < 0) {
		/* failed to allocate fd.
		 * bpf_prog_put() is needed because the above
		 * bpf_prog_alloc_id() has published the prog
		 * to the userspace and the userspace may
		 * have refcnt-ed it through BPF_PROG_GET_FD_BY_ID.
		 */
		bpf_prog_put(prog);
		return err;
	}

	bpf_prog_kallsyms_add(prog);
	return err;

free_used_maps:
	bpf_prog_kallsyms_del_subprogs(prog);
	free_used_maps(prog->aux);
free_prog:
	bpf_prog_uncharge_memlock(prog);
free_prog_sec:
	security_bpf_prog_free(prog->aux);
free_prog_nouncharge:
	bpf_prog_free(prog);
	return err;
} 

struct bpf_prog {
	u16			pages;		      /* Number of allocated pages */
	u16			jited:1,	      /* Is our filter JIT'ed? */
				jit_requested:1,      /* archs need to JIT the prog */
				undo_set_mem:1,	      /* Passed set_memory_ro() checkpoint */
				gpl_compatible:1,     /* Is filter GPL compatible? */
				cb_access:1,	      /* Is control block accessed? */
				dst_needed:1,	      /* Do we need dst entry? */
				blinded:1,	      /* Was blinded */
				is_func:1,	      /* program is a bpf function */
				kprobe_override:1,    /* Do we override a kprobe? */
				has_callchain_buf:1;  /* callchain buffer allocated? */
	enum bpf_prog_type	type;		      /* Type of BPF program */ // 当前bpf程序的类型(kprobe/tracepoint/perf_event/sk_filter/sched_cls/sched_act/xdp/cg_skb)
	enum bpf_attach_type	expected_attach_type; /* For some prog types */ // 程序包含 bpf 指令的数量
	u32			len;		      /* Number of filter blocks */
	u32			jited_len;	      /* Size of jited insns in bytes */
	u8			tag[BPF_TAG_SIZE];
	struct bpf_prog_aux	*aux;		      /* Auxiliary fields */ // 主要用来辅助 verifier 校验和转换的数据
	struct sock_fprog_kern	*orig_prog;	      /* Original BPF program */
	unsigned int		(*bpf_func)(const void *ctx, const struct bpf_insn *insn);
	                                              /* Instructions for interpreter */ // 运行时BPF程序的入口。如果JIT转换成功,这里指向的就是BPF程序JIT转换后的映像;否则这里指向内核解析器(interpreter)的通用入口__bpf_prog_run()
	union {
		struct sock_filter	insns[0];
		struct bpf_insn		insnsi[0];    // 从用户态拷贝过来的,BPF程序原始指令的存放空间
	};
};

#22 内核模块 » Gentoo 之 BPF,eBPF与XDP简介与使用 » 2024-03-23 01:08:22

batsom
回复: 0

Kernel Bypass

在过去几年中,我们看到了编程工具包和技术的升级,以克服Linux kernel的限制,来进行高性能数据包处理。最流行的技术之一是kernel bypass(内核旁路),这意味着跳过内核的网络层,在用户态(user-sapce)做全部的包处理。kernel bypass涉及从user-space管理NIC(network interface controller,也就是常说的网卡),也就是说需要用户态的驱动程序(user space driver)来处理NIC

用户态程序完全控制NIC,有什么好处呢?减少了内核开销;等

坏处呢?用户程序需要直接管理硬件;kernel被完全跳过,所以内核提供的所有网络功能也被跳过,用户程序可能需要实现一些原来内核提供的功能;

本质上kernel bypass实现高性能包处理是通过将数据包从kernel移动到user-space

XDP(后面会讲)实际上正好相反,XDP允许我们在数据包到达NIC时,在它移动到 kernel’s networking subsystem之前,执行我们定义的处理函数,从而显著提高数据包处理速度。但是用户态定义的程序如何在内核中执行呢?

这就用到了BPF,BPF就是一种在内核中运行用户指定的程序的设计
BPF

Berkeley packet filter,用于过滤网络报文(packet)

是tcpdump(linux)和wireshark(windows)乃至整个网络监控(network monitoring)的基石

BPF实际上并不只是包处理,而更像一个VM(virtual machine)

BPF虚拟机及其字节码由Steve McCanne和Van Jacobson于1992年底在其论文 《The BSD Packet Filter: A New Architecture for User-level Packet Capture》中介绍,并首次在1993年冬季Usenix会议上提出。

由于BPF是一个VM,它定义了一个程序执行的环境。除了字节码,它还定义了基于数据包的内存模型(packet-based memory model)、寄存器(A and X; Accumulator ans Index register)、暂存内存(scratch memory)、隐式程序计数器(implicit pc)。有趣的是,BPF的字节码是模仿摩托罗拉6502ISA的。Steve McCanne在他的  Sharkfest ‘11 keynote主题演讲中回忆道,他在初中时就熟悉6502 assembly在Apple II上的编程,这在他设计BPF字节码时对他产生了影响

Linux内核从v2.5开始就支持BPF,主要由Jay Schullist添加。直到2011年,BPF代码才发生重大变化,Eric Dumazet将BPF解释器转换为JIT(来源: A JIT for packet filters)。现在内核不再解释BPF字节码,而是能够将BPF程序直接转换为目标体系结构:x86、ARM、MIPS等。

随后,在2014年,Alexei Starovoitov引入了新的BPF JIT。这种新的JIT实际上是一种基于BPF的新体系结构,称为eBPF。我认为这两个虚拟机共存了一段时间,但现在包过滤是在eBPF之上实现的。事实上,许多文档现在将eBPF称为BPF,而经典的BPF称为cBPF。
eBPF

eBPF在以下几个方面扩展了传统的BPF虚拟机:

    利用现代64位体系结构。eBPF使用64位寄存器,并将可用寄存器的数量从2(累加器和X寄存器)增加到10。eBPF还扩展了操作码的数量(BPF_MOV、BPF_JNE、BPF_CALL…
    与网络子系统分离。BPF被绑定到基于数据包的数据模型。由于它被用于数据包过滤,其代码位于网络子系统中。但是,eBPF VM不再局限于数据模型,它可以用于任何目的。现在可以将eBPF程序连接到跟踪点或kprobe。这为eBPF在其他内核子系统中的插装、性能分析和更多用途打开了大门。eBPF代码现在位于自己的路径: kernel/bpf
    增加Maps用来存储全局数据。Maps是键值对的存储方式,允许在user-sapce和kernel-space做数据交互。eBPF提供了多种类型的Map
    增加辅助函数(helper function)。例如数据包重写、校验和计算或数据包克隆。与用户空间编程不同,这些函数在内核中执行。此外,还可以从eBPF程序执行系统调用
    增加尾调用(tail call)。eBPF程序限制为4096字节。尾部调用功能允许eBPF程序通过控制一个新的eBPF程序,从而克服此限制(最多可以链接32个程序)

eBPF怎么使用呢?

看一个例子,也是Linux kernel自带的样例。它们可在  samples/bpf/上获得。要编译这些示例,可参考我前面的一篇文章。

我们选择 tracex4程序分析,eBPF编程通常包括两个程序:eBPF程序和user-sapce程序

     tracex4_kern.c, contains the source code to be executed in the kernel as eBPF bytecode.
     tracex4_user.c, contains the user-space program.

首先我们需要将tracex4_kern.c编程成eBPF bytecode,gcc缺乏BPF后端,幸运的是,Clang支持,自带的Makefile利用Clang将trace4_kern.c编译成一个目标文件(object file)

阅读以下tracex4_kern.c源码:

Maps are key/value stores that allow to exchange data between user-space and kernel-space programs. tracex4_kern defines one map:

struct pair {
    u64 val;
    u64 ip;
};  

struct bpf_map_def SEC("maps") my_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(long),
    .value_size = sizeof(struct pair),
    .max_entries = 1000000,
};

BPF_MAP_TYPE_HASH是eBPF提供的多个Map中的一个,你还能看到 SEC("map"),SEC是一个宏用来在二进制文件(目标文件,.o文件)中生成一个新的section

tracex4_kern.c还定义了另外两个section:

SEC("kprobe/kmem_cache_free")
int bpf_prog1(struct pt_regs *ctx)
{   
    long ptr = PT_REGS_PARM2(ctx);

    bpf_map_delete_elem(&my_map, &ptr); 
    return 0;
}

SEC("kretprobe/kmem_cache_alloc_node") 
int bpf_prog2(struct pt_regs *ctx)
{
    long ptr = PT_REGS_RC(ctx);
    long ip = 0;

    // get ip address of kmem_cache_alloc_node() caller
    BPF_KRETPROBE_READ_RET_IP(ip, ctx);

    struct pair v = {
        .val = bpf_ktime_get_ns(),
        .ip = ip,
    };

    bpf_map_update_elem(&my_map, &ptr, &v, BPF_ANY);
    return 0;
} 

这两个函数允许我们在map中增加一个entry(kprobe/kmem_cache_free) 和添加一条entry(kretprobe/kmem_cache_alloc_node)。

所有大写字母的函数实际上都是宏,定义在  bpf_helpers.h中

如果我们反汇编目标文件,我们可以看见新的section被定义:

$ objdump -h tracex4_kern.o

tracex4_kern.o:     file format elf64-little

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000000  0000000000000000  0000000000000000  00000040  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 kprobe/kmem_cache_free 00000048  0000000000000000  0000000000000000  00000040  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  2 kretprobe/kmem_cache_alloc_node 000000c0  0000000000000000  0000000000000000  00000088  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  3 maps          0000001c  0000000000000000  0000000000000000  00000148  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  4 license       00000004  0000000000000000  0000000000000000  00000164  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  5 version       00000004  0000000000000000  0000000000000000  00000168  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  6 .eh_frame     00000050  0000000000000000  0000000000000000  00000170  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA 

main program是  tracex4_user.c,大体上,这个程序的作用就是监听kmem_cache_alloc_node上的事件,当事件发生时,对应的eBPF code会被执行,且把ip信息保存到Map,main program从map中读取并打印出来

 $ sudo ./tracex4
obj 0xffff8d6430f60a00 is  2sec old was allocated at ip ffffffff9891ad90
obj 0xffff8d6062ca5e00 is 23sec old was allocated at ip ffffffff98090e8f
obj 0xffff8d5f80161780 is  6sec old was allocated at ip ffffffff98090e8f

这user-sapce program 和 eBPF program是怎么连接在一起的?在初始化的时候,tracex4_user.c 使用load_bpf_file 加载 tracex4_kern.o

int main(int ac, char **argv)
{
    struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
    char filename[256];
    int i;

    snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);

    if (setrlimit(RLIMIT_MEMLOCK, &r)) {
        perror("setrlimit(RLIMIT_MEMLOCK, RLIM_INFINITY)");
        return 1;
    }

    if (load_bpf_file(filename)) {
        printf("%s", bpf_log_buf);
        return 1;
    }

    for (i = 0; ; i++) {
        print_old_objects(map_fd[1]);
        sleep(1);
    }

    return 0;
} 

执行  load_bpf_file时,eBPF文件中定义的探测(kprobe)将添加到/sys/kernel/debug/tracing/kprobe_events中。我们现在正在监听这些事件,当它们发生时,我们的程序可以做一些事情。

$ sudo cat /sys/kernel/debug/tracing/kprobe_events
p:kprobes/kmem_cache_free kmem_cache_free
r:kprobes/kmem_cache_alloc_node kmem_cache_alloc_node 

XDP

XDP的设计源于Cloudflare在Netdev 1.1上提出的DDoS攻击缓解解决方案

因为Cloudflare希望保持使用iptables(以及内核网络堆栈的其余部分)的便利性,所以他们无法使用完全控制硬件的解决方案(即前面的kernel bypass),例如DPDK

Cloudflare的解决方案使用Netmap工具包实现其部分内核旁路(partial kernel bypass)(来源: Single Rx queue kernel bypass with Netmap)。这个想法可以通过在Linux内核网络堆栈中添加一个检查点(checkpoint),最好是在NIC中接收到数据包之后。该checkpoint应将数据包传递给eBPF程序,该程序将决定如何处理该数据包:丢弃该数据包(drop)或让其继续通过正常路径(pass). 就像这幅图一样:

FluxBB bbcode 测试

Example: An IPv6 packet filter

介绍XDP的典型例子是DDos过滤器,它的作用是:果数据包来自可疑来源,就丢弃它们。在我的例子中,我将使用更简单的功能:一个过滤除IPv6之外的所有流量的功能。

为了简单处理,我们不需要管理可疑地址列表。我们只简单地检查数据包的ethertype值,并让它继续通过网络堆栈(network stack),或者根据是否是IPv6数据包丢弃它。

SEC("prog")
int xdp_ipv6_filter_program(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data     = (void *)(long)ctx->data;
    struct ethhdr *eth = data;
    u16 eth_type = 0;

    if (!(parse_eth(eth, data_end, eth_type))) {
        bpf_debug("Debug: Cannot parse L2\n");
        return XDP_PASS;
    }

    bpf_debug("Debug: eth_type:0x%x\n", ntohs(eth_type));
    if (eth_type == ntohs(0x86dd)) {
        return XDP_PASS;
    } else {
        return XDP_DROP;
    }
} 

函数xdp_ipv6_filter_程序是我们的主程序。我们在二进制文件中定义了一个称为prog的新部分。这是我们的程序和XDP之间的挂钩。每当XDP收到一个数据包,我们的代码就会被执行。

CTX表示一个上下文,一个包含访问数据包所需的所有数据的结构。我们的程序调用parse_eth来获取ethertype。然后检查其值是否为0x86dd(IPv6以太网类型),如果是,数据包将通过。否则,数据包将被丢弃。此外,出于调试目的,所有ethertype值都会打印出来。

bpf_debug实际上是一个宏,定义如下:

 #define bpf_debug(fmt, ...)                          \
    ({                                               \
        char ____fmt[] = fmt;                        \
        bpf_trace_printk(____fmt, sizeof(____fmt),   \
            ##__VA_ARGS__);                          \
    })

内部其实也是调用了bpf_trace_printk,这个函数会打印在/sys/kernel/debug/tracing/trace_pipe 中的信息

函数parse_eth获取数据包的开头和结尾,并解析其内容:

static __always_inline
bool parse_eth(struct ethhdr *eth, void *data_end, u16 *eth_type)
{
    u64 offset;

    offset = sizeof(*eth);
    if ((void *)eth + offset > data_end)
        return false;
    *eth_type = eth->h_proto;
    return true;
} 

在内核中运行外部代码涉及某些风险。例如,无限循环可能会冻结内核,或者程序可能会访问不受限制的内存区域。为避免这些潜在危险,在加载eBPF代码时运行验证器。验证器遍历所有可能的代码路径,检查我们的程序没有访问超出范围的内存,也没有越界跳转;验证器还确保程序在有限时间内终止。

我们的eBPF程序符合这些要求。现在我们只需要编译它(完整的源代码可以在: xdp_ipv6_filter上找到)。

make 

这会生成 xdp_ipv6_filter.o,一个eBPF object file

现在我们需要把这个object file加载到network interface,这有两种方式可以做到这一点:

    写一个user-space program加载目标文件到network interface
    使用iproute来加载目标文件到interface

在这个例子中,我们将使用后一种方法

目前,支持XDP的网络接口数量有限(ixgbe、i40e、mlx5、veth、tap、tun、virtio_net和其他),尽管数量在不断增加。其中一些网络接口在驱动程序级别支持XDP(言下之意有些还不能在驱动级别)。这意味着,XDP钩子是在网络层的最低点实现的,就在NIC在Rx ring中接收到数据包的时候。在其他情况下,XDP钩子在网络堆栈中的较高点实现。前者提供了更好的性能结果,尽管后者使XDP可用于任何网络接口。

幸运的是,XDP支持veth interfaces,我将创建一个veth对,并将eBPF程序连接到它的一端。记住veth总是成对的,它就像一根虚拟电缆连接两个端口,任何在一端传送的东西都会到达另一端,反之亦然。

$ sudo ip link add dev veth0 type veth peer name veth1
$ sudo ip link set up dev veth0
$ sudo ip link set up dev veth1 

现在我们将eBPF program attach到veth1上:

$ sudo ip link set dev veth1 xdp object xdp_ipv6_filter.o


您可能已经注意到,我将eBPF程序的部分称为“prog”。这是iproute2希望查找的节的名称,使用其他名称命名该节将导致错误。

如果程序成功加载,我将在veth1接口中看到一个xdp标志:

$ sudo ip link sh veth1
8: veth1@veth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 32:05:fc:9a:d8:75 brd ff:ff:ff:ff:ff:ff
    prog/xdp id 32 tag bdb81fb6a5cf3154 jited 

为了验证我的程序是否按预期工作,我将把IPv4和IPv6数据包的混合推送到veth0(IPv4-and-IPv6-data.pcap)。我的示例总共有20个数据包(10个IPv4和10个IPv6)。但在这样做之前,我将在veth1上启动一个tcpdump程序,它只准备捕获10个IPv6数据包。

 $ sudo tcpdump "ip6" -i veth1 -w captured.pcap -c 10
tcpdump: listening on veth1, link-type EN10MB (Ethernet), capture size 262144 bytes

送packets到veth0:
$ sudo tcpreplay -i veth0 ipv4-and-ipv6-data.pcap

过滤后的数据包到达另一端。由于收到了所有预期的数据包,tcpdump程序终止。

10 packets captured
10 packets received by filter
0 packets dropped by kernel

我们也可以打印出/sys/kernel/debug/tracing/trace_pipe,来检查ethertype value.

$ sudo cat /sys/kernel/debug/tracing/trace_pipe
tcpreplay-4496  [003] ..s1 15472.046835: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046847: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046855: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046862: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046869: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046878: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046885: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046892: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046903: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046911: 0: Debug: eth_type:0x800
... 

#23 内核模块 » Gentoo 之 time subsystem » 2024-03-23 00:38:01

batsom
回复: 0

一、概要

         时间管理在内核中占有非常重要的地位,内核中有大量的函数都是基于时间驱动的。有些函数需要周期执行,而有些需要等待一个相对时间后才运行,此外内核还必须管理系统的运行时间以及当前日期和时间。内核必须在硬件的帮助下才能计算和管理时间。

         硬件为内核提供了一个系统定时器用以计算流逝的时间,该时钟在内核中可看成是一个电子时间资源。系统定时器以某种频率自行触发或射中时钟中断,当时钟中断发生时内核就通过一种特殊的中断处理程序对其进行处理。该频率可以通过编程预定(即节拍率),连续两次时钟中断的间隔时间称为节拍(tick)。利用时间中断周期执行的主要工作:

A.更新系统运行时间

B.更新墙上时间

C.在smp系统上,均衡调度程序中各处理器上的运行队里

D.检查当前进程是否用尽了自己的时间片

E.运行超时的动态定时器

F.更新资源消耗和处理器时间的统计值


1、节拍率:HZ

         系统定时器频率是通过静态预处理定义的,就是HZ,在系统启动时按照HZ值对硬件进行设置。不同体系结构,HZ不同,但大部分都是100即一个tick是10ms。

         一个合适的HZ是相当重要的,下面分析了高HZ的优势和劣势:

优势:

A.内核定时器能够以更高的频度和更高的准确度运行

B.依赖定时值的系统调用(poll和select)能够以更高的精度运行

C.对诸如资源消耗和系统运行时间等的测量会有更精细的解析度

D.提高进程抢占的准确度

劣势:

A.中断频率越高,切换频繁,系统负担越重

B.中断处理程序占用的CPU时间越多

C.更频繁的打乱处理器高速缓存并增加耗电
2、jiffies

         全局变量jiffies用来记录自系统启动以来产生的节拍的总数。内核给jiffies赋了一个特殊的初值,引起尽早溢出捕捉bug,每次时钟中断处理程序就会增加该变量的值。

         在include/linux/jiffies.h文件中定义了该变量:

extern unsigned long volatile __jiffy_datajiffies;

jiffies为无符号长整形,在32位体系结构上是32位,在64位体系结构上是64位。

         当jiffies变量的值超过它的最大存放范围后就会发生溢出,回绕到0。因此,内核提供了四个宏来帮助比较节拍计数,能正确地处理节拍计数回绕情况:

time_after(a, b) time_before(a, b)time_after_eq(a, b) time_before_eq(a, b)
3、硬件时钟和定时器

         体系结构提供了两种设备进行计时:实时时钟和系统定时器。

实时时钟:

         用来持久存放系统时间的设备,即便系统关闭后也可以靠主板上的微型电池提供的电力保持系统的计时。当系统启动时,内核通过读取RTC来初始化墙上时间,该时间存放在xtime变量中,然后定期同步两者时间保持一致,重启时可以得到一个相对准确的时间。

系统定时器:

         内核定时机制中最为重要的角色,提供一种周期性触发中断机制。相应的中断处理程序主要完成以下工作:

A.更新jiffies

B.更新墙上时间

C.更新资源消耗的统计值,比如当前进程所消耗的系统时间和用户时间

D.执行到期的动态定时器

E.执行scheduler_tick()函数

F.计算平均负载值
4、实际时间

         当前实际时间定义在文件kernel/time/timekeeping.c中:

static struct timekeeper timekeeper

struct timekeeper {

u64        xtime_sec;

u64        xtime_nsec;

}

xtime_sec以秒为单位,存放着自1970年1月1日(UTC)以来经过的时间,该时间点被称为纪元,多数Unix系统的墙上时间都是基于该纪元而言的。xtime_nsec上一秒开始经过的ns数。

         通过gettimeofday和settimeofday来获取和设置当前时间,设置时间需要具有CAP_SYS_TIME权能。
5、动态定时器

         它是管理内核流逝的时间的基础,使用简单。只需要执行一些初始化工作,设置一个超时时间,指定超时发生后执行的函数,然后激活定时器就可以了。

         定时器由结构timer_list表示,定义在文件<include/linux/timer.h >

struct timer_list {                                                                                                                             

   /*                                                                                                                                           

    * All fields that change during normal runtime grouped to the                                                                               

    * same cacheline                                                                                                                           

    */                                                                                                                                         

   struct list_head entry;   //定时器链表入口                                                                                                                   

   unsigned long expires;   //以jiffies为单位的定时值                                                                                                                     

   struct tvec_base *base;  //定时器内部值,用户不使用                                                                                                                                                                                                                                                       

   void (*function)(unsigned long); //定时器处理函数                                                                                                           

   unsigned long data;          //传递给处理函数的长整型参数                                                                                                                                                                                                                                                       

intslack;       

         …

}

         使用方法如下:

创建定时器:structtimer_list my_timer

初始化定时器:init_timer(&my_timer)

定时值初始化:

                            my_timer.expires= jiffies + delay

                            my_timer.data= 0

                            my_timer.function= my_function( void my_function(unsigned long data))

激活定时器:add_timer(&my_timer)

修改已经激活的定时器超时时间:mod_timer(&my_timer,jiffies+new_delay)

在定时器超市前停止定时器:del_timer(&my_timer)或 del_timer_sync(&my_timer)

         定时器作为软中断在下半部上下文中执行,时钟中断处理程序会通过raise_softirq(TIMER_SOFTIRQ)唤醒定时器软中断,从而在当前处理器上运行所有的超时定时器。虽然所有定时器都以链表形式存放在一起,但是为了提高搜索效率,内核将定时器按照他们的超时时间划分成五组,当定时器超时时间接近时,定时器将随组一起下移。
6、延迟执行

         内核代码(尤其是驱动程序)除了使用定时器或下半部机制以外,还需要其他方法来推迟执行任务。内核提供了许多延迟方法处理各种延迟要求:

A.忙等待

         该方法想要延迟的时间是节拍的整数倍,或者精确率要求不高时使用

unsinged long timeout = jiffies + 10

while (time_before(jiffies, timeout))

         ;

或者

while(time_before(jiffies, delay))

         cond_resched();

B.短延迟:

         voidudelay(unsigned long usecs)

         voidndelay(unsigned long nsecs)

         voidmdelay(unsigned long msecs)

/proc/cpuinfo的BogoMIPS主要被udelay()和mdelay()函数使用,记录处理器在给定时间内忙循环执行的次数。

C.schedule_timeout()

         该方法会让需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行。唯一的参数是延迟的相对时间,单位是jiffies。需要注意一点:

该函数需要调用调度程序,所以调用它的代码必须保证能够睡眠。换句话说,调用代码必须处于进程上下文中,并且不能持有锁。
二、linux时间框架
1、框架

随着技术发展,出现了下面两种新的需求:

(1)嵌入式设备需要较好的电源管理策略。传统的linux会有一个周期性的时钟,即便是系统无事可做的时候也要醒来,这样导致系统不断的从低功耗(idle)状态进入高功耗的状态。这样的设计不符合电源管理的需求。

(2)多媒体的应用程序需要非常精确的timer,例如为了避免视频的跳帧、音频回放中的跳动,这些需要系统提供足够精度的timer。

         因此,内核时间子系统也进行了框架调整,引进了高精度timer。和低精度timer不同,高精度timer使用了人类的最直观的时间单位ns(低精度timer使用的tick是和内核配置相关,不够直接)。本质上linuxkernel提供了高精度timer之后,其实不必提供低精度timer了,不过由于低精度timer存在了很长的历史,并且渗入到内核各个部分,如果去掉容易引起linuxkernel稳定性和健壮性的问题,因此kernel保持两种并存。其示意图如下:

         在smp情况下,driver硬件定时器划分成了两部分:一个提供给clocksource模块使用,一个提供给clockevent事件使用。而clockevent又简单化为两个小部分:一个是每个CPU自己的定时器,一个是全局定时器;每个CPU定时器管理本CPU的任务运行情况、资源统计等,第一个启动CPU(一般是CPU0)还会更新tick和墙上时钟,而globaltimer则主要用于低功耗模式下CPU进入睡眠时唤醒所有CPU。

         Kernel抽象了底层驱动,划分为clockevent和clocksource连个模块,驱动加载时会调用两个模块接口进行注册。clocksource的精度就是定时器时钟频率的精度(ns级别),可以认为是一个timeline,而clockevent则是这个timeline上的特定时间点,产生中断后调用相应的callback函数进行事件处理。

         tickdevice layer 基于clockevent设备进行工作:每个CPU都有自己唯一的tickdevice,管理自己的调度,进程统计等。Tickdevice可以工作在两种模式:periodic 和 one shot mode。有多少CPU就有多少个tickdevice称之为local tickdevice,在所有的local tickdevice中会有一个被选为globaltick device(一般是CPU0的tick device),该device负责维护整个系统的jiffies,更新wall clock,计算全局负荷什么的。

         高精度hrtimer需要高精度的clockevent,工作在one shotmode的tick device提供高精度的clockevent。虽然有了高精度hrtimer的出现,内核并没有抛弃老的低精度timer机制。当系统处于高精度timer的时候,系统会setup一个特别的高精度hrtimer(sched_timer),该高精度timer会周期性的触发,从而模拟传统的periodictick,推动传统低精度timer的运转(代码没有弄明白sched_timer?)。
2、内核配置

(1)GENERIC_CLOCKEVENTS和GENERIC_CLOCKEVENTS_BUILD:代表使用新的时间子系统架构,默认就是配置好的

(2)新时间子系统框架下,Timerssubsystem配置选项主要和tick已经是否支持高精度hrtimer有关。Tick有2种配置(二选一):

CONFIG_HZ_PERIODIC:无论何时都启用周期性的tick,即便是在系统idle的时候。

CONFIG_NO_HZ_IDLE:该选项默认会打开 CONFIG_TICK_ONESHOT 和CONFIG_NO_HZ_COMMON,在系统idle的时候,停掉周期性tick。

CONFIG_HIGH_RES_TIMERS:支持高精度hrtimer

配置了高精度hrtimer或NO_HZ_COMMOM就一定配置CONFIG_TICK_ONESHOT,表示系统支持one-shot类型的tick_device。

         因此,在新时间子系统下有4种配置:

A.低精度timer和周期tick

B.低精度timer和dynamic tick(tickless idle)(当前系统使用情况)

C.高精度hrtimer和周期tick

D.高精度hrtimer和dynamic tick(tickless idle)
3、四种配置(转载自附录网站)
(1)低精度timer + 周期tick

我们首先看周期性tick的实现。起始点一定是底层的clocksource chip driver,该driver会调用接口clockevents_register_device向clock event注册。一旦增加了一个clockevent device,需要通知上层的tickdevice layer,有可能新注册的这个device更好、更适合某个tick device。要是这个clock eventdevice被某个tickdevice收留了(要么该tickdevice之前没有匹配的clockevent device,要么新的clockevent device更适合该tickdevice),那么就启动对该tickdevice的配置(参考tick_setup_device)。根据当前系统的配置情况(周期性tick),会调用tick_setup_periodic函数,这时候,该tick device对应的clock event device的clock event handler被设置为tick_handle_periodic。底层硬件会周期性的产生中断,从而会周期性的调用tick_handle_periodic从而驱动整个系统的运转。需要注意的是:即便是配置了CONFIG_NO_HZ和CONFIG_TICK_ONESHOT,系统中没有提供one shot的clock event device,这种情况下,整个系统仍然是运行在周期tick的模式下。

下面来到低精度timer模块了,其实即便没有使能高精度timer,内核也会把高精度timer模块的代码编译进kernel的image中,这一点可以从Makefile文件中看出。在这种构架下,各个内核模块也可以调用linuxkernel中的高精度timer模块的接口函数来实现高精度timer,但是,这时候高精度timer模块是运行在低精度的模式,也就是说这些hrtimer虽然是按照高精度timer的红黑树进行组织,但是系统只是在每一周期性tick到来的时候调用hrtimer_run_queues函数,来检查是否有expire的hrtimer。毫无疑问,这里的高精度timer也就是没有意义了。

由于存在周期性tick,低精度timer的运作毫无压力,和过去一样。
(2)低精度timer + Dynamic Tick

系统开始的时候并不是直接进入Dynamictick mode的,而是经历一个切换过程。开始的时候,系统运行在周期tick的模式下,各个cpu对应的tick device的(clock event device的)event handler是tick_handle_periodic。在timer的软中断上下文中,会调用tick_check_oneshot_change进行是否切换到one shot模式的检查,如果系统中有支持one-shot的clock event device,并且没有配置高精度timer的话,那么就会发生tick mode的切换(调用tick_nohz_switch_to_nohz),这时候,tick device会切换到one shot模式,而event handler被设置为tick_nohz_handler。由于这时候的clock eventdevice工作在oneshot模式,因此当系统正常运行的时候,在eventhandler中每次都要reprogramclock event,以便正常产生tick。当cpu运行idle进程的时候,clock eventdevice不再reprogram产生下次的tick信号,这样,整个系统的周期性的tick就停下来。
(3)高精度timer + Dynamic Tick

同样的,系统开始的时候并不是直接进入Dynamictick mode的,而是经历一个切换过程。系统开始的时候是运行在周期tick的模式下,event handler是tick_handle_periodic。在周期tick的软中断上下文中(参考run_timer_softirq),如果满足条件,会调用hrtimer_switch_to_hres将hrtimer从低精度模式切换到高精度模式上。这时候,系统会有下面的动作:

A.Tickdevice的clockevent设备切换到oneshotmode(参考tick_init_highres函数)

B.Tickdevice的clockevent设备的eventhandler会更新为hrtimer_interrupt(参考tick_init_highres函数)

C.设定schedtimer(也就是模拟周期tick那个高精度timer,参考tick_setup_sched_timer函数)

这样,当下一次tick到来的时候,系统会调用hrtimer_interrupt来处理这个tick(该tick是通过sched timer产生的)。

在Dynamictick的模式下,各个cpu的tick device工作在one shot模式,该tick device对应的clock event设备也工作在one shot的模式,这时候,硬件Timer的中断不会周期性的产生,但是linuxkernel中很多的模块是依赖于周期性的tick的,因此,在这种情况下,系统使用hrtime模拟了一个周期性的tick。在切换到dynamic tick模式的时候会初始化这个高精度timer,该高精度timer的回调函数是tick_sched_timer。这个函数执行的函数类似周期性tick中event handler执行的内容。不过在最后会reprogram该高精度timer,以便可以周期性的产生clockevent。当系统进入idle的时候,就会stop这个高精度timer,这样,当没有用户事件的时候,CPU可以持续在idle状态,从而减少功耗。
(4)高精度timer + 周期性Tick

这种配置不多见,多半是由于硬件无法支持oneshot的clockevent device,这种情况下,整个系统仍然是运行在周期tick的模式下。
三、用户接口
1、系统时间相关服务
(1)秒级函数:time和stime

#include <time.h>

time_t time(time_t *t); //获取时间秒

int stime(time_t *t);  //设置时间秒

对应的系统调用为:sys_time和sys_stime。time函数返回当前点到linuxepoch的秒数,stime设定当前时间点到linuxepoch的秒数。

         与上面函数配套的还有一系列时间点与linuxepoch转换函数:mktime,localtime_r。
(2)微秒级函数:gettimeofday和settimeofday

#include <sys/time.h>

int gettimeofday(struct timeval *tv, structtimezone *tz);

int settimeofday(const struct timeval *tv,const struct timezone *tz);

struct timeval {

time_t      tv_sec;     /* seconds */

suseconds_ttv_usec;    /* microseconds */

};

struct timezone {

inttz_minuteswest;     /* minutes west ofGreenwich */

inttz_dsttime;         /* type of DST correction */

};

对应的系统调用:sys_gettimeofday和sys_settimeday。Gettimeofday获取linux epoch到当前时间点的秒数以及微秒数;settimeofday则设定从linuxepoch到当前时间点的秒数以及微秒数。

值得一提的是:这些系统调用在新的POSIX标准中接口被clock_gettime和clock_settime取代。
(3)纳秒级别的时间函数:clock_gettime和clock_settime

#include <time.h>

int clock_getres(clockid_t clk_id, structtimespec *res); //获取clock_id的系统时钟精度

int clock_gettime(clockid_t clk_id, structtimespec *tp);

int clock_settime(clockid_t clk_id, conststruct timespec *tp);

struct timespec {

time_t   tv_sec;        /* seconds */

long     tv_nsec;       /* nanoseconds */

};

clk_id识别systemclock的ID,定义如下:

CLOCK_REALTIME:真实的墙上时钟,前面函数就是从获取该ID的值

CLOCK_MONOTONIC:该时钟是单调递增的,也是真实的墙上时钟只是起始点不一定是linuxepoch,一般会把系统启动的时间点设定为基准点。除了NTP和adjtime对该时钟进行调整外,其他任何接口不允许设定该时钟,保证该时钟的单调性。可以了解系统启动时间。

CLOCK_MONOTONIC_RAW:具备CLOCK_MONOTONIC的特性,但它不受NTP和adjtime影响,是完全基于本地晶振的时钟。一般程序员不大用。

CLOCK_BOOTTIME:类似CLOCK_MONOTONIC,但是系统suspend时依然增加。

CLOCK_PROCESS_CPUTIME_ID:每个CPU的高精度进程定时器,clock_getcpuclockid获取进程的clock_id

CLOCK_THREAD_CPUTIME_ID:线程的CPU时间,pthread_getcpuclockid获取线程的clock_id。
(4)系统时钟调整

         上面设定系统时间是一个比较粗暴的做法,一旦修改了系统时间,系统中很多以来绝对时间的进程会有各种奇怪的行为。所以系统提供了时间同步的接口函数,可以让外部的精准计时服务器不断的修正系统时钟。

A.adjtime

int adjtime(const struct timeval *delta,struct timeval *olddelta);

struct timeval {

time_t      tv_sec;     /* seconds */

suseconds_ttv_usec;    /* microseconds */

};

         该函数可以根据delta参数缓慢的修正系统时钟(CLOCK_REALTIME那个)。olddelta返回上一次调整中尚未完成的delta。

B.adjtimex

#include <sys/timex.h>

int adjtimex(struct timex *buf);

struct timex {

intmodes;           /* mode selector */

longoffset;         /* time offset (usec) */

longfreq;           /* frequency offset(scaled ppm) */

longmaxerror;       /* maximum error (usec)*/

longesterror;       /* estimated error (usec)*/

intstatus;          /* clock command/status*/

longconstant;       /* pll time constant */

longprecision;      /* clock precision (usec)(read-only) */

longtolerance;      /* clock frequencytolerance (ppm)

                                      (read-only) */

structtimeval time; /* current time (read-only) */

longtick;           /* usecs between clockticks */

};

         该函数用来显示或这修改linux内核的时间变量的工具,提供了对与内核时间变量直接访问功能,可以实现对于系统时间的飘逸进行修正。任何用户都可以使用它查看,但是只有root用户才可以更改这些参数。
2、进程睡眠
(1)秒级函数:sleep

#include <unistd.h>

unsigned int sleep(unsigned int seconds);

         该函数会导致当前进程sleepseconds之后(基于CLOCK_REALTIME)返回继续执行程序。返回值说明了进程没有进入睡眠的时间。
(2)微秒级别函数:usleep

#include <unistd.h>

int usleep(useconds_t usec);

         该函数功能和上面一样,不过返回值定义不同。0:表示执行成功,-1:执行失败,错误码在errno中。
(3)纳秒级别函数:nanosleep

#include <time.h>

int nanosleep(const struct timespec *req,struct timespec *rem);

struct timespec {

time_ttv_sec;        /* seconds */

long   tv_nsec;       /* nanoseconds */

};

该函数取代了usleep函数,req中设定你要sleep的秒以及纳秒值,rem表示还有多少时间没睡完。返回0表示成功,返回-1说明失败。

sleep/usleep/nanosleep的系统都是通过kernel的sys_nanosleep系统调用实现(底层基于hrtimer)。
(4)更高级的sleep函数:clock_nanosleep

#include <time.h>

int clock_nanosleep(clockid_t clock_id, intflags,

                           const structtimespec *request,

                           struct timespec*remain);

clock_id说明该函数不仅能基于real_timeclock睡眠,还可以基于其他的系统时钟睡眠。flag等0或1,分别指明request参数设定的时间值是相对时间还是绝对时间。
3、timer相关的服务
(1)alarm函数

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

         该函数在指定秒数(基于CLOCK_REALTIME)的时间过去后,向该进程发送SIGALRM信号。调用该接口的程序需要设定signalhandler。
四、代码解析

第二章节讲述内核配置有两种主要模式:periodic和 one-shot,下面代码分析主要根据实际使用的低精度tick和dynamic tick (tickles tick)即使用one-shot配置进行讲解(Linux-3.10.y)。
1、数据结构
(1)clocksource

内核使用structclocksource数据结构记录时钟源所有信息,主要作为系统时间的基准,当有多个时钟源时选择最优那个,没有时钟源时默认使用基于jiffies的时钟clocksource_jiffies。内核通过一个链表clocksource_list管理所有注册的时钟源,每个时钟源定义了一个单调增加的计数器并以ns为单位。

         structclocksource结构体详细如下(include/linux/clocksource.h):

struct clocksource {

   cycle_t (*read)(struct clocksource *cs); //读取指定CS的cycle值(定时器当前计数值)

   cycle_t cycle_last;   //保存最近一次read的cycle值(其中一个重要作用翻转)

cycle_tmask; //counter是32位还是64位

//公式:ns =(cycles/F) * NSEC_PER_SEC = (cycle* mult) >> shift

   u32 mult; //cycle转化为ns的乘数

   u32 shift; //cycle转化为ns的除数,采用移位的方式

   u64 max_idle_ns; //该时钟允许的最大空闲时间(没搞明白如何用)

   u32 maxadj;     //最大调整值与mult相关

#ifdef CONFIG_ARCH_CLOCKSOURCE_DATA //未用

   struct arch_clocksource_data archdata;

#endif

   const char *name; //时钟源名字

   struct list_head list; //注册时钟源链表头

intrating; //时钟源精度值,

1--99: 不适合于用作实际的时钟源,只用于启动过程或用于测试;

100--199:基本可用,可用作真实的时钟源,但不推荐;

200--299:精度较好,可用作真实的时钟源;

300--399:很好,精确的时钟源;

400--499:理想的时钟源,如有可能就必须选择它作为时钟源;

    int(*enable)(struct clocksource *cs); //使能时钟源

   void (*disable)(struct clocksource *cs); //禁止时钟源

   unsigned long flags; //时钟源属性,CLOCK_SOURCE_IS_CONTINUOUS连续时钟

   void (*suspend)(struct clocksource *cs); //挂起时钟源

   void (*resume)(struct clocksource *cs); //恢复时钟源

#ifdef CONFIG_CLOCKSOURCE_WATCHDOG   //未用

   /* Watchdog related data, used by the framework */

   struct list_head wd_list;

   cycle_t cs_last;

   cycle_t wd_last;

#endif

}____cacheline_aligned;
(2)clockevent

内核使用struct clock_event_device数据结构记录时钟的事件信息,包括硬件时钟中断发生时要执行的那些操作。提供了对周期性事件和单触发事件的支持。还提供了高精度定时器和动态定时器的支持。内核通过一个clockevent_devices管理所有注册的clock event设备。

         该结构体在头文件include/linux/clockchips.h中定义,详细定义如下:

struct clock_event_device {

   void          (*event_handler)(structclock_event_device *); //事件处理函数,主要有三种:peridioc\one-shot\broadcast

   int    (*set_next_event)(unsignedlong evt, struct clock_event_device *); //设置下次触发事件基于clocksource的cycles

   int    (*set_next_ktime)(ktime_texpires, struct clock_event_device *); //设置下次触发事件基于ktime(用得比较少)

   ktime_t    next_event;

   u64  max_delta_ns; //可设置的最大时间差

   u64  min_delta_ns; //可设置的最小时间差

   u32  mult; //和clocksource一样

   u32  shift; //和clocksource一样

   enum clock_event_mode   mode; //clockevent工作模式,见下面

   unsigned int        features; //clockevent设备特征,见下面

    unsigned long       retries;

   void  (*broadcast)(const structcpumask *mask); //广播所有CPU函数

   void  (*set_mode)(enumclock_event_mode mode, struct clock_event_device *); //设置模式

   void  (*suspend)(structclock_event_device *);

   void  (*resume)(struct clock_event_device*);

   unsigned long  min_delta_ticks;

   unsigned long  max_delta_ticks;

   const char        *name;

   int    rating;

   int    irq;

   const struct cpumask      *cpumask;//CPU掩码,判断是否属于某一个CPU,或广播支持的CPU

   struct list_head        list;

} ____cacheline_aligned;

         clockevent设备工作模式:

enum clock_event_mode {

   CLOCK_EVT_MODE_UNUSED = 0,         

   CLOCK_EVT_MODE_SHUTDOWN,           //关闭模式

   CLOCK_EVT_MODE_PERIODIC,               //周期性模式

   CLOCK_EVT_MODE_ONESHOT,      //单次模式

   CLOCK_EVT_MODE_RESUME,        //恢复模式

};

         clockevent设备特征:

#define CLOCK_EVT_FEAT_PERIODIC     0x000001          //可以产生周期触发事件特征

#define CLOCK_EVT_FEAT_ONESHOT     0x000002          //可以产生单触发事件特征

#define CLOCK_EVT_FEAT_KTIME           0x000004        //产生事件的事件基准ktime

//X86下使用,进入省电情况

#define CLOCK_EVT_FEAT_C3STOP         0x000008        //clocksource停止,需要广播事件支持,本ARM平台也使用了该选项

#define CLOCK_EVT_FEAT_DUMMY       0x000010          // Local APIC timer使用该选项
(3)tick_device

         struct tick_device只是对struct clock_event_device的一个封装,加入了运行模式变量,支持PERIODIC和ONESHOT两种模式。

struct tick_device {

   struct clock_event_device *evtdev;

   enum tick_device_mode mode;

};

enum tick_device_mode {

   TICKDEV_MODE_PERIODIC,

   TICKDEV_MODE_ONESHOT,

};
2、内核初始化

         在内核启动函数start_kernel里对时间系统进行了初始化
(1)tick_init

         该函数初始化tick控制。向clockevents_chain通知链中添加一个tick通知分发器tick_notifier(分发回调函数:tick_notify)。在底层驱动注册设备时,CLOCK_EVT_NOTIFY_ADD消息就是添加了一个新的clockevent设备。

初始化tick broadcast掩码,如果配置了CONFIG_TICK_ONESHOT相关掩码也要初始化。
(2)init_timers

初始化本CPU上的低精度定时器相关的数据结构,将通知分发器timers_nb添加到cpu_chain通知链;初始化定时器软中断open_softirq(TIMER_SOFTIRQ, run_timer_softirq);
(3)hrtimers_init

初始化本CPU上的高精度精度定时器相关的数据结构,将通知分发器hrtimers_nb添加到cpu_chain通知链;如果开启高精度定时器宏,则初始化高精度定时器软中断open_softirq(HRTIMER_SOFTIRQ, run_hrtimer_softirq)(本平台为使用)。
(4)timekeeping_init

初始化时钟源clocksource及timekeeping模块时间初始值,如果平台没有更好的时钟源,系统使用jiffies作为时钟源。

clock = clocksource_default_clock();

struct clocksource * __init __weakclocksource_default_clock(void)

{

   return &clocksource_jiffies;

}

struct clocksource clocksource_jiffies = {

   .name       = "jiffies",

   .rating     = 1, /* lowest validrating*/

   .read       = jiffies_read,

   .mask       = 0xffffffff,/*32bits*/

   .mult       = NSEC_PER_JIFFY<< JIFFIES_SHIFT, /* details above */

   .shift      = JIFFIES_SHIFT,

};
(5)time_init

前面函数时内核通用架构,该函数为硬件时钟初始化平台相关,一般由各个平台自己实现,细节见下节。

void __init time_init(void)

   if (machine_desc->init_time)

        machine_desc->init_time(); (=hi3536_timer_init)

   else

       clocksource_of_init();

   sched_clock_postinit();

}


3、硬件时钟初始化
(1)平台注册

MACHINE_START(HI3536, "hi3536")

.atag_offset  = 0x100,

   .map_io       = hi3536_map_io,

   .init_early   = hi3536_init_early,

   .init_irq     =hi3536_gic_init_irq,

#ifdef CONFIG_HI3536_SYSCNT

   .init_time    = arch_timer_init,

#else

    .init_time    =hi3536_timer_init,

#endif

   .init_machine = hi3536_init,

   .smp          =smp_ops(hi3536_smp_ops),

   .reserve      = hi3536_reserve,

   .restart      = hi3536_restart,

   MACHINE_END

         上一节machine_desc的具体实现即为该宏定义,init_time就是hi3536_timer_init,其具体内容见下面。


(2)平台定时器初始化

void __init hi3536_timer_init(void)

{

/* 设置所有定时器的工作时钟(未初始化,默认3MHZ)

         Hi3536有time0~910个时钟

         根据配置,所有定时器都配置成了总线时钟125Mhz(8ns)

*/

   writel(readl((const volatile void *)IO_ADDRESS(REG_BASE_SCTL)) |

       (1 << 16) | (1 << 18),

       (volatile void *)IO_ADDRESS(REG_BASE_SCTL));   

#ifdef CONFIG_SP804_LOCAL_TIMER

   hi3536_local_timer_init(); //每个CPU使用一个定时器作为local timer,该平台有4核CPU0~CPU3分别对应:timer4~timer7,注册为clockevent设备

#endif

   hi3536_clocksource_init((void *)TIMER(0)->addr,

       TIMER(0)->name); //timer0作为clocksource设备

   sp804_clockevents_init((void *)TIMER(1)->addr,

       TIMER(1)->irq.irq, TIMER(1)->name); //timer1作为clockevent设备的globaltimer

}

         从上面看,hi3536总共支持10个timer,实际使用了6个,timer0/timer1,timer4~timer7,其他保留。其注册的顺序如下:timer0(clocksource)—> timer1(clockevent global timer) —> timer4(clockevent cpu0 local timer)—> time5~7(clockevent cpu1~3 local timer)。每类定时器的初始化,见下面细节分析。
(3)clocksource初始化

         初始化函数源码如下:

static void __inithi3536_clocksource_init(void __iomem *base, const char *name)                                                                 

{                                                                                                                                               

   long rate = sp804_get_clock_rate(name);  //获取定时器时钟62.5MHz     

   struct clocksource *clksrc = &hi3536_clocksource.clksrc;   

   

   if (rate < 0)                                                                                                                               

       return;                                                                                                                                 

       

   clksrc->name   = name;  //name=timer0       

   clksrc->rating = 200; //时钟源精度值           

   clksrc->read   =hi3536_clocksource_read;   //获取计数值,系统主要调用该接口转化为系统时间         

   clksrc->mask   =CLOCKSOURCE_MASK(32),  //计数值32位

   clksrc->flags  = CLOCK_SOURCE_IS_CONTINUOUS, //持续的时钟源

   clksrc->resume = hi3536_clocksource_resume,     

   hi3536_clocksource.base = base;       

   hi3536_clocksource_start(base);  //初始化寄存器

    clocksource_register_hz(clksrc, rate);  //计算出mult和shift,为系统选择更好的时钟源 

   setup_sched_clock(hi3536_sched_clock_read, 32, rate); //通用sched_clock模块,这个模块主要是提供一个sched_clock的接口函数,获取当前时间点和系统启动之间的纳秒值。

}
(4)per CPU定时器初始化

         每CPU定时器初始化函数:

static void __inithi3536_local_timer_init(void)

    unsigned int cpu = 0;

   unsigned int ncores = num_possible_cpus(); //获取CPU个数

    local_timer_rate = sp804_get_clock_rate("sp804"); //获取定时器时钟

    for (cpu = 0; cpu < ncores; cpu++) { //为每个CPU分配各自的定时器

       struct hi_timer_t *cpu_timer = GET_SMP_TIMER(cpu);

        cpu_timer->irq.handler = sp804_timer_isr; //中断处理函数

       cpu_timer->irq.dev_id = (void *)cpu_timer; //定时器分别是timer0~3

       setup_irq(cpu_timer->irq.irq, &cpu_timer->irq); //注册中断号

       disable_irq(cpu_timer->irq.irq); //关闭中断

    }

local_timer_register(&hi3536_timer_tick_ops);//注册定时器操作函数

/* 以上只是为每个CPU分配了定时器,并没有注册每个cpu的clockevent,启动CPU(CPU0)在稍后注册,而其他次CPU则直到kernel_init启动它们后才会注册 */

}

//定时器注册clockevent操作函数

static struct local_timer_opshi3536_timer_tick_ops __cpuinitdata = {

   .setup  =hi3536_local_timer_setup,

   .stop   = hi3536_local_timer_stop,

};

static int __cpuinithi3536_local_timer_setup(struct clock_event_device *evt)

{

   unsigned int cpu = smp_processor_id();

   struct hi_timer_t *timer = GET_SMP_TIMER(cpu);

   struct irqaction *irq = &timer->irq;

    evt->name = timer->name;

   evt->irq  = irq->irq;

   evt->features = CLOCK_EVT_FEAT_PERIODIC | CLOCK_EVT_FEAT_ONESHOT

                          |CLOCK_EVT_FEAT_C3STOP;

    evt->set_mode = sp804_set_mode;

   evt->set_next_event = sp804_set_next_event;

   evt->rating = 350;

    timer->priv = (void *)evt;

    clockevents_config_and_register(evt, local_timer_rate, 0xf, 0xffffffff);//注册clockevent

   irq_set_affinity(evt->irq, evt->cpumask);

   enable_irq(evt->irq);

    return 0;

}

//中断处理函数

static irqreturn_t sp804_timer_isr(int irq,void *dev_id)

{

   struct hi_timer_t *timer = (struct hi_timer_t *)dev_id;

   unsigned int clkevt_base = timer->addr;

   struct clock_event_device *evt

       = (struct clock_event_device *)timer->priv;

    /* clear the interrupt */

   writel(1, IOMEM(clkevt_base + TIMER_INTCLR));

    evt->event_handler(evt);  //periodic 和 one-shot模式处理函数不一样,见4小节细节。

    return IRQ_HANDLED;

}
(5)per CPU注册clockevent

         上面只是初始化为每个CPU分配一个定时器,并没有将定时器注册到clockevent。而注册则是在各个CPU启动后,主CPU和次CPU注册是分开的,具体如下:

A.主CPU(cpu0)

         注册过程:kernel_init—> kernel_init_freeable —> smp_prepare_cpus —> percpu_timer_setup :

static void __cpuinitpercpu_timer_setup(void)

{

   unsigned int cpu = smp_processor_id();

   struct clock_event_device *evt = &per_cpu(percpu_clockevent, cpu);

   evt->cpumask = cpumask_of(cpu);

   if (!lt_ops || lt_ops->setup(evt)) //这里的setup就是前面初始化的:hi3536_local_timer_setup,将CPU0的timer注册到clockevent设备。

       broadcast_timer_setup(evt);

}

B.次CPU(cpu1~cpu3)

         注册过程:kernel_init—> kernel_init_freeable —> smp_init:

void __init smp_init(void)

   …

   /* FIXME: This should be done in userspace --RR */

   for_each_present_cpu(cpu) {

       if (num_online_cpus() >= setup_max_cpus)

           break;

        if (!cpu_online(cpu)) //启动所有未启动的CPU

           cpu_up(cpu);

    }

}

         cpu_up—> _cpu_up —> __cpu_up —> boot_secondary —> smp_ops.smp_boot_secondary—> hi3536_boot_secondary —> hi3536_secondary_startup(汇编) —> secondary_startup(汇编) —> __secondary_switched(汇编) —> secondary_start_kernel —>percpu_timer_setup该函数就是上面主CPU注册本地定时器的函数。
(6)clockevent初始化

         这里主要是注册clockevent的global timer,在periodic模式下不起作用,在one-shot模式下才会作用。注册过程过程如下:

         sp804_clockevents_init—> __sp804_clockevents_init —> clockevents_config_and_register —> clockevents_register_device—> clockevents_do_notify —> tick_notify(tick_init初始化的通知分发器) —>tick_check_new_device


4、periodic和one-shot模式

         针对clockevent有periodic和one-shot两种模式,主要的不同点:event_handler事件处理函数不同,细节见下列分析。
(1)periodic模式

         根据上一节的注册初始化,其先后顺序如下:timer1(clockeventglobal timer) —> timer4(clockevent cpu0 local timer) —> time5~7(clockeventcpu1~3 local timer)。

         注册timer1时运行在CPU0上,次CPU1~3还没有启动,这时tick_check_new_device接口会把timer1注册为CPU0的local timer,此刻timer1的event_handler= tick_handle_periodic。当CPU0调用smp_prepare_cpus注册自己的local timer时会用timer4替换timer1,此刻timer4的event_handler= tick_handle_periodic,而timer1的event_handler在tick_setup_device中被修改为clockevents_handle_noop(空实现),并在接口tick_check_broadcast_device中被注册为globaltimer设备(在周期模式下不起任何作用)。

         CPU1~3启动时调用secondary_start_kernel注册local timer时只有一个定时器分别是timer5~7,它们的event_handler= tick_handle_periodic。

         clockevents_handle_noop:空实现

         tick_handle_periodic主要完成如下工作:

A.如果是CPU0,则调用do_timer完成tick更新和墙上时钟更新

B.update_process_times完成:启动本地软中断;更新本CPU的运行队列;调度任务;SMP下触发运行队列均衡。


(2)one-shot模式

         设备启动时一开始是periodic模式,过程和上面一模一样。各个CPU触发本地软中断后发生切换,run_timer_softirq—> hrtimer_run_pending —> tick_check_oneshot_change —> tick_nohz_switch_to_nohz—> tick_switch_to_oneshot:

将CPU0~3的event_handler切换为:tick_nohz_handler

将timer1的event_handler切换为:tick_handle_oneshot_broadcast

切换只会发生一次,切换好后hrtimer_run_pending接口就会直接返回。

tick_nohz_handler:

         和tick_handle_periodic做的事情大致一样。

tick_handle_oneshot_broadcast主要完成:唤醒各个CPU,补偿tick的偏差。

         one-shot模式下event_handler处理函数每次都要重新设置next_event。该模式的好处就是可以节省CPU功耗。
5、系统调用例子讲解
(1)gettimeofday

         该函数主要用于获取微妙级别的时间。其对应的系统调用为sys_gettimeofday,实际起作用的是do_gettimeofday:

void do_gettimeofday(struct timeval*tv)                                                                                                         

{                                                                                                                                               

   struct timespec now;                                                                                                                         

   getnstimeofday(&now);                                                                                                                         

   tv->tv_sec = now.tv_sec;                                                                                                                     

   tv->tv_usec = now.tv_nsec/1000;                                                                                                             

}

int __getnstimeofday(structtimespec *ts)                                                                                                         

   struct timekeeper *tk = &timekeeper;

   unsigned long seq;                                                                                                                           

   s64 nsecs = 0;                                                                                                                                           

   do {

       seq = read_seqcount_begin(&timekeeper_seq);  //使用顺序锁进行数据同步访问

  ts->tv_sec = tk->xtime_sec;    //获取秒数,xtime_sec更新由update_all_time进行                                                                                                             

       nsecs = timekeeping_get_ns(tk); //调用clocksource的read函数(即上面的hi3536_clocksource_read),将计数转化为相应的ns数

    }while (read_seqcount_retry(&timekeeper_seq, seq));

   ts->tv_nsec = 0;

   timespec_add_ns(ts, nsecs); //调整ns数,传递给用户

      …..

}


(2)nanosleep

         这是一个ns级别的睡眠函数,对应的系统调用sys_nanosleep(kernel/hrtimer.c),实际起作用的是hrtimer_nanosleep—> do_nanosleep:

static int __sched do_nanosleep(structhrtimer_sleeper *t, enum hrtimer_mode mode)

         //初始化一个新的hrtimer

   hrtimer_init_sleeper(t, current);

       

   do {

       set_current_state(TASK_INTERRUPTIBLE); //设置当前任务睡眠

       hrtimer_start_expires(&t->timer, mode);//将新的hrtimer加入到timer_list

       if (!hrtimer_active(&t->timer)) //如果没有激活hrtimer则直接退出

           t->task = NULL;

           

       if (likely(t->task)) //如果激活了,就开始调度

           freezable_schedule();

   

       hrtimer_cancel(&t->timer); //运行到这里,说明定时器到期,那么取消定时器。

       mode = HRTIMER_MODE_ABS;

   

    }while (t->task && !signal_pending(current));

   __set_current_state(TASK_RUNNING); //设置进程状态可执行

   return t->task == NULL;

}

         不管内核有没有配置HIGH_RES_TIMERS,内核都编译httimer.c接口。如果没有配置hrtimer则使用低精度的tick方案,这时定时器是相当不准确;如果进行了配置,则使用高精度方案。两种方案下,定时器中断处理方法不同:

低精度下的hrtimer:

         update_process_times—> run_local_timers —> hrtimer_run_queues

高精度下的hrtimer:

run_hrtimer_softirq—>hrtimer_peek_ahead_timers —> __hrtimer_peek_ahead_timers —> hrtimer_interrupt


附录A

参考资料

http://www.wowotech.net/timer_subsystem … cture.html

http://www.wowotech.net/timer_subsystem … space.html

#24 内核模块 » Gentoo 之 time subsystem 概述 » 2024-03-17 22:29:59

batsom
回复: 0

Linux的计时系统(Timekeeping)在ULK中有专门一章来讲,但ULK第三版是基于2.6.10的,TimeKeeping部分已经有了很大的变化,如NOHZ mode和hrtimer,在ULK中都没有涉及,本文希望能对现在的kernel的Timekeeping部分做较完整的讲述。

从2.6.10到2.6.38,Timekeeping部分的变动可以参考<Hrtimers and Beyond: Transforming the Linux Time Subsystems>这篇文章,里面比较详细的讲述了2.6.16之前版本的Timekeeping的不足,以及日后的改进,到2.6.38,文章里提到的改进大多都已经merge到kernel里。

如ULK所说,Timekeeping主要完成以下工作:
*更新自系统启动以来所经过的时间
*更新系统的时间和日期
*衡量进程的运行时间以及为进程分配时间片
*更新资源使用统计数
*检查软定时器是否到时

2.6.10 kernel的Timekeeping的结构图如下:
FluxBB bbcode 测试


虚线右侧是硬件部分,不同Arch提供不一样的硬件,左侧是kernel。
为了完成Timekeeping的功能,硬件需要提供至少一种Clock source和Clock event source。Clock source只是一个简单的Counter,使能之后以固定的频率递增,kernel通过主动读取Counter可以判断时间的增加值,Clock event source是一个硬件Timer,设定后,可以以固定的频率产生中断,kernel可以在中断服务程序里完成上面所说的工作。理论上来说,只有Timer也可以维持系统的运行,但所有与时间相关的精度就仅限于Timer的产生频率。而Timer的中断频率一般不会很高,桌面系统常用100HZ~1000HZ,所以现在的系统Timer和Count都是必不可少的。

x86提供了多种Clock source和Clock event source。
Clock source有tsc, hpet, acpi_pm,clock event source有pit, hpet, local apic timer, acpi_pm timer等。这些硬件在ULK上均有描述。假如系统中存在多种Clock source和clock event source,kernel会以一定的规则来选择。
对于clock source,稳定且分辨率更高的会被选用,当然通过/sys/devices/system/clocksource/clocksource0/available_clocksouce可以手动选择clock source;
对于clock event source,稍微复杂一些,在uniprocessor系统中,优先选择支持One-shot模式且分辨率更高的,在SMP系统中,存在global event device和local event device。这两者的区别在ULK中有讲,global event device主要负责soft timer的处理,local event device负责与本CPU相关的工作,如CPU上进程执行时间的检查以及后面要说的hrtimer等。local event device除了优先选择oneshot模式和分辨率更高的之外,还要求device支持设置中断亲和力,因为local event device产生的中断,其他CPU是不能响应的。因此 lapic timer是local event device的最佳人选;local event device一般选择hpet或者pit(没有hpet的情况下)。
在我的机器上,global device是hpet,local device是lapic timer。

除了上面说的硬件之外,还有一个RTC,主要是为了保存实时时钟,但他也提供了timer的功能,只不过kernel很少用,定时开机会用到。

上述硬件的lapic产生的中断的中断号为0xef(LOC),RTC的中断号为0x8,global event device的中断号为0,即我们常说的timer中断。

Timer的中断在kernel即对应与jiffies,每次Timer中断,jiffies就增加1,为防止jiffies在短时间溢出,还有一个64位的jiffies_64,二者共用低32位。
kernel每收到一次Timer中断,就会将jiffies加1,并读取Clock source的值,通过HZ与s/ms/ns之间的关系计算系统的当前时间,写入到xtime和wall_to_monotonic中;检查进程的时间片是否用完,并为用完时间片的进程分配新的时间片;处理统计工作(Profiling);并在退出中断的时候检查soft timer是否到时。

soft timer采用的是时间轮模型,从上面的图可以知道,它是基于jiffies的,换句话说,他将jiffies作为其时钟源,当jiffies发生变化时,soft timer就会被处理,也就是global event device产生中断后。但对soft timer的处理并不是在timer 中断中进行的,而是在退出timer中断是的exit_irq()函数,该函数会检查softirq,soft timer就是以softirq的形式实现的。run_timer_softirq()函数执行具体的处理过程。

应该说,2.6.16之前的timekeeping是比较简单的,但弊端也较多。主要体现在一下几个方面:
1,缺乏必要的抽象层。代码与硬件结合紧密,不同架构下往往需要实现很多重复的代码,比如对clock source和clock event的管理
2,周期性的timer中断导致CPU不能进入sleep mode
3,soft timer的精度不够,其基于jiffies,最多只能提供jiffies的精度,在这样的timer模型下,要提高精度,只能提高HZ数,但这会给系统带来额外的负担

因此,2.6.16之后的kerel加入很多修改,主要包括:
*clock source和clock event source抽象层
*NOHZ mode
*hrtimer

clock source和clock event source抽象层将clock source device和clock event device的硬件进行抽象并统一管理,Arch相关的代码只需要负责注册这两类device即可;
NOHZ mode则抛弃了以前periodic的方式(周期性的产生Timer中断),使用硬件timer的oneshot模式,即配置一次,产生一次中断,这样就可以在系统空闲的时候,将硬件timer的下一次到期时间设置得较长,让CPU能够较长时间的进入sleep mode,从而达到省电和降温的目的;
hrtimer在传统的time wheel的模式外,新建立了一套timer机制,叫做hrtimers,即高精度定时器。hrtimer不再基于jiffies(Tick),最高可以达到ns的精度(需要硬件的支持)
新的timekeeping架构如下图:

FluxBB bbcode 测试

在新的架构之下,硬件被抽象成为clock source device和clock event device,时钟中断被抽象称为clock event,event还以分发,不同的event device在收到event之后,会去执行其所属的event_handler,而event_handler是回调函数,由tick模块根据不同的mode进行填充。

tick模块中继续对event device进行包装,产生tick device的概念,由于NOHZ和hrtimer的引入,event device可以有两种工作模式:periodic,oneshot。具有工作模式属性的event device即为tick device。事实上,tick device仅包含event device和mode两个成员。
对应于event device的两种模式,tick模块实现了三种模式的tick,PERIODIC_MODE, NOHZ_MODE_LOWRES, NOHZ_MODE_HIGHRES。
在kernel的config中,有几个选项来控制这些模式的选择:
CONFIG_NO_HZ, CONFIG_HIGH_RES_TIMERS
当CONFIG_NO_HZ=yes,CONFIG_HIGH_RES_TIMERS=no,使用NOHZ_MODE_LOWRES模式
当CONFIG_HIGH_RES_TIMERS=yes,使用NOHZ_MODE_HIGHRES模式
当CONFIG_NO_HZ=no, CONFIG_HIGH_RES_TIMERS=no,使用PERIODIC_MODE

PERIODIC_MODE下的tick周期性的产生,频率为HZ的值,硬件timer也配置为周期性的产生中断;
NOHZ_MODE_LOWRES模式下,tick的产生不是周期性的,而根据当前系统的负载来决定下一次tick的产生时间,对于硬件来说就是使用ONESHOT mode,在每次timer产生时决定下一次中断产生的时间,再将计算得到的值写入到硬件timer的寄存器中;
NOHZ_MODE_HIGHRES模式跟NOHZ_MODE_LOWRES模式类似,但是NOHZ_MODE_LOWRES模式决定下一次tick产生的时间还是以tick_period为单位的,其精度并没有提升,而NOHZ_MODE_HIGHRES模式则通过判断最近到时的hrtimer的到期值来决定下一次tick产生的时间,因此精度可以达到ns。
因此高精度的hrtimer也只能在NOHZ_MODE_HIGHRES模式下使用。

从实现上来说,这三个模式的主要差别就是event device的event_handler是不同的,PERIODIC_MODE的event_handler是tick_handle_periodic(),NOHZ_MODE_LOWRES的event_handler是tick_nohz_handler(),NOHZ_MODE_HIGHRES的event_handler是hrtimer_interrupt()。

kernel在初始化时会首先进入PERIODIC_MODE,然后在event_handler中根据kernel的CONFIG和硬件是否支持决定是否进入NOHZ_MODE_LOWRES和NOHZ_MODE_HIGHRES。

前面说过基于时间轮的soft timer精度只能到达tick的精度,而hrtimer的精度则可以达到ns,其原因就在于hrtimer只能在NOHZ_MODE_HIGHRES模式下工作,而此模式下,下一次tick的产生是根据本地CPU中最早到期的hrtimer的到期时间决定的,因此下一次tick的产生精度也就能达到ns,而在下一次tick的event_handler中,hrtimer就会被处理。
时间轮的timer是在softirq中处理的,而hrtimer是在timer的中断处理函数中处理的。

#25 内核模块 » Gentoo 之 IRQ subsystem 之 软件中断(softIRQ) » 2024-03-16 23:50:49

batsom
回复: 0

软件中断(softIRQ)是内核提供的一种延迟执行机制,它完全由软件触发,虽然说是延迟机制,实际上,在大多数情况下,它与普通进程相比,能得到更快的响应时间。软中断也是其他一些内核机制的基础,比如tasklet,高分辨率timer等。

/*****************************************************************************************************/
声明:本博内容均由http://blog.csdn.net/droidphone原创,转载请注明出处,谢谢!
/*****************************************************************************************************/
1.  软件中断的数据结构

1.1  struct softirq_action

        内核用softirq_action结构管理软件中断的注册和激活等操作,它的定义如下:

struct softirq_action
{
	void	(*action)(struct softirq_action *);
};

非常简单,只有一个用于回调的函数指针。软件中断的资源是有限的,内核目前只实现了10种类型的软件中断,它们是:

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
};

内核的开发者们不建议我们擅自增加软件中断的数量,如果需要新的软件中断,尽可能把它们实现为基于软件中断的tasklet形式。与上面的枚举值相对应,内核定义了一个softirq_action的结构数组,每种软中断对应数组中的一项:

static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;

1.2  irq_cpustat_t

        多个软中断可以同时在多个cpu运行,就算是同一种软中断,也有可能同时在多个cpu上运行。内核为每个cpu都管理着一个待决软中断变量(pending),它就是irq_cpustat_t:

typedef struct {
	unsigned int __softirq_pending;
} ____cacheline_aligned irq_cpustat_t;

irq_cpustat_t irq_stat[NR_CPUS] ____cacheline_aligned;

__softirq_pending字段中的每一个bit,对应着某一个软中断,某个bit被置位,说明有相应的软中断等待处理。

1.3  软中断的守护进程ksoftirqd

        在cpu的热插拔阶段,内核为每个cpu创建了一个用于执行软件中断的守护进程ksoftirqd,同时定义了一个per_cpu变量用于保存每个守护进程的task_struct结构指针:

DEFINE_PER_CPU(struct task_struct *, ksoftirqd);

大多数情况下,软中断都会在irq_exit阶段被执行,在irq_exit阶段没有处理完的软中断才有可能会在守护进程中执行。

2.  触发软中断

        要触发一个软中断,只要调用api:raise_softirq即可,它的实现很简单,先是关闭本地cpu中断,然后调用:raise_softirq_irqoff

void raise_softirq(unsigned int nr)
{
	unsigned long flags;
 
	local_irq_save(flags);
	raise_softirq_irqoff(nr);
	local_irq_restore(flags);
}

再看看raise_softirq_irqoff:

inline void raise_softirq_irqoff(unsigned int nr)
{
	__raise_softirq_irqoff(nr);
 
        ......
	if (!in_interrupt())
		wakeup_softirqd();
}

先是通过__raise_softirq_irqoff设置cpu的软中断pending标志位(irq_stat[NR_CPUS] ),然后通过in_interrupt判断现在是否在中断上下文中,或者软中断是否被禁止,如果都不成立,则唤醒软中断的守护进程,在守护进程中执行软中断的回调函数。否则什么也不做,软中断将会在中断的退出阶段被执行。

3.  软中断的执行

        基于上面所说,软中断的执行既可以守护进程中执行,也可以在中断的退出阶段执行。实际上,软中断更多的是在中断的退出阶段执行(irq_exit),以便达到更快的响应,加入守护进程机制,只是担心一旦有大量的软中断等待执行,会使得内核过长地留在中断上下文中。

3.1  在irq_exit中执行
        看看irq_exit的部分:

void irq_exit(void)
{
        ......
	sub_preempt_count(IRQ_EXIT_OFFSET);
	if (!in_interrupt() && local_softirq_pending())
		invoke_softirq();
        ......
}

如果中断发生嵌套,in_interrupt()保证了只有在最外层的中断的irq_exit阶段,invoke_interrupt才会被调用,当然,local_softirq_pending也会实现判断当前cpu有无待决的软中断。代码最终会进入__do_softirq中,内核会保证调用__do_softirq时,本地cpu的中断处于关闭状态,进入__do_softirq:

asmlinkage void __do_softirq(void)
{
        ......
	pending = local_softirq_pending();
 
	__local_bh_disable((unsigned long)__builtin_return_address(0),
				SOFTIRQ_OFFSET);
restart:
	/* Reset the pending bitmask before enabling irqs */
	set_softirq_pending(0);
 
	local_irq_enable();
 
	h = softirq_vec;
 
	do {
		if (pending & 1) {
	                ......
			trace_softirq_entry(vec_nr);
			h->action(h);
			trace_softirq_exit(vec_nr);
                        ......
		}
		h++;
		pending >>= 1;
	} while (pending);
 
	local_irq_disable();
 
	pending = local_softirq_pending();
	if (pending && --max_restart)
		goto restart;
 
	if (pending)
		wakeup_softirqd();
 
	lockdep_softirq_exit();
 
	__local_bh_enable(SOFTIRQ_OFFSET);
}

    首先取出pending的状态;
    禁止软中断,主要是为了防止和软中断守护进程发生竞争;
    清除所有的软中断待决标志;
    打开本地cpu中断;
    循环执行待决软中断的回调函数;
    如果循环完毕,发现新的软中断被触发,则重新启动循环,直到以下条件满足,才退出:
        没有新的软中断等待执行;
        循环已经达到最大的循环次数MAX_SOFTIRQ_RESTART,目前的设定值时10次;
    如果经过MAX_SOFTIRQ_RESTART次循环后还未处理完,则激活守护进程,处理剩下的软中断;
    推出前恢复软中断;


3.2  在ksoftirqd进程中执行
        从前面几节的讨论我们可以看出,软中断也可能由ksoftirqd守护进程执行,这要发生在以下两种情况下:

    在irq_exit中执行软中断,但是在经过MAX_SOFTIRQ_RESTART次循环后,软中断还未处理完,这种情况虽然极少发生,但毕竟有可能;
    内核的其它代码主动调用raise_softirq,而这时正好不是在中断上下文中,守护进程将被唤醒;

守护进程最终也会调用__do_softirq执行软中断的回调,具体的代码位于run_ksoftirqd函数中,内核会关闭抢占的情况下执行__do_softirq,具体的过程这里不做讨论。

4.  tasklet

       因为内核已经定义好了10种软中断类型,并且不建议我们自行添加额外的软中断,所以对软中断的实现方式,我们主要是做一个简单的了解,对于驱动程序的开发者来说,无需实现自己的软中断。但是,对于某些情况下,我们不希望一些操作直接在中断的handler中执行,但是又希望在稍后的时间里得到快速地处理,这就需要使用tasklet机制。 tasklet是建立在软中断上的一种延迟执行机制,它的实现基于TASKLET_SOFTIRQ和HI_SOFTIRQ这两个软中断类型。

4.1  tasklet_struct       

在软中断的初始化函数softirq_init的最后,内核注册了TASKLET_SOFTIRQ和HI_SOFTIRQ这两个软中断:

void __init softirq_init(void)
{
        ......
	open_softirq(TASKLET_SOFTIRQ, tasklet_action);
	open_softirq(HI_SOFTIRQ, tasklet_hi_action);
}

内核用一个tasklet_struct来表示一个tasklet,它的定义如下:

struct tasklet_struct
{
	struct tasklet_struct *next;
	unsigned long state;
	atomic_t count;
	void (*func)(unsigned long);
	unsigned long data;
};

next用于把同一个cpu的tasklet链接成一个链表,state用于表示该tasklet的当前状态,目前只是用了最低的两个bit,分别用于表示已经准备被调度执行和已经在另一个cpu上执行:

enum
{
	TASKLET_STATE_SCHED,	/* Tasklet is scheduled for execution */
	TASKLET_STATE_RUN	/* Tasklet is running (SMP only) */
};

原子变量count用于tasklet对tasklet_disable和tasklet_enable的计数,count为0时表示允许tasklet执行,否则不允许执行,每次tasklet_disable时,该值加1,tasklet_enable时该值减1。func是tasklet被执行时的回调函数指针,data则用作回调函数func的参数。

4.2  初始化一个tasklet

有两种办法初始化一个tasklet,第一种是静态初始化,使用以下两个宏,这两个宏定义一个tasklet_struct结构,并用相应的参数对结构中的字段进行初始化:

    DECLARE_TASKLET(name, func, data);定义名字为name的tasklet,默认为enable状态,也就是count字段等于0。
    DECLARE_TASKLET_DISABLED(name, func, data);定义名字为name的tasklet,默认为enable状态,也就是count字段等于1。

第二个是动态初始化方法:先定义一个tasklet_struct,然后用tasklet_init函数进行初始化,该方法默认tasklet处于enable状态:

struct tasklet_struct tasklet_xxx;
......
tasklet_init(&tasklet_xxx, func, data);

4.3  tasklet的使用方法

使能和禁止tasklet,使用以下函数:

    tasklet_disable()  通过给count字段加1来禁止一个tasklet,如果tasklet正在运行中,则等待运行完毕才返回(通过TASKLET_STATE_RUN标志)。
    tasklet_disable_nosync()  tasklet_disable的异步版本,它不会等待tasklet运行完毕。
    tasklet_enable()  使能tasklet,只是简单地给count字段减1。

调度tasklet的执行,使用以下函数:

    tasklet_schedule(struct tasklet_struct *t)  如果TASKLET_STATE_SCHED标志为0,则置位TASKLET_STATE_SCHED,然后把tasklet挂到该cpu等待执行的tasklet链表上,接着发出TASKLET_SOFTIRQ软件中断请求。
    tasklet_hi_schedule(struct tasklet_struct *t)  效果同上,区别是它发出的是HI_SOFTIRQ软件中断请求。

销毁tasklet,使用以下函数:

    tasklet_kill(struct tasklet_struct *t)  如果tasklet处于TASKLET_STATE_SCHED状态,或者tasklet正在执行,则会等待tasklet执行完毕,然后清除TASKLET_STATE_SCHED状态。


4.4  tasklet的内部执行机制

内核为每个cpu用定义了一个tasklet_head结构,用于管理每个cpu上的tasklet的调度和执行:

struct tasklet_head
{
	struct tasklet_struct *head;
	struct tasklet_struct **tail;
};
 
static DEFINE_PER_CPU(struct tasklet_head, tasklet_vec);
static DEFINE_PER_CPU(struct tasklet_head, tasklet_hi_vec);

回到4.1节,我们知道,tasklet是利用TASKLET_SOFTIRQ和HI_SOFTIRQ这两个软中断来实现的,两个软中断只是有优先级的差别,所以我们只讨论TASKLET_SOFTIRQ的实现,TASKLET_SOFTIRQ的中断回调函数是tasklet_action,我们看看它的代码:

static void tasklet_action(struct softirq_action *a)
{
	struct tasklet_struct *list;
 
	local_irq_disable();
	list = __this_cpu_read(tasklet_vec.head);
	__this_cpu_write(tasklet_vec.head, NULL);
	__this_cpu_write(tasklet_vec.tail, &__get_cpu_var(tasklet_vec).head);
	local_irq_enable();
 
	while (list) {
		struct tasklet_struct *t = list;
 
		list = list->next;
 
		if (tasklet_trylock(t)) {
			if (!atomic_read(&t->count)) {
				if (!test_and_clear_bit(TASKLET_STATE_SCHED, &t->state))
					BUG();
				t->func(t->data);
				tasklet_unlock(t);
				continue;
			}
			tasklet_unlock(t);
		}
 
		local_irq_disable();
		t->next = NULL;
		*__this_cpu_read(tasklet_vec.tail) = t;
		__this_cpu_write(tasklet_vec.tail, &(t->next));
		__raise_softirq_irqoff(TASKLET_SOFTIRQ);
		local_irq_enable();
	}
}

解析如下:

    关闭本地中断的前提下,移出当前cpu的待处理tasklet链表到一个临时链表后,清除当前cpu的tasklet链表,之所以这样处理,是为了处理当前tasklet链表的时候,允许新的tasklet被调度进待处理链表中。
    遍历临时链表,用tasklet_trylock判断当前tasklet是否已经在其他cpu上运行,而且tasklet没有被禁止:
        如果没有运行,也没有禁止,则清除TASKLET_STATE_SCHED状态位,执行tasklet的回调函数。
        如果已经在运行,或者被禁止,则把该tasklet重新添加会当前cpu的待处理tasklet链表上,然后触发TASKLET_SOFTIRQ软中断,等待下一次软中断时再次执行。

分析到这了我有个疑问,看了上面的代码,如果一个tasklet被tasklet_schedule后,在没有被执行前被tasklet_disable了,岂不是会无穷无尽地引发TASKLET_SOFTIRQ软中断?
通过以上的分析,我们需要注意的是,tasklet有以下几个特征:

    同一个tasklet只能同时在一个cpu上执行,但不同的tasklet可以同时在不同的cpu上执行;
    一旦tasklet_schedule被调用,内核会保证tasklet一定会在某个cpu上执行一次;
    如果tasklet_schedule被调用时,tasklet不是出于正在执行状态,则它只会执行一次;
    如果tasklet_schedule被调用时,tasklet已经正在执行,则它会在稍后被调度再次被执行;
    两个tasklet之间如果有资源冲突,应该要用自旋锁进行同步保护;

页脚

Powered by FluxBB