公告

Gentoo群:87709706,OS群:838664909

#1 内核模块 » Gentoo伯克利子系统之八:Linux tracing system对比分析 » 2024-07-13 13:01:51

batsom
回复: 0

Linux trace技术发展已久,经常看到很多的专业术语,从perf LTTng systemtap  bpftrace tracepoint  trace BCC bpf ebpf等词汇,这些关键的词汇有着怎样的联系和关联,通过下面的这个图可以直观的认识到这几种关键技术的内在联系。

        整个内核的跟踪和观测技术分为前端工具,内核框架支持、内核数据源支持三个部分。前端工具通常作为使用者,在跟踪内核或者观测内核时使用的命令,比如:bpftrace BCC 等工具,内核态框架是指支持上述前端工具的内核框架,比如systemtap的内核模块、BPF的字节码等。内核态数据源是指对内核跟踪或者观测获取到的数据方法,比如kprobe和tracepoint等。

FluxBB bbcode 测试

上述内核观测和跟踪技术的调用和对应关系如下:通过下面的关系图,可以看到清晰的调用关系。     

FluxBB bbcode 测试

前面的文章中介绍过Systemtap bpftrace perf在内核跟踪和观测方面的使用方法和实例。这里我们从几个维度对这几种技术进行对比分析。

FluxBB bbcode 测试

      从上面的几个维度的对比中我们能够更清晰的认识各类技术的特点。对于eBPF来说在各个方面都是占有优势的,

可编程性:BPF是可针对内核可编程的,

内核内置:也是现在内核中内置的技术框架,无需专门安装,只需要打开内核的配置参数。

安全性:BPF对编写的程序有安全检查

内核版本的支持范围:BPF主要针对高版本的内核,越高版本的内核,其包含的功能越丰富,对于老版本的内核不友好,但是systemtap基本通吃所有内核版本。

性能:在性能上这几种技术类型没有本质的区别

生态:BPF作为最近几年新发展的技术,围绕BPF的各类开源工具以及生态发展较快,其他几类工具基本处于发展停滞阶段。

功能丰富度:BPF除了内核的观测和跟踪方面发挥作用外,在内核安全、网络性能提升等多方向上都有对应的解决方案

使用便捷性:根据bpf技术的bpftrace bpf-tool等工具提供了大量可直接使用的工具,并且可以结合高级的编程语言python go进行开发,在使用便捷性上也有极大的优势。

#2 内核模块 » Gentoo伯克利子系统之七:静态跟踪点tracepoint » 2024-07-13 12:59:42

batsom
回复: 0

在《linux-observability-with-bpf》中第4章节中的案例中,有一个tracepoint bpf_prog_load的实例,在我自己的云主机上,执行不通过。发现网上也有一些人遇到。针对该问题分析了一下具体的原因。 根本原因是:内核版本的问题。
下面是《linux-observability-with-bpf》中的一个示例程序:


from bcc import BPF
 
bpf_source = """
int trace_bpf_prog_load(struct pt_regs *ctx) {
  char comm[16];
  bpf_get_current_comm(&comm, sizeof(comm));
  bpf_trace_printk("%s is loading a BPF program", comm);
  return 0;
}
"""
 
bpf = BPF(text = bpf_source)
bpf.attach_tracepoint(tp = "bpf:bpf_prog_load", fn_name = "trace_bpf_prog_load")
bpf.trace_print()

上面的程序在bpf_prog_load的函数中添加一个添加一个tracepoint点。通过下面的命令可以参考本操作系统支持tracepoint的函数。
1)通过下面目录中,查看是否有相关的events

FluxBB bbcode 测试

2)通过bcc的相关命令行工具

FluxBB bbcode 测试

        通过是上面的两个命令查看,都没有相关bpf的tracepoint点。所有执行上面的命令会出现下面的错误信息。open(/sys/kernel/debug/tracing/events/bpf/bpf_prog_load/id): No such file or directory

FluxBB bbcode 测试

        通过对内核中相关的文件进行分析, 在kernel/bpf/syscall.c文件中,我们可以看到其中提交的一个commit,去掉对bpf_prog_load的tracepoint,使用git show 4d220ed 显示下面的信息


FluxBB bbcode 测试

FluxBB bbcode 测试
        通过上面的查看,是在内核4.18之后的内核删除了。commit的描述是在有可能导致内核的panic。为了测试tracepoint的使用,可以使用下面的代码进行验证。

from bcc import BPF
 
bpf_source = """
int trace_net_dev_xmit(struct pt_regs *ctx) {
  char comm[16];
  bpf_get_current_comm(&comm, sizeof(comm));
  bpf_trace_printk("%s is loading a BPF program", comm);
  return 0;
}
"""
 
bpf = BPF(text = bpf_source)
bpf.attach_tracepoint(tp = "net:net_dev_xmit", fn_name = "trace_net_dev_xmit")
bpf.trace_print()

#3 内核模块 » Gentoo伯克利子系统之六:使用bpf实现xdp的例子 » 2024-07-13 12:55:52

batsom
回复: 0

本篇文章参考《Linux  Observability with BPF》中第7章的例子,主要功能是借助于ip命令作为前端,对其他主机访问tcp的8000端口进行限制,这里需要使用较新版本的iproute2软件工具包.

1. 下载编译iproute2 工具包,使用最新的ip命令,支持配置xdp

   git clone git://git.kernel.org/pub/scm/linux/kernel/git/shemminger/iproute2.git

在编译iproute2时,需要开启支持libbpf的选项信息,在iproute2目录下配置使用下面的配置选项信息

  ./configure --libbpf_force=on --LIBBPF_DIR=/usr/local/lib64

        执行上面的命令,可能出现下面的错误信息,

FluxBB bbcode 测试

确定libbpf的pk-config配置文件位置,/usr/local/lib64/pkgconfig/libbpf.pc  使用

export PKG_CONFIG_PATH=/usr/local/lib64/pkgconfig/ 导入libbpf的配置文件,

使用pkg-config --list-all | grep libbpf 查看是否配置libbpf

FluxBB bbcode 测试

然后执行,make && make install 进行安装。

2. 编写bpf程序

       本bpf程序对于访问本机的tcp的协议的8000端口进行限制,


#include <linux/bpf.h>
 
#include <linux/if_ether.h>
 
#include <linux/in.h>
 
#include <linux/ip.h>
 
#include <linux/if_packet.h>
 
#include <bpf/bpf_helpers.h>
 
#include <linux/if_vlan.h>
 
#include <linux/types.h>
 
#include <linux/tcp.h>
 
#include <linux/udp.h>
 
#include <bpf/bpf_helpers.h>
 
#include <bpf/bpf_endian.h>
 
 
 
static __always_inline int get_dport(void *trans_data, void *data_end, int protocol)
 
{
 
    struct tcphdr *th;
 
    struct udphdr *uh;
 
 
    switch (protocol) {
 
        case IPPROTO_TCP:
 
            th = (struct tcphdr *)trans_data;
 
            if ((void*)(th + 1) > data_end)
 
                return -1;
 
            return th->dest;
 
        case IPPROTO_UDP:
 
            uh = (struct udphdr *)trans_data;
 
            if ((void *)(uh + 1) > data_end)
 
                return -1;
 
            return uh->dest;
 
        default:
 
            return 0;
 
    }
 
 
}
 
 
SEC("mysection")
 
int myprogram(struct xdp_md *ctx) {
 
  void *data = (void *)(long)ctx->data;
 
  void *data_end = (void *)(long)ctx->data_end;
 
  char fmt[] = "source = %d \n";
 
  struct iphdr *ip;
 
  int dport;
 
  int hdport;
 
  struct ethhdr *eth = data;
 
  struct iphdr *iph = data + sizeof(struct ethhdr);
 
 
    if ((void *)(iph + 1) > data_end) {
 
        return XDP_DROP;
 
    }
 
 
  dport = get_dport(iph + 1, data_end,iph->protocol);
 
 
  if (dport == -1 || dport == bpf_htons(8000)) {
 
        bpf_trace_printk(fmt,sizeof(fmt),bpf_ntohs(dport));
 
      return XDP_DROP;
 
  }
 
 
  return XDP_PASS;
 
}
 
char _license [] SEC ("license") = "GPL";

使用下面的命令进行编译:clang -g -c -O2 -target bpf -c program.c -o program.o

编译完成后,使用下面的ip命令对某个网卡进行价值xdp文件。

ip link set dev eth0 xdp obj program.o sec mysection   

通过上面的命令加载后,在接口上出现加载的xdp的类型和ID,表明加载成功。

FluxBB bbcode 测试

使用python3 -m http.server在本地主机上其中http服务器,并监听8000端口。

FluxBB bbcode 测试

使用另一台主机上使用nmap命令扫描对方监听的端口。nmap -sS 10.9.4.222,扫描结果如下:

FluxBB bbcode 测试

ip link set dev eth0 xdp off 关闭加载的xdp程序。再次使用nmap扫描

#4 内核模块 » Gentoo伯克利子系统之五:Systemtap BPF/BCC bpftrace 实践对比 » 2024-07-13 12:52:36

batsom
回复: 0

本篇文章通过一个案例,对systemtap、BPF/BCC、bpftrace三种不同类型的内核探测工具进行剖析和对比。这个程序就是简单对icmp_rcv函数,收到icmp报文,打印出对应的源IP地址和目的IP地址。

1. 使用BPF/BCC
1.1在centos8操作系统上安装对应的软件二进制包

1)	安装kernel-devel包;
2)	安装dnf -y install bcc-tools

1.2 源码包安装

dnf install -y bison cmake ethtool flex git iperf3 libstdc+±devel python3-netaddr python3-pip gcc gcc-c++ make zlib-devel elfutils-libelf-devel
dnf install -y clang clang-devel llvm llvm-devel llvm-static ncurses-devel
dnf -y install netperf
pip3 install pyroute2
ln -s /usr/bin/python3 /usr/bin/python
dnf -y install openssl
git clone https://github.com/iovisor/bcc.git
mkdir bcc_build
cmake …/bcc -DCMAKE_INSTALL_PREFIX=/usr -DENABLE_LLVM_SHARED=1
cd …/&& make -j10
make install
1.3 程序示例

使用bpf/bcc需要的内核版本最少是4.10以上。
使用下面的bcc代码,


#!/usr/bin/env python3.6

from __future__ import print_function
from bcc import BPF
from bcc.utils import printb

bpf_text = """
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <bcc/proto.h>
#include <uapi/linux/icmp.h>
#include <linux/icmp.h>
#include <uapi/linux/ip.h>
#include <linux/ip.h>


static inline struct iphdr *skb_to_iphdr(const struct sk_buff *skb)
{
    // unstable API. verify logic in ip_hdr() -> skb_network_header().
    return (struct iphdr *)(skb->head + skb->network_header);
}

int icmp_rcv_cb(struct pt_regs *ctx, struct sk_buff *skb)
{
            struct icmphdr *icmph ;
            struct iphdr *iph = skb_to_iphdr(skb);
            bpf_trace_printk("ipsrc:%pI4  ipdst:%pI4 \\n",&iph->saddr, &iph->daddr);
            icmph = (struct icmphdr *)skb->data;
            bpf_trace_printk("devname:%s ----- icmp_type:%d  \\n",skb->dev->name, icmph->type);
            return 0;
};
"""
# initialize BPF
b = BPF(text=bpf_text)
b.attach_kprobe(event="icmp_rcv", fn_name="icmp_rcv_cb")
#end format output
while 1:
    # Read messages from kernel pipe
    (task, pid, cpu, flags, ts, msg) = b.trace_fields()
    print("task:%s pid: %d %s " % (task, pid, msg))
#b.trace_print()

FluxBB bbcode 测试

2. Systemtap
2.1安装 systemtap

在centos8 上直接使用yum安装 yum install systemtap systemtap-runtime
2.2 Stap-prep

FluxBB bbcode 测试

通过在http://debuginfo.centos.org/8/x86_64/Packages/下载安装完debuginfo包后,执行stap-prep命令

FluxBB bbcode 测试

简单测试可以运行成功

FluxBB bbcode 测试

2.3 程序示例

下面是systemtap的方式对icmp_rcv函数的探测,对本机收到的ICMP报文打印出,对应的源IP和目的IP地址。


stap -g icmp_systemtap.stp
#!/usr/bin/stap -g
%{
#include <linux/kernel.h>
#include <linux/net.h>
#include <linux/skbuff.h>
#include <net/ip.h>
#include <linux/module.h>
#include <uapi/linux/if_packet.h>
#include <linux/fdtable.h>
#include <net/icmp.h>

        static inline void ip2str(char *to,unsigned int from)
        {
                int size = snprintf(to,16,"%pI4",&from);
                to[size] = '\0';
         }
%}
function get_icmp_packet_info:string(skb:long)
%{
        int ret = -1;
        struct sk_buff *skb = (struct sk_buff *)STAP_ARG_skb;
        struct iphdr *ip_header;
        unsigned int src_ip_1 = 0;
        unsigned int  dst_ip_1 = 0;
        char src_ip[16],dst_ip[16];
        struct icmphdr *icmph;

        if(!skb)
        {
                goto EXIT_F;
        }

        ip_header = (struct iphdr *)skb_network_header(skb);


        if(!ip_header)
        {
                goto EXIT_F;
        }

        src_ip_1 = (unsigned int)ip_header->saddr;
        dst_ip_1 = (unsigned int)ip_header->daddr;
        ip2str(src_ip,src_ip_1);
        ip2str(dst_ip,dst_ip_1);

        icmph = icmp_hdr(skb);
        if(icmph->type == 0)
        {

                goto ECHO_ICMP;
        }
        if(icmph->type == 8)
        {
                goto REPLY_ICMP;
        }

EXIT_F:
        snprintf(STAP_RETVALUE,MAXSTRINGLEN,"ERROR:src_ip:%s dst_ip:%s",src_ip,dst_ip);
ECHO_ICMP:
        snprintf(STAP_RETVALUE,MAXSTRINGLEN,"ECHO_ICMP:src_ip:%s dst_ip:%s",src_ip,dst_ip);
REPLY_ICMP:
        snprintf(STAP_RETVALUE,MAXSTRINGLEN,"REPLY_ICMP:src_ip:%s dst_ip:%s",src_ip,dst_ip);
%}

global locations

probe begin { printf("Monitoring for recv icmp packets\n") }
probe end { printf("Stropping monitoring  packets\n") }

probe kernel.function("icmp_rcv").return
{
        printf("%s\n",get_icmp_packet_info($skb))
        iphdr = __get_skb_iphdr($skb)
        saddr = format_ipaddr(__ip_skb_saddr(iphdr), @const("AF_INET"))
        daddr = format_ipaddr(__ip_skb_daddr(iphdr), @const("AF_INET"))
        printf("src_ip:%s  dst_ip:=%s\n",saddr,daddr);

}

probe timer.sec(5)
{
        exit ()
}

下面是运行后的测试结果:

FluxBB bbcode 测试

3. bpftrace
3.1 安装软件

yum -y install bpftrace
3.2 程序示例

bpftrace是使用自定义单行代码和简短脚本的临时工具的不错的选择,而BCC是复杂工具和守护程序的理想选择、bpftrace和BCC都是BPF的前端工具。


在这里插入代码片#!/usr/bin/bpftrace

#include <linux/skbuff.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <linux/socket.h>

BEGIN
{
        printf("Tracing icmp rev.Hit  Ctrl-C end.\n");
}

kprobe:icmp_rcv
{
        $skb = (struct sk_buff *)arg0;

        $iph = (struct iphdr*)($skb->head + $skb->network_header);
        $src_ip = ntop(AF_INET,$iph->saddr);
        $dst_ip = ntop(AF_INET,$iph->daddr);

        printf("src_ip:%s  ----> dst_ip:%s\n",$src_ip,$dst_ip);
}

END
{
        printf("OVER  bye!!")
}

运行结果如下:

FluxBB bbcode 测试

4 总结

    使用systemtap工具跟踪内核需要安装和内核对应版本的debuginfo包,systemtap作为老牌的内核跟踪工具,可以支持比较老的内核版本,对于现有存量的内核定位跟踪有明显的优势。
    BPF/BCC作为新的内核跟踪工具,需要较新的内核版本,最少是4.10版本,最好是4.19版本的内核。
    通过运行对比发现,编译和运行BPF/BCC的代码比systemtap的代码要快的多。
    BPF有各类安全检查,避免在内核跟踪过程中产生panic,systemtap没有此类的安全检查,需要开发者在开发systemtap程序时,保证代码的安全性。
    Bpftrace作为内核跟踪的一种工具,特别适合简单的内核跟踪,适合一条命令搞定的内核跟踪,bpftrace也有自己的一套语法体系可用。
    各种不同类型的内核探测跟踪技术,适合不同类型的场景,在实际使用中可选择适合自己的方式。

参考文献:
https://lwn.net/Articles/852112/

#5 内核模块 » Gentoo伯克利子系统之四:使用sockmap示例:bypass内核协议栈 » 2024-07-13 12:47:16

batsom
回复: 0

当我们使用127.0.0.1的localhost地址,在本地机器上进行发送和接受数据时,整个数据的发送还是要经过完整的内核协议栈。Cilimu的网络借用bfp的sockmap bypass提高整个网络的性能,参考《How to use eBPF for accelerating Cloud Native application》文章中的示例,在centos7.6平台上进行验证和测试。具体源码的github为:。

具体的原理解释性的内容就不从其他网页上搬迁过来了。主要有以下几点需要注意的问题,在这里记录以下:

1. 文章中使用的cgroup为cgroup v2版本的,在centos7上默认使用的是cgroup v1版本,可以通过修改内核的启动参数,添加对cgroup v2版本的支持,添加cgroup_no_v1=all 支持cgroup v2

2. 在添加cgroup v2支持后,使用mount命令看不到cgroup2的支持。使用下面的命令查看是否支持cgroup2,如果看到cgroup2 说明系统已经支持。

FluxBB bbcode 测试

3. centos和ubuntu对cgroup v2的目录是不一样的,没有/sys/fs/cgroup/unified目录,需要自己创建,使用下面的命令创建cgroupv2的支持。

mkdir /root/cgroup2

mount -t cgroup2 nodev /root/cgroup2

4. 在attach的命令中,使用下面的cgroup2目录,bpftool cgroup attach "/root/cgroup2/" sock_ops pinned "/sys/fs/bpf/bpf_sockop"

测试结果:在一个窗口中开启:socat TCP4-LISTEN:1000,fork exec:cat作为服务器端,在另一个窗口中:nc localhost  1000。通过cat /sys/kernel/debug/tracing/trace_pipe文件可以看到端口号,通过在lo上面抓包也可以看到。

FluxBB bbcode 测试

使用tcpmdump抓包

通过nc发送数据

FluxBB bbcode 测试

FluxBB bbcode 测试

#6 内核模块 » Gentoo伯克利子系统之三:BPF示例程序 » 2024-06-23 23:08:52

batsom
回复: 0

上一篇文章中BPF的例子是以BCC的整体框架为基础,本篇介绍一下基于libbpf库函数为基础,结合内核中的bpf的sample为基础编写一个BPF的程序,本篇介绍是以《linux-observability-with-bpf》这本书第二章的例子为基础,由于内核版本的不同,本篇介绍是以Linux5.16内核为基础,Linux5.16内核中的接口函数与书中的给到的程序案例有较大的差别。
1. 下载并编译内核
1) 确定和编译内核版本
下载需要编译的内核版本,本次使用的内核版本为:Linux-5.16.11版本。
2) 修改内核的配置文件,设置CONFIG_DEBUG_INFO_BTF=y,编译调试,
3) 编译内核
make olddefconfig
make -j 4
make modules_install
make install
通过命令grub2-set-default 设置启动的内核
4) 重启机器使用安装的新内核版本:5.16.11.frank+
5) 确定/sys/kernel/btf/vmlinux文件是否存在。
2. 编译安装libbpf库
1) 进入目录tools/lib/bpf  在该目录下执行make install

2) 修改/etc/ld.so.conf 文件,添加/usr/local/lib64  执行ldconfig,查看ldconfig  -v 2> /dev/null | grep libbpf
如果没有编译libbpf库,在编译bpf程序中会出现,下面的错误信息
上述准备工作完毕后,有以下两种方式编译bpf的例子,第一种方式,把编写的bpf程序放到sample/bpf目录下,首先编译sample/bpf,
1. 编译内核下samples/bpf目录下的bpf
1) 在编译之前安装必要的工具:
yum -y install binutils-devel
yum -y install readline-devel
yum -y install  dwarves  libdwarves1  libdwarves1-devel(dwarves版本号最好大于1.17)
yum -y install libcap-devel 
2) 在sample/bpf目录下  make
在编译的过程中,确定vmlinux的位置,
make VMLINUX_BTF=/sys/kernel/btf/vmlinux -C samples/bpf
使用vmlinux产生vmlinux.h头文件,CO:RE开发需要vmlinux.h文件,(Compile once, run everywhere)
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
2. 编译自己编写的bpf程序
1)编译通过完成后,修改sample/bpf的目录下的Makefile文件,添加下面的三行代码:
hello-objs := hello_user.o
always-y += hello_kern.o
tprogs-y += hello
hello_user 为我们用户空间的程序名,hello_kern为我们的内核空间程序名。
2)Kernel hello_kern.c程序:

#include <linux/ptrace.h>
#include <linux/version.h>
#include <uapi/linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include "trace_common.h"
SEC("tracepoint/syscalls/sys_enter_execve")
 int bpf_prog(struct pt_regs *ctx) {
    char msg[] = "Hello, BPF World!";
    bpf_trace_printk(msg, sizeof(msg));
    return 0;
  }
char _license[] SEC("license") = "GPL";
   u32 _version SEC("version") = LINUX_VERSION_CODE;

kernel程序比较简单,意思是在执行到内核中的execve函数时,打印 Hello BPF World!

3) 应用程序 hello_user.c

#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include <fcntl.h>
#include <unistd.h>
 
#define DEBUGFS "/sys/kernel/debug/tracing/"
int load_bpf_file(char *filename);
 
int load_bpf_file(char *path)
{
    struct bpf_object *obj;
    struct bpf_program *prog;
    struct bpf_link *link = NULL;
    int progs_fd;
    printf("%s\n",path);
 
    obj = bpf_object__open_file(path, NULL);
    if (libbpf_get_error(obj))
    {
        fprintf(stderr, "ERROR: opening BPF object file failed\n");
        return 0;
    }
 
   if (bpf_object__load(obj))
    {
        fprintf(stderr, "ERROR: loading BPF object file failed\n");
        goto cleanup;
    }
 
    prog = bpf_object__find_program_by_name(obj, "bpf_prog");
    if (!prog) {
        printf("finding a prog in obj file failed\n");
        goto cleanup;
    }
 
    link = bpf_program__attach(prog);
    if (libbpf_get_error(link)) {
        fprintf(stderr, "ERROR: bpf_program__attach failed\n");
        link = NULL;
        goto cleanup;
    }
 
 read_trace_pipe();
 
cleanup:
    bpf_link__destroy(link);
    bpf_object__close(obj);
    return 0;
}
 
void read_trace_pipe(void)
{
       int trace_fd;
 
       trace_fd = open(DEBUGFS "trace_pipe", O_RDONLY, 0);
       if (trace_fd < 0)
               return;
 
       while (1) {
               static char buf[4096];
               ssize_t sz;
 
               sz = read(trace_fd, buf, sizeof(buf) - 1);
               if (sz > 0) {
                       buf[sz] = 0;
                       puts(buf);
               }
       }
}
 
int main(int argc, char **argv) {
   if (load_bpf_file("hello_kern.o") != 0) {
       printf("The kernel didn't load the BPF program\n");
       return -1;
   }
}

执行上面的程序输出如下结果:
FluxBB bbcode 测试

第二种方法:  如果不把编写的bpf示例程序放到,samples/bpf目录下,可以单独写一个makefile文件,内容如下:

CLANG = clang
 
EXECABLE = monitor-exec
 
BPFCODE = bpf_program
 
BPFTOOLS = /data/kernel/v1/linux-stable/samples/bpf
 
CCINCLUDE += -I/data/kernel/v1/linux-stable/tools/testing/selftests/bpf
 
LOADINCLUDE += -I/data/kernel/v1/linux-stable/samples/bpf
LOADINCLUDE += -I/data/kernel/v1/linux-stable//tools/lib
LOADINCLUDE += -I/data/kernel/v1/linux-stable/tools/perf
LOADINCLUDE += -I/data/kernel/v1/linux-stable/tools/include
LIBRARY_PATH = -L/usr/local/lib64
BPFSO = -lbpf
 
CFLAGS += $(shell grep -q "define HAVE_ATTR_TEST 1" /data/kernel/v1/linux-stable/tools/perf/perf-sys.h \
                 && echo "-DHAVE_ATTR_TEST=0")
 
.PHONY: clean $(CLANG) bpfload build
 
clean:
       rm -f *.o *.so $(EXECABLE)
 
build: ${BPFCODE.c}
       $(CLANG) -O2 -target bpf -c $(BPFCODE:=.c) $(CCINCLUDE) -o ${BPFCODE:=.o}
 
bpfload: build
       clang $(CFLAGS) -o $(EXECABLE) -lelf $(LOADINCLUDE) $(LIBRARY_PATH) $(BPFSO) \
       loader.c
 
$(EXECABLE): bpfload
 
.DEFAULT_GOAL := $(EXECABLE)

1)本程序虽然以《linux-observability-with-bpf》第2章的程序为基础,但是随着内核的更新,采用5.16版本内核时load_bpf_file函数已经被移除了,需要重新调用函数实现load_bpf_file函数。
2)随着bpf和内核版本的不断变化,参考本文时需要重点关注不同的内核版本、bpftool、gcc等各类工具的版本。
3) 内核源代码中的samples/bpf目录下有大量的bpf的示例程序可以参考。
4)使用bcc框架版本的bpf程序和使用libbpf库bpf程序在编写方式上会有所不同,注意不同的接口函数。
5)centos安装libbpf -devel


sed -i -e "s|mirrorlist=|#mirrorlist=|g" /etc/yum.repos.d/CentOS-*

sed -i -e "s|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g" /etc/yum.repos.d/CentOS-*

dnf --enablerepo=PowerTools install libbpf-devel

参考文献:https://blog.aquasec.com/vmlinux.h-ebpf-programs

#7 内核模块 » Gentoo伯克利子系统之二:基于BCC的BPF示例程序 » 2024-06-13 22:31:35

batsom
回复: 0

结合前面一篇针对BPF的学习,本篇文章重点介绍编写一个对内核系统调用exec的例子。本测试例子基本上包含了全部的,syscall类别系统调用的BPF的框架。


```python
#!/usr/bin/python
from __future__ import print_function
from bcc import BPF
from collections import defaultdict

bpf_text = """
#include
#include
#include
#define ARGSIZE 256
struct data_t {
      u32 pid; // PID as in the userspace term (i.e. task->tgid in kernel)
      char comm[TASK_COMM_LEN];
      char argv[ARGSIZE];
};
BPF_PERF_OUTPUT(events);
int syscall__execve(struct pt_regs *ctx,
                   const char __user *filename,
                   const char __user *const __user *__argv,
                   const char __user *const __user *__envp)
{
    struct data_t data = {};
    bpf_trace_printk("Hello, World!222%s\\n",filename);
    data.pid = bpf_get_current_pid_tgid();
    bpf_get_current_comm(&data.comm,sizeof(data.comm));
    bpf_probe_read_user(data.argv, sizeof(data.argv), filename);
    events.perf_submit(ctx, &data, sizeof(struct data_t));
    return 0;
}
"""
b = BPF(text=bpf_text)
execve_fnname = b.get_syscall_fnname("execve")
b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve")
print("%-18s %-16s %-14s" % ("COMM", "PID","ARGS"))


argv = defaultdict(list)

def print_event(cpu, data, size):
    event = b["events"].event(data)
    argv[event.pid].append(event.argv)

    argv_text = b' '.join(argv[event.pid]).replace(b'\n', b'\\n')

    print("%-18s %-16d %-14s" % (event.comm, event.pid,argv_text))


b["events"].open_perf_buffer(print_event)
while 1:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()
```

主要注意点:

1. BPF_PERF_OUTPUT   创建BPF的table,通过Perf 的环形缓存区,把用户定义的event事件的数据推送到用户空间,这是把事件数据推送到用户空间的首选的方式。 也就是如果把从内核中获取到的数据,push到用户空间进行处理和展示,在代码中添加该宏。

2. b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve") 这里的fn_name="syscal__execve"函数名,保持格式的绝对一致,也就是syscall__ 后面两个下划线,execve名字和内核中syscall保持一致。
3.  在内核中插桩syscall__execve 函数时,这里的第一个参数struct pt_regs *ctx 保持固定,其余的参数是syscall类函数保持一致的,下面是调用execve的参数。

int execve(const char *pathname, char *const argv[], char *const envp[]);

4.  其实整个程序包含了两个部分,一个是python的客户端,一个是以字符串的形式bpf_text出现的插桩函数,使用C语言编写,整个测试程序包含了,用户空间和内核空间的代码。

5  bpf_trace_printk函数,类似于C语言中printf,编写bpf程序时,可以用来调试打印相关信息。注意该打印函数会将信息输出到下面的文件中:/sys/kernel/debug/tracing/trace_pipe,通过跟踪该文件进行查看。

6. print_even 函数时对内核中获取的数据和信息的统一展示。

#8 内核模块 » Gentoo 伯克利子系统之一:BPF概述 » 2024-06-13 07:34:20

batsom
回复: 0

BPF的英文是Berkeley  Packet  Filter的缩写,直白的翻译过来就是伯克利数据包过滤器,从这个英文翻译可以看出,BPF原先是针对网络的数据包进行各种操作处理的一个工具集。目前已经发展到对内核事件和用户空间事件进行跟踪的一套综合的工具集。BPF的原理简单解释为,在各种内核事件和应用程序事件的发生的前面,运行一小段程序,通过运行的这段程序获取到例如:函数调用的流程、结构体内的变量值等有利于分析问题的信息。BPF在2014年正式加入Linux内核主线,随着BPF的在内核中发展,以及 eBPF的出现,已经可以对内核中绝大部分的子系统进行跟踪观测。

        BPF跟踪检测有两个前端的工具BCC和bpftrace。BCC为BPF编译器集合,BPF  Compiler Collection,他提供了开发BPF跟踪程序的高级框架,提供了一个编写内核BPF程序的C语言环境,同时也提供了 Python和Lua等用户端的接口。BCC自身大概提供了97个BPF工具(截止到BCC-0.24版本。),bpftrace大概提供了35个可用工具。当然我们可以在这个基础和框架之下,开发符合我们自己的实际需求的BPF工具。

         BPF工具主要是为分析和定位Linux系统中出现一些性能问题,包括内核和应用程序两个方向,当然也可以通过打印出调用程序的堆栈信息,获取到函数的调用关系

BCC工具:
FluxBB bbcode 测试

bpftrace工具:
FluxBB bbcode 测试


BPF主要思维导向图:
FluxBB bbcode 测试

#9 内核模块 » Gentoo时间子系统之八:动态时钟框架(CONFIG_NO_HZ、tickless) » 2024-04-23 23:29:04

batsom
回复: 0

在前面章节的讨论中,我们一直基于一个假设:Linux中的时钟事件都是由一个周期时钟提供,不管系统中的clock_event_device是工作于周期触发模式,还是工作于单触发模式,也不管定时器系统是工作于低分辨率模式,还是高精度模式,内核都竭尽所能,用不同的方式提供周期时钟,以产生定期的tick事件,tick事件或者用于全局的时间管理(jiffies和时间的更新),或者用于本地cpu的进程统计、时间轮定时器框架等等。周期性时钟虽然简单有效,但是也带来了一些缺点,尤其在系统的功耗上,因为就算系统目前无事可做,也必须定期地发出时钟事件,激活系统。为此,内核的开发者提出了动态时钟这一概念,我们可以通过内核的配置项CONFIG_NO_HZ来激活特性。有时候这一特性也被叫做tickless,不过还是把它称呼为动态时钟比较合适,因为并不是真的没有tick事件了,只是在系统无事所做的idle阶段,我们可以通过停止周期时钟来达到降低系统功耗的目的,只要有进程处于活动状态,时钟事件依然会被周期性地发出。

/*****************************************************************************************************/
声明:本博内容均由http://blog.csdn.net/droidphone原创,转载请注明出处,谢谢!
/*****************************************************************************************************/

在动态时钟正确工作之前,系统需要切换至动态时钟模式,而要切换至动态时钟模式,需要一些前提条件,最主要的一条就是cpu的时钟事件设备必须要支持单触发模式,当条件满足时,系统切换至动态时钟模式,接着,由idle进程决定是否可以停止周期时钟,退出idle进程时则需要恢复周期时钟。
1.  数据结构

在上一章的内容里,我们曾经提到,切换到高精度模式后,高精度定时器系统需要使用一个高精度定时器来模拟传统的周期时钟,其中利用了tick_sched结构中的一些字段,事实上,tick_sched结构也是实现动态时钟的一个重要的数据结构,在smp系统中,内核会为每个cpu都定义一个tick_sched结构,这通过一个percpu全局变量tick_cpu_sched来实现,它在kernel/time/tick-sched.c中定义:

/*
 * Per cpu nohz control structure
 */
static DEFINE_PER_CPU(struct tick_sched, tick_cpu_sched);

tick_sched结构在include/linux/tick.h中定义,我们看看tick_sched结构的详细定义:

struct tick_sched {
	struct hrtimer			sched_timer;
	unsigned long			check_clocks;
	enum tick_nohz_mode		nohz_mode;
	ktime_t				idle_tick;
	int				inidle;
	int				tick_stopped;
	unsigned long			idle_jiffies;
	unsigned long			idle_calls;
	unsigned long			idle_sleeps;
	int				idle_active;
	ktime_t				idle_entrytime;
	ktime_t				idle_waketime;
	ktime_t				idle_exittime;
	ktime_t				idle_sleeptime;
	ktime_t				iowait_sleeptime;
	ktime_t				sleep_length;
	unsigned long			last_jiffies;
	unsigned long			next_jiffies;
	ktime_t				idle_expires;
	int				do_timer_last;
};

sched_timer  该字段用于在高精度模式下,模拟周期时钟的一个hrtimer,请参看 Linux时间子系统之六:高精度定时器(HRTIMER)的原理和实现。

check_clocks  该字段用于实现clock_event_device和clocksource的异步通知机制,帮助系统切换至高精度模式或者是动态时钟模式。

nohz_mode  保存动态时钟的工作模式,基于低分辨率和高精度模式下,动态时钟的实现稍有不同,根据模式它可以是以下的值:

>NOHZ_MODE_INACTIVE  系统动态时钟尚未激活
>NOHZ_MODE_LOWRES  系统工作于低分辨率模式下的动态时钟
>NOHZ_MODE_HIGHRES  系统工作于高精度模式下的动态时钟

idle_tick  该字段用于保存停止周期时钟是的内核时间,当退出idle时要恢复周期时钟,需要使用该时间,以保持系统中时间线(jiffies)的正确性。

tick_stopped  该字段用于表明idle状态的周期时钟已经停止。

idle_jiffies  系统进入idle时的jiffies值,用于信息统计。

idle_calls 系统进入idle的统计次数。

idle_sleeps  系统进入idle且成功停掉周期时钟的次数。

idle_active  表明目前系统是否处于idle状态中。

idle_entrytime  系统进入idle的时刻。

idle_waketime  idle状态被打断的时刻。

idle_exittime  系统退出idle的时刻。

idle_sleeptime  累计各次idle中停止周期时钟的总时间。

sleep_length  本次idle中停止周期时钟的时间。

last_jiffies  系统中最后一次周期时钟的jiffies值。

next_jiffies  预计下一次周期时钟的jiffies。

idle_expires  进入idle后,下一个最先到期的定时器时刻。

我们知道,根据系统目前的工作模式,系统提供周期时钟(tick)的方式会有所不同,当处于低分辨率模式时,由cpu的tick_device提供周期时钟,而当处于高精度模式时,是由一个高精度定时器来提供周期时钟,下面我们分别讨论一下在两种模式下的动态时钟实现方式。
2.  低分辨率下的动态时钟
回看之前一篇文章: Linux时间子系统之四:定时器的引擎:clock_event_device中的关于tick_device一节,不管tick_device的工作模式(周期触发或者是单次触发),tick_device所关联的clock_event_device的事件回调处理函数都是:tick_handle_periodic,不管当前是否处于idle状态,他都会精确地按HZ数来提供周期性的tick事件,这不符合动态时钟的要求,所以,要使动态时钟发挥作用,系统首先要切换至支持动态时钟的工作模式:NOHZ_MODE_LOWRES  。

2.1  切换至动态时钟模式

动态时钟模式的切换过程的前半部分和切换至高精度定时器模式所经过的路径是一样的,请参考:Linux时间子系统之六:高精度定时器(HRTIMER)的原理和实现。这里再简单描述一下过程:系统工作于周期时钟模式,定期地发出tick事件中断,tick事件中断触发定时器软中断:TIMER_SOFTIRQ,执行软中断处理函数run_timer_softirq,run_timer_softirq调用hrtimer_run_pending函数:

void hrtimer_run_pending(void)
{
	if (hrtimer_hres_active())
		return;
        ......
	if (tick_check_oneshot_change(!hrtimer_is_hres_enabled()))
		hrtimer_switch_to_hres();
}

tick_check_oneshot_change函数的参数决定了现在是要切换至低分辨率动态时钟模式,还是高精度定时器模式,我们现在假设系统不支持高精度定时器模式,hrtimer_is_hres_enabled会直接返回false,对应的tick_check_oneshot_change函数的参数则是true,表明需要切换至动态时钟模式。tick_check_oneshot_change在检查过timekeeper和clock_event_device都具备动态时钟的条件后,通过tick_nohz_switch_to_nohz函数切换至动态时钟模式:

首先,该函数通过tick_switch_to_oneshot函数把tick_device的工作模式设置为单触发模式,并把它的中断事件回调函数置换为tick_nohz_handler,接着把tick_sched结构中的模式字段设置为NOHZ_MODE_LOWRES:

static void tick_nohz_switch_to_nohz(void)
{
	struct tick_sched *ts = &__get_cpu_var(tick_cpu_sched);
	ktime_t next;
 
	if (!tick_nohz_enabled)
		return;
 
	local_irq_disable();
	if (tick_switch_to_oneshot(tick_nohz_handler)) {
		local_irq_enable();
		return;
	}
 
	ts->nohz_mode = NOHZ_MODE_LOWRES;

然后,初始化tick_sched结构中的sched_timer定时器,通过tick_init_jiffy_update获取下一次tick事件的时间并初始化全局变量last_jiffies_update,以便后续可以正确地更新jiffies计数值,最后,把下一次tick事件的时间编程到tick_device中,到此,系统完成了到低分辨率动态时钟的切换过程。

	hrtimer_init(&ts->sched_timer, CLOCK_MONOTONIC, HRTIMER_MODE_ABS);
	/* Get the next period */
	next = tick_init_jiffy_update();
 
	for (;;) {
		hrtimer_set_expires(&ts->sched_timer, next);
		if (!tick_program_event(next, 0))
			break;
		next = ktime_add(next, tick_period);
	}
	local_irq_enable();
}

上面的代码中,明明现在没有切换至高精度模式,为什么要初始化tick_sched结构中的高精度定时器?原因并不是要使用它的定时功能,而是想重用hrtimer代码中的hrtimer_forward函数,利用这个函数来计算下一次tick事件的时间。

2.2  低分辨率动态时钟下的事件中断处理函数

上一节提到,当切换至低分辨率动态时钟模式后,tick_device的事件中断处理函数会被设置为tick_nohz_handler,总体来说,它和周期时钟模式的事件处理函数tick_handle_periodic所完成的工作大致类似:更新时间、更新jiffies计数值、调用update_process_time更新进程信息和触发定时器软中断等等,最后重新编程tick_device,使得它在下一个正确的tick时刻再次触发本函数:

static void tick_nohz_handler(struct clock_event_device *dev)
{
        ......
	dev->next_event.tv64 = KTIME_MAX;
 
	if (unlikely(tick_do_timer_cpu == TICK_DO_TIMER_NONE))
		tick_do_timer_cpu = cpu;
 
	/* Check, if the jiffies need an update */
	if (tick_do_timer_cpu == cpu)
		tick_do_update_jiffies64(now);
        ......	
	if (ts->tick_stopped) {
		touch_softlockup_watchdog();
		ts->idle_jiffies++;
	}
 
	update_process_times(user_mode(regs));
	profile_tick(CPU_PROFILING);
 
	while (tick_nohz_reprogram(ts, now)) {
		now = ktime_get();
		tick_do_update_jiffies64(now);
	}
}

因为现在工作于动态时钟模式,所以,tick时钟可能在idle进程中被停掉不止一个tick周期,所以当该函数被再次触发时,离上一次触发的时间可能已经不止一个tick周期,tick_nohz_reprogram对tick_device进行编程时必须正确地处理这一情况,它利用了前面所说的hrtimer_forward函数来实现这一特性:

static int tick_nohz_reprogram(struct tick_sched *ts, ktime_t now)
{
	hrtimer_forward(&ts->sched_timer, now, tick_period);
	return tick_program_event(hrtimer_get_expires(&ts->sched_timer), 0);
}

2.3  动态时钟:停止周期tick时钟事件

开启动态时钟模式后,周期时钟的开启和关闭由idle进程控制,idle进程内最终是一个循环,循环的一开始通过tick_nohz_idle_enter检测是否允许关闭周期时钟若干时间,然后进入低功耗的idle模式,当有中断事件使得cpu退出低功耗idle模式后,判断是否有新的进程被激活从而需要重新调度,如果需要则通过tick_nohz_idle_exit重新启用周期时钟,然后重新进行进程调度,等待下一次idle的发生,我们可以用下图来表示:

FluxBB bbcode 测试

停止周期时钟的时机在tick_nohz_idle_enter函数中,它把主要的工作交由tick_nohz_stop_sched_tick函数来完成。内核也不是每次进入tick_nohz_stop_sched_tick都会停止周期时钟,那么什么时候才会停止?我们想一想,这时候既然idle进程在运行,说明系统中的其他进程都在等待某种事件,系统处于无事所做的状态,唯一要处理的就是中断,除了定时器中断,其它的中断我们无法预测它会何时发生,但是我们可以知道最先一个到期的定时器的到期时间,也就是说,在该时间到期前,产生周期时钟是没有必要的,我们可以据此推算出周期时钟可以停止的tick数,然后重新对tick_device进行编程,使得在最早一个定时器到期前都不会产生周期时钟,实际上,tick_nohz_stop_sched_tick还做了一些限制:当下一个定时器的到期时间与当前jiffies值只相差1时,不会停止周期时钟,当定时器的到期时间与当前的jiffies值相差的时间大于timekeeper允许的最大idle时间时,则下一个tick时刻被设置timekeeper允许的最大idle时间,这主要是为了防止太长时间不去更新timekeeper中的系统时间,有可能导致clocksource的溢出问题。tick_nohz_stop_sched_tick函数体看起来很长,实现的也就是上述的逻辑,所以这里就不贴它的代码了,有兴趣的读者可以自行阅读内核的代码:kernel/time/tick-sched.c。

看了动态时钟的停止过程和tick_nohz_handler的实现方式,其实还有一个情况没有处理:当系统进入idle进程后,周期时钟被停止若干个tick周期,当这若干个tick周期到期后,tick事件必然会产生,tick_nohz_handler被触发调用,然后最先到期的定时器被处理。但是在tick_nohz_handler的最后,tick_device一定会被编程为紧跟着的下一个tick周期的时刻被触发,如果刚才的定时器处理后,并没有激活新的进程,我们的期望是周期时钟可以用下一个新的定时器重新计算可以停止的时间,而不是下一个tick时刻,但是tick_nohz_handler却仅仅简单地把tick_device的到期时间设为下一个周期的tick时刻,这导致了周期时钟被恢复,显然这不是我们想要的。为了处理这种情况,内核使用了一点小伎俩,我们知道定时器是在软中断中执行的,所以内核在irq_exit中的软件中断处理完后,加入了一小段代码,kernel/softirq.c :

void irq_exit(void)
{
        ......
	if (!in_interrupt() && local_softirq_pending())
		invoke_softirq();
 
#ifdef CONFIG_NO_HZ
	/* Make sure that timer wheel updates are propagated */
	if (idle_cpu(smp_processor_id()) && !in_interrupt() && !need_resched())
		tick_nohz_irq_exit();
#endif
        ......
}

关键的调用是tick_nohz_irq_exit:

void tick_nohz_irq_exit(void)
{
	struct tick_sched *ts = &__get_cpu_var(tick_cpu_sched);
 
	if (!ts->inidle)
		return;
 
	tick_nohz_stop_sched_tick(ts);
}

tick_nohz_irq_exit再次调用了tick_nohz_stop_sched_tick函数,使得系统有机会再次停止周期时钟若干个tick周期。
2.3  动态时钟:重新开启周期tick时钟事件

回到图2.3.1,当在idle进程中停止周期时钟后,在某一时刻,有新的进程被激活,在重新调度前,tick_nohz_idle_exit会被调用,该函数负责恢复被停止的周期时钟。tick_nohz_idle_exit最终会调用tick_nohz_restart函数,由tick_nohz_restart函数最后完成恢复周期时钟的工作。函数并不复杂:先是把上一次停止周期时钟的时刻设置到tick_sched结构的sched_timer定时器中,然后在通过hrtimer_forward函数把该定时器的到期时刻设置为当前时间的下一个tick时刻,对于高精度模式,启动该定时器即可,对于低分辨率模式,使用该时间对tick_device重新编程,最后通过tick_do_update_jiffies64更新jiffies数值,为了防止此时正在一个tick时刻的边界,可能当前时刻正好刚刚越过了该到期时间,函数使用了一个while循环:

static void tick_nohz_restart(struct tick_sched *ts, ktime_t now)
{
	hrtimer_cancel(&ts->sched_timer);
	hrtimer_set_expires(&ts->sched_timer, ts->idle_tick);
 
	while (1) {
		/* Forward the time to expire in the future */
		hrtimer_forward(&ts->sched_timer, now, tick_period);
 
		if (ts->nohz_mode == NOHZ_MODE_HIGHRES) {
			hrtimer_start_expires(&ts->sched_timer,
					      HRTIMER_MODE_ABS_PINNED);
			/* Check, if the timer was already in the past */
			if (hrtimer_active(&ts->sched_timer))
				break;
		} else {
			if (!tick_program_event(
				hrtimer_get_expires(&ts->sched_timer), 0))
				break;
		}
		/* Reread time and update jiffies */
		now = ktime_get();
		tick_do_update_jiffies64(now);
	}
}

3.  高精度模式下的动态时钟
高精度模式和低分辨率模式的主要区别是在切换过程中,怎样切换到高精度模式,我已经在上一篇文章中做了说明,切换到高精度模式后,动态时钟的开启和关闭和低分辨率模式下没有太大的区别,也是通过tick_nohz_stop_sched_tick和tick_nohz_restart来控制,在这两个函数中,分别判断了当前的两种模式:

>NOHZ_MODE_HIGHRES
>NOHZ_MODE_LOWRES

如果是NOHZ_MODE_HIGHRES则对tick_sched结构的sched_timer定时器进行设置,如果是NOHZ_MODE_LOWRES,则直接对tick_device进行操作。

4.  动态时钟对中断的影响
在进入和退出中断时,因为动态时钟的关系,中断系统需要作出一些配合。先说中断发生于周期时钟停止期间,如果不做任何处理,中断服务程序中如果要访问jiffies计数值,可能得到一个滞后的jiffies值,因为正常状态下,jiffies值会在恢复周期时钟时正确地更新,所以,为了防止这种情况发生,在进入中断的irq_enter期间,tick_check_idle会被调用:

void tick_check_idle(int cpu)
{
	tick_check_oneshot_broadcast(cpu);
	tick_check_nohz(cpu);
}

tick_check_nohz函数的最重要的作用就是更新jiffies计数值:

static inline void tick_check_nohz(int cpu)
{
	struct tick_sched *ts = &per_cpu(tick_cpu_sched, cpu);
	ktime_t now;
 
	if (!ts->idle_active && !ts->tick_stopped)
		return;
	now = ktime_get();
	if (ts->idle_active)
		tick_nohz_stop_idle(cpu, now);
	if (ts->tick_stopped) {
		tick_nohz_update_jiffies(now);
		tick_nohz_kick_tick(cpu, now);
	}
}

另外一种情况是在退出定时器中断时,需要重新评估周期时钟的运行状况,这一点已经在2.3节中做了说明,这里就不在赘述了。

#10 内核模块 » Gentoo时间子系统之七:定时器的应用--msleep(),hrtimer_nanosleep() » 2024-04-23 22:05:52

batsom
回复: 0

我们已经在前面几章介绍了低分辨率定时器和高精度定时器的实现原理,内核为了方便其它子系统,在时间子系统中提供了一些用于延时或调度的API,例如msleep,hrtimer_nanosleep等等,这些API基于低分辨率定时器或高精度定时器来实现,本章的内容就是讨论这些方便、好用的API是如何利用定时器系统来完成所需的功能的。

/*****************************************************************************************************/
声明:本博内容均由http://blog.csdn.net/droidphone原创,转载请注明出处,谢谢!
/*****************************************************************************************************/
1.  msleep

msleep相信大家都用过,它可能是内核用使用最广泛的延时函数之一,它会使当前进程被调度并让出cpu一段时间,因为这一特性,它不能用于中断上下文,只能用于进程上下文中。要想在中断上下文中使用延时函数,请使用会阻塞cpu的无调度版本mdelay。msleep的函数原型如下:

>void msleep(unsigned int msecs)

延时的时间由参数msecs指定,单位是毫秒,事实上,msleep的实现基于低分辨率定时器,所以msleep的实际精度只能也是1/HZ级别。内核还提供了另一个比较类似的延时函数msleep_interruptible:

unsigned long msleep_interruptible(unsigned int msecs)

延时的单位同样毫秒数,它们的区别如下:

FluxBB bbcode 测试

最主要的区别就是msleep会保证所需的延时一定会被执行完,而msleep_interruptible则可以在延时进行到一半时被信号打断而退出延时,剩余的延时数则通过返回值返回。两个函数最终的代码都会到达schedule_timeout函数,它们的调用序列如下图所示:

FluxBB bbcode 测试

下面我们看看schedule_timeout函数的实现,函数首先处理两种特殊情况,一种是传入的延时jiffies数是个负数,则打印一句警告信息,然后马上返回,另一种是延时jiffies数是MAX_SCHEDULE_TIMEOUT,表明需要一直延时,直接执行调度即可:

signed long __sched schedule_timeout(signed long timeout)
{
	struct timer_list timer;
	unsigned long expire;
 
	switch (timeout)
	{
	case MAX_SCHEDULE_TIMEOUT:
		schedule();
		goto out;
	default:
		if (timeout < 0) {
			printk(KERN_ERR "schedule_timeout: wrong timeout "
				"value %lx\n", timeout);
			dump_stack();
			current->state = TASK_RUNNING;
			goto out;
		}
	}

然后计算到期的jiffies数,并在堆栈上建立一个低分辨率定时器,把到期时间设置到该定时器中,启动定时器后,通过schedule把当前进程调度出cpu的运行队列:

	expire = timeout + jiffies;
 
	setup_timer_on_stack(&timer, process_timeout, (unsigned long)current);
	__mod_timer(&timer, expire, false, TIMER_NOT_PINNED);
	schedule();

到这个时候,进程已经被调度走,那它如何返回继续执行?我们看到定时器的到期回调函数是process_timeout,参数是当前进程的task_struct指针,看看它的实现:

static void process_timeout(unsigned long __data)
{
	wake_up_process((struct task_struct *)__data);
}

噢,没错,定时器一旦到期,进程会被唤醒并继续执行:

	del_singleshot_timer_sync(&timer);
 
	/* Remove the timer from the object tracker */
	destroy_timer_on_stack(&timer);
 
	timeout = expire - jiffies;
 
 out:
	return timeout < 0 ? 0 : timeout;
}

schedule返回后,说明要不就是定时器到期,要不就是因为其它时间导致进程被唤醒,函数要做的就是删除在堆栈上建立的定时器,返回剩余未完成的jiffies数。

说完了关键的schedule_timeout函数,我们看看msleep如何实现:

signed long __sched schedule_timeout_uninterruptible(signed long timeout)
{
	__set_current_state(TASK_UNINTERRUPTIBLE);
	return schedule_timeout(timeout);
}
 
void msleep(unsigned int msecs)
{
	unsigned long timeout = msecs_to_jiffies(msecs) + 1;
 
	while (timeout)
		timeout = schedule_timeout_uninterruptible(timeout);
}

msleep先是把毫秒转换为jiffies数,通过一个while循环保证所有的延时被执行完毕,延时操作通过schedule_timeout_uninterruptible函数完成,它仅仅是在把进程的状态修改为TASK_UNINTERRUPTIBLE后,调用上述的schedule_timeout来完成具体的延时操作,TASK_UNINTERRUPTIBLE状态保证了msleep不会被信号唤醒,也就意味着在msleep期间,进程不能被kill掉。

看看msleep_interruptible的实现:

signed long __sched schedule_timeout_interruptible(signed long timeout)
{
	__set_current_state(TASK_INTERRUPTIBLE);
	return schedule_timeout(timeout);
}
 
unsigned long msleep_interruptible(unsigned int msecs)
{
	unsigned long timeout = msecs_to_jiffies(msecs) + 1;
 
	while (timeout && !signal_pending(current))
		timeout = schedule_timeout_interruptible(timeout);
	return jiffies_to_msecs(timeout);
}

msleep_interruptible通过schedule_timeout_interruptible中转,schedule_timeout_interruptible的唯一区别就是把进程的状态设置为了TASK_INTERRUPTIBLE,说明在延时期间有信号通知,while循环会马上终止,剩余的jiffies数被转换成毫秒返回。实际上,你也可以利用schedule_timeout_interruptible或schedule_timeout_uninterruptible构造自己的延时函数,同时,内核还提供了另外一个类似的函数,不用我解释,看代码就知道它的用意了:

signed long __sched schedule_timeout_killable(signed long timeout)
{
	__set_current_state(TASK_KILLABLE);
	return schedule_timeout(timeout);
}

2.  hrtimer_nanosleep
第一节讨论的msleep函数基于时间轮定时系统,只能提供毫秒级的精度,实际上,它的精度取决于HZ的配置值,如果HZ小于1000,它甚至无法达到毫秒级的精度,要想得到更为精确的延时,我们自然想到的是要利用高精度定时器来实现。没错,linux为用户空间提供了一个api:nanosleep,它能提供纳秒级的延时精度,该用户空间函数对应的内核实现是sys_nanosleep,它的工作交由高精度定时器系统的hrtimer_nanosleep函数实现,最终的大部分工作则由do_nanosleep完成。调用过程如下图所示:

FluxBB bbcode 测试

与msleep的实现相类似,hrtimer_nanosleep函数首先在堆栈中创建一个高精度定时器,设置它的到期时间,然后通过do_nanosleep完成最终的延时工作,当前进程在挂起相应的延时时间后,退出do_nanosleep函数,销毁堆栈中的定时器并返回0值表示执行成功。不过do_nanosleep可能在没有达到所需延时数量时由于其它原因退出,如果出现这种情况,hrtimer_nanosleep的最后部分把剩余的延时时间记入进程的restart_block中,并返回ERESTART_RESTARTBLOCK错误代码,系统或者用户空间可以根据此返回值决定是否重新调用nanosleep以便把剩余的延时继续执行完成。下面是hrtimer_nanosleep的代码:

long hrtimer_nanosleep(struct timespec *rqtp, struct timespec __user *rmtp,
		       const enum hrtimer_mode mode, const clockid_t clockid)
{
	struct restart_block *restart;
	struct hrtimer_sleeper t;
	int ret = 0;
	unsigned long slack;
 
	slack = current->timer_slack_ns;
	if (rt_task(current))
		slack = 0;
 
	hrtimer_init_on_stack(&t.timer, clockid, mode);
	hrtimer_set_expires_range_ns(&t.timer, timespec_to_ktime(*rqtp), slack);
	if (do_nanosleep(&t, mode))
		goto out;
 
	/* Absolute timers do not update the rmtp value and restart: */
	if (mode == HRTIMER_MODE_ABS) {
		ret = -ERESTARTNOHAND;
		goto out;
	}
 
	if (rmtp) {
		ret = update_rmtp(&t.timer, rmtp);
		if (ret <= 0)
			goto out;
	}
 
	restart = ¤t_thread_info()->restart_block;
	restart->fn = hrtimer_nanosleep_restart;
	restart->nanosleep.clockid = t.timer.base->clockid;
	restart->nanosleep.rmtp = rmtp;
	restart->nanosleep.expires = hrtimer_get_expires_tv64(&t.timer);
 
	ret = -ERESTART_RESTARTBLOCK;
out:
	destroy_hrtimer_on_stack(&t.timer);
	return ret;
}

接着我们看看do_nanosleep的实现代码,它首先通过hrtimer_init_sleeper函数,把定时器的回调函数设置为hrtimer_wakeup,把当前进程的task_struct结构指针保存在hrtimer_sleeper结构的task字段中:

void hrtimer_init_sleeper(struct hrtimer_sleeper *sl, struct task_struct *task)
{
	sl->timer.function = hrtimer_wakeup;
	sl->task = task;
}
EXPORT_SYMBOL_GPL(hrtimer_init_sleeper);
 
static int __sched do_nanosleep(struct hrtimer_sleeper *t, enum hrtimer_mode mode)
{
	hrtimer_init_sleeper(t, current);

然后,通过一个do/while循环内:启动定时器,挂起当前进程,等待定时器或其它事件唤醒进程。这里的循环体实现比较怪异,它使用hrtimer_active函数间接地判断定时器是否到期,如果hrtimer_active返回false,说明定时器已经过期,然后把hrtimer_sleeper结构的task字段设置为NULL,从而导致循环体的结束,另一个结束条件是当前进程收到了信号事件,所以,当因为是定时器到期而退出时,do_nanosleep返回true,否则返回false,上述的hrtimer_nanosleep正是利用了这一特性来决定它的返回值。以下是do_nanosleep循环体的代码:

	do {
		set_current_state(TASK_INTERRUPTIBLE);
		hrtimer_start_expires(&t->timer, mode);
		if (!hrtimer_active(&t->timer))
			t->task = NULL;
 
		if (likely(t->task))
			schedule();
 
		hrtimer_cancel(&t->timer);
		mode = HRTIMER_MODE_ABS;
 
	} while (t->task && !signal_pending(current));
 
	__set_current_state(TASK_RUNNING);
 
	return t->task == NULL;
}

除了hrtimer_nanosleep,高精度定时器系统还提供了几种用于延时/挂起进程的api:

    schedule_hrtimeout    使得当前进程休眠指定的时间,使用CLOCK_MONOTONIC计时系统;
    schedule_hrtimeout_range    使得当前进程休眠指定的时间范围,使用CLOCK_MONOTONIC计时系统;
    schedule_hrtimeout_range_clock    使得当前进程休眠指定的时间范围,可以自行指定计时系统;
    usleep_range 使得当前进程休眠指定的微妙数,使用CLOCK_MONOTONIC计时系统;

它们之间的调用关系如下:

FluxBB bbcode 测试

最终,所有的实现都会进入到schedule_hrtimeout_range_clock函数。需要注意的是schedule_hrtimeout_xxxx系列函数在调用前,最好利用set_current_state函数先设置进程的状态,在这些函数返回前,进城的状态会再次被设置为TASK_RUNNING。如果事先把状态设置为TASK_UNINTERRUPTIBLE,它们会保证函数返回前一定已经经过了所需的延时时间,如果事先把状态设置为TASK_INTERRUPTIBLE,则有可能在尚未到期时由其它信号唤醒进程从而导致函数返回。主要实现该功能的函数schedule_hrtimeout_range_clock和前面的do_nanosleep函数实现原理基本一致。大家可以自行参考内核的代码,它们位于:kernel/hrtimer.c。

#11 内核模块 » Gentoo时间子系统之六:高精度定时器(HRTIMER)的原理和实现 » 2024-04-23 21:55:19

batsom
回复: 0

上一篇文章,我介绍了传统的低分辨率定时器的实现原理。而随着内核的不断演进,大牛们已经对这种低分辨率定时器的精度不再满足,而且,硬件也在不断地发展,系统中的定时器硬件的精度也越来越高,这也给高分辨率定时器的出现创造了条件。内核从2.6.16开始加入了高精度定时器架构。在实现方式上,内核的高分辨率定时器的实现代码几乎没有借用低分辨率定时器的数据结构和代码,内核文档给出的解释主要有以下几点:

>低分辨率定时器的代码和jiffies的关系太过紧密,并且默认按32位进行设计,并且它的代码已经经过长时间的优化,目前的使用也是没有任何错误,如果硬要基于它来实现高分辨率定时器,势必会打破原有的时间轮概念,并且会引入一大堆#if--#else判断;
>虽然大部分时间里,时间轮可以实现O(1)时间复杂度,但是当有进位发生时,不可预测的O(N)定时器级联迁移时间,这对于低分辨率定时器来说问题不大,可是它大大地影响了定时器的精度;
>低分辨率定时器几乎是为“超时”而设计的,并为此对它进行了大量的优化,对于这些以“超时”未目的而使用定时器,它们大多数期望在超时到来之前获得正确的结果,然后删除定时器,精确时间并不是它们主要的目的,例如网络通信、设备IO等等。

为此,内核为高精度定时器重新设计了一套软件架构,它可以为我们提供纳秒级的定时精度,以满足对精确时间有迫切需求的应用程序或内核驱动,例如多媒体应用,音频设备的驱动程序等等。以下的讨论用hrtimer(high resolution timer)表示高精度定时器。
/*****************************************************************************************************/
声明:本博内容均由http://blog.csdn.net/droidphone原创,转载请注明出处,谢谢!
/*****************************************************************************************************/
1.  如何组织hrtimer?

我们知道,低分辨率定时器使用5个链表数组来组织timer_list结构,形成了著名的时间轮概念,对于高分辨率定时器,我们期望组织它们的数据结构至少具备以下条件:

>稳定而且快速的查找能力;
>快速地插入和删除定时器的能力;
>排序功能;

内核的开发者考察了多种数据结构,例如基数树、哈希表等等,最终他们选择了红黑树(rbtree)来组织hrtimer,红黑树已经以库的形式存在于内核中,并被成功地使用在内存管理子系统和文件系统中,随着系统的运行,hrtimer不停地被创建和销毁,新的hrtimer按顺序被插入到红黑树中,树的最左边的节点就是最快到期的定时器,内核用一个hrtimer结构来表示一个高精度定时器:

struct hrtimer {
	struct timerqueue_node		node;
	ktime_t				_softexpires;
	enum hrtimer_restart		(*function)(struct hrtimer *);
	struct hrtimer_clock_base	*base;
	unsigned long			state;
        ......
};

定时器的到期时间用ktime_t来表示,_softexpires字段记录了时间,定时器一旦到期,function字段指定的回调函数会被调用,该函数的返回值为一个枚举值,它决定了该hrtimer是否需要被重新激活:

enum hrtimer_restart {
	HRTIMER_NORESTART,	/* Timer is not restarted */
	HRTIMER_RESTART,	/* Timer must be restarted */
};

state字段用于表示hrtimer当前的状态,有几下几种位组合:

#define HRTIMER_STATE_INACTIVE	0x00  // 定时器未激活
#define HRTIMER_STATE_ENQUEUED	0x01  // 定时器已经被排入红黑树中
#define HRTIMER_STATE_CALLBACK	0x02  // 定时器的回调函数正在被调用
#define HRTIMER_STATE_MIGRATE	0x04  // 定时器正在CPU之间做迁移

hrtimer的到期时间可以基于以下几种时间基准系统:

enum  hrtimer_base_type {
	HRTIMER_BASE_MONOTONIC,  // 单调递增的monotonic时间,不包含休眠时间
	HRTIMER_BASE_REALTIME,   // 平常使用的墙上真实时间
	HRTIMER_BASE_BOOTTIME,   // 单调递增的boottime,包含休眠时间
	HRTIMER_MAX_CLOCK_BASES, // 用于后续数组的定义
};

和低分辨率定时器一样,处于效率和上锁的考虑,每个cpu单独管理属于自己的hrtimer,为此,专门定义了一个结构hrtimer_cpu_base:

struct hrtimer_cpu_base {
        ......
	struct hrtimer_clock_base	clock_base[HRTIMER_MAX_CLOCK_BASES];
};

其中,clock_base数组为每种时间基准系统都定义了一个hrtimer_clock_base结构,它的定义如下:

struct hrtimer_clock_base {
	struct hrtimer_cpu_base	*cpu_base;  // 指向所属cpu的hrtimer_cpu_base结构
        ......
	struct timerqueue_head	active;     // 红黑树,包含了所有使用该时间基准系统的hrtimer
	ktime_t			resolution; // 时间基准系统的分辨率
	ktime_t			(*get_time)(void); // 获取该基准系统的时间函数
	ktime_t			softirq_time;// 当用jiffies
	ktime_t			offset;      // 
};

active字段是一个timerqueue_head结构,它实际上是对rbtree的进一步封装:

struct timerqueue_node {
	struct rb_node node;  // 红黑树的节点
	ktime_t expires;      // 该节点代表队hrtimer的到期时间,与hrtimer结构中的_softexpires稍有不同
};
 
struct timerqueue_head {
	struct rb_root head;          // 红黑树的根节点
	struct timerqueue_node *next; // 该红黑树中最早到期的节点,也就是最左下的节点
};

timerqueue_head结构在红黑树的基础上,增加了一个next字段,用于保存树中最先到期的定时器节点,实际上就是树的最左下方的节点,有了next字段,当到期事件到来时,系统不必遍历整个红黑树,只要取出next字段对应的节点进行处理即可。timerqueue_node用于表示一个hrtimer节点,它在标准红黑树节点rb_node的基础上增加了expires字段,该字段和hrtimer中的_softexpires字段一起,设定了hrtimer的到期时间的一个范围,hrtimer可以在hrtimer._softexpires至timerqueue_node.expires之间的任何时刻到期,我们也称timerqueue_node.expires为硬过期时间(hard),意思很明显:到了此时刻,定时器一定会到期,有了这个范围可以选择,定时器系统可以让范围接近的多个定时器在同一时刻同时到期,这种设计可以降低进程频繁地被hrtimer进行唤醒。经过以上的讨论,我们可以得出以下的图示,它表明了每个cpu上的hrtimer是如何被组织在一起的:

FluxBB bbcode 测试

总结一下:

>每个cpu有一个hrtimer_cpu_base结构;
>hrtimer_cpu_base结构管理着3种不同的时间基准系统的hrtimer,分别是:实时时间,启动时间和单调时间;
>每种时间基准系统通过它的active字段(timerqueue_head结构指针),指向它们各自的红黑树;
>红黑树上,按到期时间进行排序,最先到期的hrtimer位于最左下的节点,并被记录在active.next字段中;
>3中时间基准的最先到期时间可能不同,所以,它们之中最先到期的时间被记录在hrtimer_cpu_base的expires_next字段中。

2.  hrtimer如何运转

hrtimer的实现需要一定的硬件基础,它的实现依赖于我们前几章介绍的timekeeper和clock_event_device,如果你对timekeeper和clock_event_device不了解请参考以下文章:Linux时间子系统之三:时间的维护者:timekeeper,Linux时间子系统之四:定时器的引擎:clock_event_device。hrtimer系统需要通过timekeeper获取当前的时间,计算与到期时间的差值,并根据该差值,设定该cpu的tick_device(clock_event_device)的下一次的到期时间,时间一到,在clock_event_device的事件回调函数中处理到期的hrtimer。现在你或许有疑问:前面在介绍clock_event_device时,我们知道,每个cpu有自己的tick_device,通常用于周期性地产生进程调度和时间统计的tick事件,这里又说要用tick_device调度hrtimer系统,通常cpu只有一个tick_device,那他们如何协调工作?这个问题也一度困扰着我,如果再加上NO_HZ配置带来tickless特性,你可能会更晕。这里我们先把这个疑问放下,我将在后面的章节中来讨论这个问题,现在我们只要先知道,一旦开启了hrtimer,tick_device所关联的clock_event_device的事件回调函数会被修改为:hrtimer_interrupt,并且会被设置成工作于CLOCK_EVT_MODE_ONESHOT单触发模式。
2.1  添加一个hrtimer

要添加一个hrtimer,系统提供了一些api供我们使用,首先我们需要定义一个hrtimer结构的实例,然后用hrtimer_init函数对它进行初始化,它的原型如下:

void hrtimer_init(struct hrtimer *timer, clockid_t which_clock,
			 enum hrtimer_mode mode);

which_clock可以是CLOCK_REALTIME、CLOCK_MONOTONIC、CLOCK_BOOTTIME中的一种,mode则可以是相对时间HRTIMER_MODE_REL,也可以是绝对时间HRTIMER_MODE_ABS。设定回调函数:

timer.function = hr_callback;

如果定时器无需指定一个到期范围,可以在设定回调函数后直接使用hrtimer_start激活该定时器:

int hrtimer_start(struct hrtimer *timer, ktime_t tim,
			 const enum hrtimer_mode mode);

如果需要指定到期范围,则可以使用hrtimer_start_range_ns激活定时器:

hrtimer_start_range_ns(struct hrtimer *timer, ktime_t tim,
			unsigned long range_ns, const enum hrtimer_mode mode);

要取消一个hrtimer,使用hrtimer_cancel:

int hrtimer_cancel(struct hrtimer *timer);

以下两个函数用于推后定时器的到期时间:

extern u64
hrtimer_forward(struct hrtimer *timer, ktime_t now, ktime_t interval);
 
/* Forward a hrtimer so it expires after the hrtimer's current now */
static inline u64 hrtimer_forward_now(struct hrtimer *timer,
				      ktime_t interval)
{
	return hrtimer_forward(timer, timer->base->get_time(), interval);
}

以下几个函数用于获取定时器的当前状态:

static inline int hrtimer_active(const struct hrtimer *timer)
{
	return timer->state != HRTIMER_STATE_INACTIVE;
}
 
static inline int hrtimer_is_queued(struct hrtimer *timer)
{
	return timer->state & HRTIMER_STATE_ENQUEUED;
}
 
static inline int hrtimer_callback_running(struct hrtimer *timer)
{
	return timer->state & HRTIMER_STATE_CALLBACK;
}

hrtimer_init最终会进入__hrtimer_init函数,该函数的主要目的是初始化hrtimer的base字段,同时初始化作为红黑树的节点的node字段:

static void __hrtimer_init(struct hrtimer *timer, clockid_t clock_id,
			   enum hrtimer_mode mode)
{
	struct hrtimer_cpu_base *cpu_base;
	int base;
 
	memset(timer, 0, sizeof(struct hrtimer));
 
	cpu_base = &__raw_get_cpu_var(hrtimer_bases);
 
	if (clock_id == CLOCK_REALTIME && mode != HRTIMER_MODE_ABS)
		clock_id = CLOCK_MONOTONIC;
 
	base = hrtimer_clockid_to_base(clock_id);
	timer->base = &cpu_base->clock_base[base];
	timerqueue_init(&timer->node);
        ......
}

hrtimer_start和hrtimer_start_range_ns最终会把实际的工作交由__hrtimer_start_range_ns来完成:

int __hrtimer_start_range_ns(struct hrtimer *timer, ktime_t tim,
		unsigned long delta_ns, const enum hrtimer_mode mode,
		int wakeup)
{
        ......        
        /* 取得hrtimer_clock_base指针 */
        base = lock_hrtimer_base(timer, &flags); 
        /* 如果已经在红黑树中,先移除它: */
        ret = remove_hrtimer(timer, base); ......
        /* 如果是相对时间,则需要加上当前时间,因为内部是使用绝对时间 */
        if (mode & HRTIMER_MODE_REL) {
                tim = ktime_add_safe(tim, new_base->get_time());
                ......
        } 
        /* 设置到期的时间范围 */
        hrtimer_set_expires_range_ns(timer, tim, delta_ns);
        ...... 
        /* 把hrtime按到期时间排序,加入到对应时间基准系统的红黑树中 */
        /* 如果该定时器的是最早到期的,将会返回true */
        leftmost = enqueue_hrtimer(timer, new_base);
        /* 
        * Only allow reprogramming if the new base is on this CPU. 
        * (it might still be on another CPU if the timer was pending) 
        * 
        * XXX send_remote_softirq() ?
        * 定时器比之前的到期时间要早,所以需要重新对tick_device进行编程,重新设定的的到期时间
        */
        if (leftmost && new_base->cpu_base == &__get_cpu_var(hrtimer_bases))
                hrtimer_enqueue_reprogram(timer, new_base, wakeup);
        unlock_hrtimer_base(timer, &flags);
        return ret;
}
 
 

2.2  hrtimer的到期处理

高精度定时器系统有3个入口可以对到期定时器进行处理,它们分别是:
>没有切换到高精度模式时,在每个jiffie的tick事件中断中进行查询和处理;
>在HRTIMER_SOFTIRQ软中断中进行查询和处理;
>切换到高精度模式后,在每个clock_event_device的到期事件中断中进行查询和处理;

低精度模式  因为系统并不是一开始就会支持高精度模式,而是在系统启动后的某个阶段,等待所有的条件都满足后,才会切换到高精度模式,当系统还没有切换到高精度模式时,所有的高精度定时器运行在低精度模式下,在每个jiffie的tick事件中断中进行到期定时器的查询和处理,显然这时候的精度和低分辨率定时器是一样的(HZ级别)。低精度模式下,每个tick事件中断中,hrtimer_run_queues函数会被调用,由它完成定时器的到期处理。hrtimer_run_queues首先判断目前高精度模式是否已经启用,如果已经切换到了高精度模式,什么也不做,直接返回:

void hrtimer_run_queues(void)
{
 
	if (hrtimer_hres_active())
		return;

如果hrtimer_hres_active返回false,说明目前处于低精度模式下,则继续处理,它用一个for循环遍历各个时间基准系统,查询每个hrtimer_clock_base对应红黑树的左下节点,判断它的时间是否到期,如果到期,通过__run_hrtimer函数,对到期定时器进行处理,包括:调用定时器的回调函数、从红黑树中移除该定时器、根据回调函数的返回值决定是否重新启动该定时器等等:

	for (index = 0; index < HRTIMER_MAX_CLOCK_BASES; index++) {
		base = &cpu_base->clock_base[index];
		if (!timerqueue_getnext(&base->active))
			continue;
 
		if (gettime) {
			hrtimer_get_softirq_time(cpu_base);
			gettime = 0;
		}
 
		raw_spin_lock(&cpu_base->lock);
 
		while ((node = timerqueue_getnext(&base->active))) {
			struct hrtimer *timer;
 
			timer = container_of(node, struct hrtimer, node);
			if (base->softirq_time.tv64 <=
					hrtimer_get_expires_tv64(timer))
				break;
 
			__run_hrtimer(timer, &base->softirq_time);
		}
		raw_spin_unlock(&cpu_base->lock);
	}

上面的timerqueue_getnext函数返回红黑树中的左下节点,之所以可以在while循环中使用该函数,是因为__run_hrtimer会在移除旧的左下节点时,新的左下节点会被更新到base->active->next字段中,使得循环可以继续执行,直到没有新的到期定时器为止。

高精度模式  切换到高精度模式后,原来给cpu提供tick事件的tick_device(clock_event_device)会被高精度定时器系统接管,它的中断事件回调函数被设置为hrtimer_interrupt,红黑树中最左下的节点的定时器的到期时间被编程到该clock_event_device中,这样每次clock_event_device的中断意味着至少有一个高精度定时器到期。另外,当timekeeper系统中的时间需要修正,或者clock_event_device的到期事件时间被重新编程时,系统会发出HRTIMER_SOFTIRQ软中断,软中断的处理函数run_hrtimer_softirq最终也会调用hrtimer_interrupt函数对到期定时器进行处理,所以在这里我们只要讨论hrtimer_interrupt函数的实现即可。

hrtimer_interrupt函数的前半部分和低精度模式下的hrtimer_run_queues函数完成相同的事情:它用一个for循环遍历各个时间基准系统,查询每个hrtimer_clock_base对应红黑树的左下节点,判断它的时间是否到期,如果到期,通过__run_hrtimer函数,对到期定时器进行处理,所以我们只讨论后半部分,在处理完所有到期定时器后,下一个到期定时器的到期时间保存在变量expires_next中,接下来的工作就是把这个到期时间编程到tick_device中:

void hrtimer_interrupt(struct clock_event_device *dev)
{
        ......
	for (i = 0; i < HRTIMER_MAX_CLOCK_BASES; i++) {
                ......
		while ((node = timerqueue_getnext(&base->active))) {
                        ......
			if (basenow.tv64 < hrtimer_get_softexpires_tv64(timer)) {
				ktime_t expires;
 
				expires = ktime_sub(hrtimer_get_expires(timer),
						    base->offset);
				if (expires.tv64 < expires_next.tv64)
					expires_next = expires;
				break;
			}
 
			__run_hrtimer(timer, &basenow);
		}
	}
 
	/*
	 * Store the new expiry value so the migration code can verify
	 * against it.
	 */
	cpu_base->expires_next = expires_next;
	raw_spin_unlock(&cpu_base->lock);
 
	/* Reprogramming necessary ? */
	if (expires_next.tv64 == KTIME_MAX ||
	    !tick_program_event(expires_next, 0)) {
		cpu_base->hang_detected = 0;
		return;
	}

如果这时的tick_program_event返回了非0值,表示过期时间已经在当前时间的前面,这通常由以下原因造成:

>系统正在被调试跟踪,导致时间在走,程序不走;
>定时器的回调函数花了太长的时间;
>系统运行在虚拟机中,而虚拟机被调度导致停止运行;

为了避免这些情况的发生,接下来系统提供3次机会,重新执行前面的循环,处理到期的定时器:

	raw_spin_lock(&cpu_base->lock);
	now = hrtimer_update_base(cpu_base);
	cpu_base->nr_retries++;
	if (++retries < 3)
		goto retry;

如果3次循环后还无法完成到期处理,系统不再循环,转为计算本次总循环的时间,然后把tick_device的到期时间强制设置为当前时间加上本次的总循环时间,不过推后的时间被限制在100ms以内:

	delta = ktime_sub(now, entry_time);
	if (delta.tv64 > cpu_base->max_hang_time.tv64)
		cpu_base->max_hang_time = delta;
	/*
	 * Limit it to a sensible value as we enforce a longer
	 * delay. Give the CPU at least 100ms to catch up.
	 */
	if (delta.tv64 > 100 * NSEC_PER_MSEC)
		expires_next = ktime_add_ns(now, 100 * NSEC_PER_MSEC);
	else
		expires_next = ktime_add(now, delta);
	tick_program_event(expires_next, 1);
	printk_once(KERN_WARNING "hrtimer: interrupt took %llu ns\n",
		    ktime_to_ns(delta));
}

3.  切换到高精度模式

上面提到,尽管内核配置成支持高精度定时器,但并不是一开始就工作于高精度模式,系统在启动的开始阶段,还是按照传统的模式在运行:tick_device按HZ频率定期地产生tick事件,这时的hrtimer工作在低分辨率模式,到期事件在每个tick事件中断中由hrtimer_run_queues函数处理,同时,在低分辨率定时器(时间轮)的软件中断TIMER_SOFTIRQ中,hrtimer_run_pending会被调用,系统在这个函数中判断系统的条件是否满足切换到高精度模式,如果条件满足,则会切换至高分辨率模式,另外提一下,NO_HZ模式也是在该函数中判断并切换。

void hrtimer_run_pending(void)
{
	if (hrtimer_hres_active())
		return;
        ......
	if (tick_check_oneshot_change(!hrtimer_is_hres_enabled()))
		hrtimer_switch_to_hres();
}

因为不管系统是否工作于高精度模式,每个TIMER_SOFTIRQ期间,该函数都会被调用,所以函数一开始先用hrtimer_hres_active判断目前高精度模式是否已经激活,如果已经激活,则说明之前的调用中已经切换了工作模式,不必再次切换,直接返回。hrtimer_hres_active很简单:

DEFINE_PER_CPU(struct hrtimer_cpu_base, hrtimer_bases) = {
        ......
}
 
static inline int hrtimer_hres_active(void)
{
	return __this_cpu_read(hrtimer_bases.hres_active);
}

hrtimer_run_pending函数接着通过tick_check_oneshot_change判断系统是否可以切换到高精度模式,

int tick_check_oneshot_change(int allow_nohz)
{
	struct tick_sched *ts = &__get_cpu_var(tick_cpu_sched);
 
	if (!test_and_clear_bit(0, &ts->check_clocks))
		return 0;
 
	if (ts->nohz_mode != NOHZ_MODE_INACTIVE)
		return 0;
 
	if (!timekeeping_valid_for_hres() || !tick_is_oneshot_available())
		return 0;
 
	if (!allow_nohz)
		return 1;
 
	tick_nohz_switch_to_nohz();
	return 0;
}

函数的一开始先判断check_clock标志的第0位是否被置位,如果没有置位,说明系统中没有注册符合要求的时钟事件设备,函数直接返回,check_clock标志由clocksource和clock_event_device系统的notify系统置位,当系统中有更高精度的clocksource被注册和选择后,或者有更精确的支持CLOCK_EVT_MODE_ONESHOT模式的clock_event_device被注册时,通过它们的notify函数,check_clock标志的第0为会置位。

如果tick_sched结构中的nohz_mode字段不是NOHZ_MODE_INACTIVE,表明系统已经切换到其它模式,直接返回。nohz_mode的取值有3种:
>NOHZ_MODE_INACTIVE    // 未启用NO_HZ模式
>NOHZ_MODE_LOWRES    // 启用NO_HZ模式,hrtimer工作于低精度模式下
>NOHZ_MODE_HIGHRES   // 启用NO_HZ模式,hrtimer工作于高精度模式下

接下来的timerkeeping_valid_for_hres判断timekeeper系统是否支持高精度模式,tick_is_oneshot_available判断tick_device是否支持CLOCK_EVT_MODE_ONESHOT模式。如果都满足要求,则继续往下判断。allow_nohz是函数的参数,为true表明可以切换到NOHZ_MODE_LOWRES 模式,函数将进入tick_nohz_switch_to_nohz,切换至NOHZ_MODE_LOWRES 模式,这里我们传入的allow_nohz是表达式:

    (!hrtimer_is_hres_enabled())

所以当系统不允许高精度模式时,将会在tick_check_oneshot_change函数内,通过tick_nohz_switch_to_nohz切换至NOHZ_MODE_LOWRES 模式,如果系统允许高精度模式,传入的allow_nohz参数为false,tick_check_oneshot_change函数返回1,回到上面的hrtimer_run_pending函数,hrtimer_switch_to_hres函数将会被调用,已完成切换到NOHZ_MODE_HIGHRES高精度模式。好啦,真正的切换函数找到了,我们看一看它如何切换:

首先,它通过hrtimer_cpu_base中的hres_active字段判断该cpu是否已经切换至高精度模式,如果是则直接返回:

static int hrtimer_switch_to_hres(void)
{
	int i, cpu = smp_processor_id();
	struct hrtimer_cpu_base *base = &per_cpu(hrtimer_bases, cpu);
	unsigned long flags;
 
	if (base->hres_active)
		return 1;

接着,通过tick_init_highres函数接管tick_device关联的clock_event_device:

	local_irq_save(flags);
 
	if (tick_init_highres()) {
		local_irq_restore(flags);
		printk(KERN_WARNING "Could not switch to high resolution "
				    "mode on CPU %d\n", cpu);
		return 0;
	}

tick_init_highres函数把tick_device切换到CLOCK_EVT_FEAT_ONESHOT模式,同时把clock_event_device的回调handler设置为hrtimer_interrupt,这样设置以后,tick_device的中断回调将由hrtimer_interrupt接管,hrtimer_interrupt在上面已经讨论过,它将完成高精度定时器的调度和到期处理。

接着,设置hres_active标志,以表明高精度模式已经切换,然后把3个时间基准系统的resolution字段设为KTIME_HIGH_RES:

	base->hres_active = 1;
	for (i = 0; i < HRTIMER_MAX_CLOCK_BASES; i++)
		base->clock_base[i].resolution = KTIME_HIGH_RES;

最后,因为tick_device被高精度定时器接管,它将不会再提供原有的tick事件机制,所以需要由高精度定时器系统模拟一个tick事件设备,继续为系统提供tick事件能力,这个工作由tick_setup_sched_timer函数完成。因为刚刚完成切换,tick_device的到期时间并没有被正确地设置为下一个到期定时器的时间,这里使用retrigger_next_event函数,传入参数NULL,使得tick_device立刻产生到期中断,hrtimer_interrupt被调用一次,然后下一个到期的定时器的时间会编程到tick_device中,从而完成了到高精度模式的切换:

	tick_setup_sched_timer();
	/* "Retrigger" the interrupt to get things going */
	retrigger_next_event(NULL);
	local_irq_restore(flags);
	return 1;
}

整个切换过程可以用下图表示:

FluxBB bbcode 测试

4.  模拟tick事件

根据上一节的讨论,当系统切换到高精度模式后,tick_device被高精度定时器系统接管,不再定期地产生tick事件,我们知道,到目前的版本为止(V3.4),内核还没有彻底废除jiffies机制,系统还是依赖定期到来的tick事件,供进程调度系统和时间更新等操作,大量存在的低精度定时器也仍然依赖于jiffies的计数,所以,尽管tick_device被接管,高精度定时器系统还是要想办法继续提供定期的tick事件。为了达到这一目的,内核使用了一个取巧的办法:既然高精度模式已经启用,可以定义一个hrtimer,把它的到期时间设定为一个jiffy的时间,当这个hrtimer到期时,在这个hrtimer的到期回调函数中,进行和原来的tick_device同样的操作,然后把该hrtimer的到期时间顺延一个jiffy周期,如此反复循环,完美地模拟了原有tick_device的功能。下面我们看看具体点代码是如何实现的。

在kernel/time/tick-sched.c中,内核定义了一个per_cpu全局变量:tick_cpu_sched,从而为每个cpu提供了一个tick_sched结构, 该结构主要用于管理NO_HZ配置下的tickless处理,因为模拟tick事件与tickless有很强的相关性,所以高精度定时器系统也利用了该结构的以下字段,用于完成模拟tick事件的操作:

struct tick_sched {
	struct hrtimer			sched_timer;
	unsigned long			check_clocks;
	enum tick_nohz_mode		nohz_mode;
        ......
};

sched_timer就是要用于模拟tick事件的hrtimer,check_clock上面几节已经讨论过,用于notify系统通知hrtimer系统需要检查是否切换到高精度模式,nohz_mode则用于表示当前的工作模式。

上一节提到,用于切换至高精度模式的函数是hrtimer_switch_to_hres,在它的最后,调用了函数tick_setup_sched_timer,该函数的作用就是设置一个用于模拟tick事件的hrtimer:

void tick_setup_sched_timer(void)
{
	struct tick_sched *ts = &__get_cpu_var(tick_cpu_sched);
	ktime_t now = ktime_get();
 
	/*
	 * Emulate tick processing via per-CPU hrtimers:
	 */
	hrtimer_init(&ts->sched_timer, CLOCK_MONOTONIC, HRTIMER_MODE_ABS);
	ts->sched_timer.function = tick_sched_timer;
 
	/* Get the next period (per cpu) */
	hrtimer_set_expires(&ts->sched_timer, tick_init_jiffy_update());
 
	for (;;) {
		hrtimer_forward(&ts->sched_timer, now, tick_period);
		hrtimer_start_expires(&ts->sched_timer,
				      HRTIMER_MODE_ABS_PINNED);
		/* Check, if the timer was already in the past */
		if (hrtimer_active(&ts->sched_timer))
			break;
		now = ktime_get();
	}
 
#ifdef CONFIG_NO_HZ
	if (tick_nohz_enabled)
		ts->nohz_mode = NOHZ_MODE_HIGHRES;
#endif
}

该函数首先初始化该cpu所属的tick_sched结构中sched_timer字段,把该hrtimer的回调函数设置为tick_sched_timer,然后把它的到期时间设定为下一个jiffy时刻,返回前把工作模式设置为NOHZ_MODE_HIGHRES,表明是利用高精度模式实现NO_HZ。

接着我们关注一下hrtimer的回调函数tick_sched_timer,我们知道,系统中的jiffies计数,时间更新等是全局操作,在smp系统中,只有一个cpu负责该工作,所以在tick_sched_timer的一开始,先判断当前cpu是否负责更新jiffies和时间,如果是,则执行更新操作:

static enum hrtimer_restart tick_sched_timer(struct hrtimer *timer)
{
        ......
 
#ifdef CONFIG_NO_HZ
	if (unlikely(tick_do_timer_cpu == TICK_DO_TIMER_NONE))
		tick_do_timer_cpu = cpu;
#endif
 
	/* Check, if the jiffies need an update */
	if (tick_do_timer_cpu == cpu)
		tick_do_update_jiffies64(now);

然后,利用regs指针确保当前是在中断上下文中,然后调用update_process_timer:

	if (regs) {
                ......
		update_process_times(user_mode(regs));
		......
	}

最后,把hrtimer的到期时间推进一个tick周期,返回HRTIMER_RESTART表明该hrtimer需要再次启动,以便产生下一个tick事件。

	hrtimer_forward(timer, now, tick_period);
 
	return HRTIMER_RESTART;
}

关于update_process_times,如果你你感兴趣,回看一下本系列关于clock_event_device的那一章: Linux时间子系统之四:定时器的引擎:clock_event_device中的第5小节,对比一下模拟tick事件的hrtimer的回调函数tick_sched_timer和切换前tick_device的回调函数 tick_handle_periodic,它们是如此地相像,实际上,它们几乎完成了一样的工作。

#12 内核模块 » Gentoo时间子系统之五:低分辨率定时器的原理和实现 » 2024-04-23 21:47:50

batsom
回复: 0

利用定时器,我们可以设定在未来的某一时刻,触发一个特定的事件。所谓低分辨率定时器,是指这种定时器的计时单位基于jiffies值的计数,也就是说,它的精度只有1/HZ,假如你的内核配置的HZ是1000,那意味着系统中的低分辨率定时器的精度就是1ms。早期的内核版本中,内核并不支持高精度定时器,理所当然只能使用这种低分辨率定时器,我们有时候把这种基于HZ的定时器机制成为时间轮:time wheel。虽然后来出现了高分辨率定时器,但它只是内核的一个可选配置项,所以直到目前最新的内核版本,这种低分辨率定时器依然被大量地使用着。

/*****************************************************************************************************/
声明:本博内容均由http://blog.csdn.net/droidphone原创,转载请注明出处,谢谢!
/*****************************************************************************************************/

1.  定时器的使用方法

在讨论定时器的实现原理之前,我们先看看如何使用定时器。要在内核编程中使用定时器,首先我们要定义一个time_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;
	struct tvec_base *base;
 
	void (*function)(unsigned long);
	unsigned long data;
 
	int slack;
        ......
};

>entry  字段用于把一组定时器组成一个链表,至于内核如何对定时器进行分组,我们会在后面进行解释。
>expires  字段指出了该定时器的到期时刻,也就是期望定时器到期时刻的jiffies计数值。
>base  每个cpu拥有一个自己的用于管理定时器的tvec_base结构,该字段指向该定时器所属的cpu所对应tvec_base结构。
>function  字段是一个函数指针,定时器到期时,系统将会调用该回调函数,用于响应该定时器的到期事件。
>data  该字段用于上述回调函数的参数。
>slack  对有些对到期时间精度不太敏感的定时器,到期时刻允许适当地延迟一小段时间,该字段用于计算每次延迟的HZ数。

要定义一个timer_list,我们可以使用静态和动态两种办法,静态方法使用DEFINE_TIMER宏:
>#define DEFINE_TIMER(_name, _function, _expires, _data)

该宏将得到一个名字为_name,并分别用_function,_expires,_data参数填充timer_list的相关字段。

如果要使用动态的方法,则可以自己声明一个timer_list结构,然后手动初始化它的各个字段:

struct timer_list timer;
......
init_timer(&timer);
timer.function = _function;
timer.expires = _expires;
timer.data = _data;

要激活一个定时器,我们只要调用add_timer即可:

add_timer(&timer);

要修改定时器的到期时间,我们只要调用mod_timer即可:

mod_timer(&timer, jiffies+50);

要移除一个定时器,我们只要调用del_timer即可:

del_timer(&timer);

定时器系统还提供了以下这些API供我们使用:

>void add_timer_on(struct timer_list *timer, int cpu);  // 在指定的cpu上添加定时器
>int mod_timer_pending(struct timer_list *timer, unsigned long expires);  //  只有当timer已经处在激活状态时,才修改timer的到期时刻
>int mod_timer_pinned(struct timer_list *timer, unsigned long expires);  //  当
>void set_timer_slack(struct timer_list *time, int slack_hz);  //  设定timer允许的到期时刻的最大延迟,用于对精度不敏感的定时器
>int del_timer_sync(struct timer_list *timer);  //  如果该timer正在被处理中,则等待timer处理完成才移除该timer

2.  定时器的软件架构

低分辨率定时器是基于HZ来实现的,也就是说,每个tick周期,都有可能有定时器到期,关于tick如何产生,请参考:Linux时间子系统之四:定时器的引擎:clock_event_device。系统中有可能有成百上千个定时器,难道在每个tick中断中遍历一下所有的定时器,检查它们是否到期?内核当然不会使用这么笨的办法,它使用了一个更聪明的办法:按定时器的到期时间对定时器进行分组。因为目前的多核处理器使用越来越广泛,连智能手机的处理器动不动就是4核心,内核对多核处理器有较好的支持,低分辨率定时器在实现时也充分地考虑了多核处理器的支持和优化。为了较好地利用cache line,也为了避免cpu之间的互锁,内核为多核处理器中的每个cpu单独分配了管理定时器的相关数据结构和资源,每个cpu独立地管理属于自己的定时器。
2.1  定时器的分组

首先,内核为每个cpu定义了一个tvec_base结构指针:
static DEFINE_PER_CPU(struct tvec_base *, tvec_bases) = &boot_tvec_bases;

tvec_base结构的定义如下:

struct tvec_base {
	spinlock_t lock;
	struct timer_list *running_timer;
	unsigned long timer_jiffies;
	unsigned long next_timer;
	struct tvec_root tv1;
	struct tvec tv2;
	struct tvec tv3;
	struct tvec tv4;
	struct tvec tv5;
} ____cacheline_aligned;

running_timer  该字段指向当前cpu正在处理的定时器所对应的timer_list结构。

timer_jiffies  该字段表示当前cpu定时器所经历过的jiffies数,大多数情况下,该值和jiffies计数值相等,当cpu的idle状态连续持续了多个jiffies时间时,当退出idle状态时,jiffies计数值就会大于该字段,在接下来的tick中断后,定时器系统会让该字段的值追赶上jiffies值。

next_timer  该字段指向该cpu下一个即将到期的定时器。

tv1--tv5  这5个字段用于对定时器进行分组,实际上,tv1--tv5都是一个链表数组,其中tv1的数组大小为TVR_SIZE, tv2 tv3 tv4 tv5的数组大小为TVN_SIZE,根据CONFIG_BASE_SMALL配置项的不同,它们有不同的大小:

#define TVN_BITS (CONFIG_BASE_SMALL ? 4 : 6)
#define TVR_BITS (CONFIG_BASE_SMALL ? 6 : 8)
#define TVN_SIZE (1 << TVN_BITS)
#define TVR_SIZE (1 << TVR_BITS)
#define TVN_MASK (TVN_SIZE - 1)
#define TVR_MASK (TVR_SIZE - 1)
 
struct tvec {
	struct list_head vec[TVN_SIZE];
};
 
struct tvec_root {
	struct list_head vec[TVR_SIZE];
};

默认情况下,没有使能CONFIG_BASE_SMALL,TVR_SIZE的大小是256,TVN_SIZE的大小则是64,当需要节省内存空间时,也可以使能CONFIG_BASE_SMALL,这时TVR_SIZE的大小是64,TVN_SIZE的大小则是16,以下的讨论我都是基于没有使能CONFIG_BASE_SMALL的情况。当有一个新的定时器要加入时,系统根据定时器到期的jiffies值和timer_jiffies字段的差值来决定该定时器被放入tv1至tv5中的哪一个数组中,最终,系统中所有的定时器的组织结构如下图所示:

FluxBB bbcode 测试

2.2  定时器的添加

要加入一个新的定时器,我们可以通过api函数add_timer或mod_timer来完成,最终的工作会交由internal_add_timer函数来处理。该函数按以下步骤进行处理:

>计算定时器到期时间和所属cpu的tvec_base结构中的timer_jiffies字段的差值,记为idx;
>根据idx的值,选择该定时器应该被放到tv1--tv5中的哪一个链表数组中,可以认为tv1-tv5分别占据一个32位数的不同比特位,tv1占据最低的8位,tv2占据紧接着的6位,然后tv3再占位,以此类推,最高的6位分配给tv5。最终的选择规则如下表所示:

FluxBB bbcode 测试

确定链表数组后,接着要确定把该定时器放入数组中的哪一个链表中,如果时间差idx小于256,按规则要放入tv1中,因为tv1包含了256个链表,所以可以简单地使用timer_list.expires的低8位作为数组的索引下标,把定时器链接到tv1中相应的链表中即可。如果时间差idx的值在256--18383之间,则需要把定时器放入tv2中,同样的,使用timer_list.expires的8--14位作为数组的索引下标,把定时器链接到tv2中相应的链表中,。定时器要加入tv3 tv4 tv5使用同样的原理。经过这样分组后的定时器,在后续的tick事件中,系统可以很方便地定位并取出相应的到期定时器进行处理。以上的讨论都体现在internal_add_timer的代码中:

static void internal_add_timer(struct tvec_base *base, struct timer_list *timer)
{
	unsigned long expires = timer->expires;
	unsigned long idx = expires - base->timer_jiffies;
	struct list_head *vec;
 
	if (idx < TVR_SIZE) {
		int i = expires & TVR_MASK;
		vec = base->tv1.vec + i;
	} else if (idx < 1 << (TVR_BITS + TVN_BITS)) {
		int i = (expires >> TVR_BITS) & TVN_MASK;
		vec = base->tv2.vec + i;
	} else if (idx < 1 << (TVR_BITS + 2 * TVN_BITS)) {
		int i = (expires >> (TVR_BITS + TVN_BITS)) & TVN_MASK;
		vec = base->tv3.vec + i;
	} else if (idx < 1 << (TVR_BITS + 3 * TVN_BITS)) {
		int i = (expires >> (TVR_BITS + 2 * TVN_BITS)) & TVN_MASK;
		vec = base->tv4.vec + i;
	} else if ((signed long) idx < 0) {
                ......
	} else {
                ......
		i = (expires >> (TVR_BITS + 3 * TVN_BITS)) & TVN_MASK;
		vec = base->tv5.vec + i;
	}
	list_add_tail(&timer->entry, vec);
}

2.2  定时器的到期处理

经过2.1节的处理后,系统中的定时器按到期时间有规律地放置在tv1--tv5各个链表数组中,其中tv1中放置着在接下来的256个jiffies即将到期的定时器列表,需要注意的是,并不是tv1.vec[0]中放置着马上到期的定时器列表,tv1.vec[1]中放置着将在jiffies+1到期的定时器列表。因为base.timer_jiffies的值一直在随着系统的运行而动态地增加,原则上是每个tick事件会加1,base.timer_jiffies代表者该cpu定时器系统当前时刻,定时器也是动态地加入头256个链表tv1中,按2.1节的讨论,定时器加入tv1中使用的下标索引是定时器到期时间expires的低8位,所以假设当前的base.timer_jiffies值是0x34567826,则马上到期的定时器是在tv1.vec[0x26]中,如果这时候系统加入一个在jiffies值0x34567828到期的定时器,他将会加入到tv1.vec[0x28]中,运行两个tick后,base.timer_jiffies的值会变为0x34567828,很显然,在每次tick事件中,定时器系统只要以base.timer_jiffies的低8位作为索引,取出tv1中相应的链表,里面正好包含了所有在该jiffies值到期的定时器列表。

那什么时候处理tv2--tv5中的定时器?每当base.timer_jiffies的低8位为0值时,这表明base.timer_jiffies的第8-13位有进位发生,这6位正好代表着tv2,这时只要按base.timer_jiffies的第8-13位的值作为下标,移出tv2中对应的定时器链表,然后用internal_add_timer把它们从新加入到定时器系统中来,因为这些定时器一定会在接下来的256个tick期间到期,所以它们肯定会被加入到tv1数组中,这样就完成了tv2往tv1迁移的过程。同样地,当base.timer_jiffies的第8-13位为0时,这表明base.timer_jiffies的第14-19位有进位发生,这6位正好代表着tv3,按base.timer_jiffies的第14-19位的值作为下标,移出tv3中对应的定时器链表,然后用internal_add_timer把它们从新加入到定时器系统中来,显然它们会被加入到tv2中,从而完成tv3到tv2的迁移,tv4,tv5的处理可以以此作类推。具体迁移的代码如下,参数index为事先计算好的高一级tv的需要迁移的数组索引:

static int cascade(struct tvec_base *base, struct tvec *tv, int index)
{
	/* cascade all the timers from tv up one level */
	struct timer_list *timer, *tmp;
	struct list_head tv_list;
 
	list_replace_init(tv->vec + index, &tv_list);  //  移除需要迁移的链表
 
	/*
	 * We are removing _all_ timers from the list, so we
	 * don't have to detach them individually.
	 */
	list_for_each_entry_safe(timer, tmp, &tv_list, entry) {
		BUG_ON(tbase_get_base(timer->base) != base);
                //  重新加入到定时器系统中,实际上将会迁移到下一级的tv数组中
		internal_add_timer(base, timer);  
	}
 
	return index;
}

每个tick事件到来时,内核会在tick定时中断处理期间激活定时器软中断:TIMER_SOFTIRQ,关于软件中断,请参考另一篇博文: Linux中断(interrupt)子系统之五:软件中断(softIRQ。TIMER_SOFTIRQ的执行函数是__run_timers,它实现了本节讨论的逻辑,取出tv1中到期的定时器,执行定时器的回调函数,由此可见, 低分辨率定时器的回调函数是执行在软件中断上下文中的,这点在写定时器的回调函数时需要注意。__run_timers的代码如下:

static inline void __run_timers(struct tvec_base *base)
{
	struct timer_list *timer;
 
	spin_lock_irq(&base->lock);
        /* 同步jiffies,在NO_HZ情况下,base->timer_jiffies可能落后不止一个tick  */
	while (time_after_eq(jiffies, base->timer_jiffies)) {  
		struct list_head work_list;
		struct list_head *head = &work_list;
                /*  计算到期定时器链表在tv1中的索引  */
		int index = base->timer_jiffies & TVR_MASK;  
 
		/*
		 * /*  tv2--tv5定时器列表迁移处理  */
		 */
		if (!index &&
			(!cascade(base, &base->tv2, INDEX(0))) &&              
				(!cascade(base, &base->tv3, INDEX(1))) &&      
					!cascade(base, &base->tv4, INDEX(2)))  
			cascade(base, &base->tv5, INDEX(3));  
                /*  该cpu定时器系统运行时间递增一个tick  */                 
		++base->timer_jiffies;  
                /*  取出到期的定时器链表  */                                       
		list_replace_init(base->tv1.vec + index, &work_list);
                /*  遍历所有的到期定时器  */          
		while (!list_empty(head)) {                                    
			void (*fn)(unsigned long);
			unsigned long data;
 
			timer = list_first_entry(head, struct timer_list,entry);
			fn = timer->function;
			data = timer->data;
 
			timer_stats_account_timer(timer);
 
			base->running_timer = timer;    /*  标记正在处理的定时器  */
			detach_timer(timer, 1);
 
			spin_unlock_irq(&base->lock);
			call_timer_fn(timer, fn, data);  /*  调用定时器的回调函数  */
			spin_lock_irq(&base->lock);
		}
	}
	base->running_timer = NULL;
	spin_unlock_irq(&base->lock);
}

通过上面的讨论,我们可以发现,内核的低分辨率定时器的实现非常精妙,既实现了大量定时器的管理,又实现了快速的O(1)查找到期定时器的能力,利用巧妙的数组结构,使得只需在间隔256个tick时间才处理一次迁移操作,5个数组就好比是5个齿轮,它们随着base->timer_jifffies的增长而不停地转动,每次只需处理第一个齿轮的某一个齿节,低一级的齿轮转动一圈,高一级的齿轮转动一个齿,同时自动把即将到期的定时器迁移到上一个齿轮中,所以低分辨率定时器通常又被叫做时间轮:time wheel。事实上,它的实现是一个很好的空间换时间软件算法。

3.  定时器软件中断

系统初始化时,start_kernel会调用定时器系统的初始化函数init_timers:

void __init init_timers(void)
{      
	int err = timer_cpu_notify(&timers_nb, (unsigned long)CPU_UP_PREPARE, 
				(void *)(long)smp_processor_id());
 
	init_timer_stats();
 
	BUG_ON(err != NOTIFY_OK);
	register_cpu_notifier(&timers_nb);  /* 注册cpu notify,以便在hotplug时在cpu之间进行定时器的迁移 */
	open_softirq(TIMER_SOFTIRQ, run_timer_softirq);
}

可见,open_softirq把run_timer_softirq注册为TIMER_SOFTIRQ的处理函数,另外,当cpu的每个tick事件到来时,在事件处理中断中,update_process_times会被调用,该函数会进一步调用run_local_timers,run_local_timers会触发TIMER_SOFTIRQ软中断:

void run_local_timers(void)
{
	hrtimer_run_queues();
	raise_softirq(TIMER_SOFTIRQ);
}

TIMER_SOFTIRQ的处理函数是run_timer_softirq:

static void run_timer_softirq(struct softirq_action *h)
{
	struct tvec_base *base = __this_cpu_read(tvec_bases);
 
	hrtimer_run_pending();
 
	if (time_after_eq(jiffies, base->timer_jiffies))
		__run_timers(base);
}

好啦,终于看到__run_timers函数了,2.2节已经介绍过,正是这个函数完成了对到期定时器的处理工作,也完成了时间轮的不停转动。

#13 内核模块 » Gentoo时间子系统之四:定时器的引擎:clock_event_device » 2024-04-23 21:43:52

batsom
回复: 0

早期的内核版本中,进程的调度基于一个称之为tick的时钟滴答,通常使用时钟中断来定时地产生tick信号,每次tick定时中断都会进行进程的统计和调度,并对tick进行计数,记录在一个jiffies变量中,定时器的设计也是基于jiffies。这时候的内核代码中,几乎所有关于时钟的操作都是在machine级的代码中实现,很多公共的代码要在每个平台上重复实现。随后,随着通用时钟框架的引入,内核需要支持高精度的定时器,为此,通用时间框架为定时器硬件定义了一个标准的接口:clock_event_device,machine级的代码只要按这个标准接口实现相应的硬件控制功能,剩下的与平台无关的特性则统一由通用时间框架层来实现。

1.  时钟事件软件架构
本系列文章的第一节中,我们曾经讨论了时钟源设备:clocksource,现在又来一个时钟事件设备:clock_event_device,它们有何区别?看名字,好像都是给系统提供时钟的设备,实际上,clocksource不能被编程,没有产生事件的能力,它主要被用于timekeeper来实现对真实时间进行精确的统计,而clock_event_device则是可编程的,它可以工作在周期触发或单次触发模式,系统可以对它进行编程,以确定下一次事件触发的时间,clock_event_device主要用于实现普通定时器和高精度定时器,同时也用于产生tick事件,供给进程调度子系统使用。时钟事件设备与通用时间框架中的其他模块的关系如下图所示:

FluxBB bbcode 测试

>与clocksource一样,系统中可以存在多个clock_event_device,系统会根据它们的精度和能力,选择合适的clock_event_device对系统提供时钟事件服务。在smp系统中,为了减少处理器间的通信开销,基本上每个cpu都会具备一个属于自己的本地clock_event_device,独立地为该cpu提供时钟事件服务,smp中的每个cpu基于本地的clock_event_device,建立自己的tick_device,普通定时器和高精度定时器。
>在软件架构上看,clock_event_device被分为了两层,与硬件相关的被放在了machine层,而与硬件无关的通用代码则被集中到了通用时间框架层,这符合内核对软件的设计需求,平台的开发者只需实现平台相关的接口即可,无需关注复杂的上层时间框架。
>tick_device是基于clock_event_device的进一步封装,用于代替原有的时钟滴答中断,给内核提供tick事件,以完成进程的调度和进程信息统计,负载平衡和时间更新等操作。

2.  时钟事件设备相关数据结构
2.1  struct clock_event_device

时钟事件设备的核心数据结构是clock_event_device结构,它代表着一个时钟硬件设备,该设备就好像是一个具有事件触发能力(通常就是指中断)的clocksource,它不停地计数,当计数值达到预先编程设定的数值那一刻,会引发一个时钟事件中断,继而触发该设备的事件处理回调函数,以完成对时钟事件的处理。clock_event_device结构的定义如下:

struct clock_event_device {
	void			(*event_handler)(struct clock_event_device *);
	int			(*set_next_event)(unsigned long evt,
						  struct clock_event_device *);
	int			(*set_next_ktime)(ktime_t expires,
						  struct clock_event_device *);
	ktime_t			next_event;
	u64			max_delta_ns;
	u64			min_delta_ns;
	u32			mult;
	u32			shift;
	enum clock_event_mode	mode;
	unsigned int		features;
	unsigned long		retries;
 
	void			(*broadcast)(const struct cpumask *mask);
	void			(*set_mode)(enum clock_event_mode mode,
					    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;
	struct list_head	list;
} ____cacheline_aligned;

event_handler  该字段是一个回调函数指针,通常由通用框架层设置,在时间中断到来时,machine底层的的中断服务程序会调用该回调,框架层利用该回调实现对时钟事件的处理。

set_next_event  设置下一次时间触发的时间,使用类似于clocksource的cycle计数值(离现在的cycle差值)作为参数。

set_next_ktime  设置下一次时间触发的时间,直接使用ktime时间作为参数。

max_delta_ns  可设置的最大时间差,单位是纳秒。

min_delta_ns  可设置的最小时间差,单位是纳秒。

mult shift  与clocksource中的类似,只不过是用于把纳秒转换为cycle。

mode  该时钟事件设备的工作模式,两种主要的工作模式分别是:

>CLOCK_EVT_MODE_PERIODIC  周期触发模式,设置后按给定的周期不停地触发事件;
>CLOCK_EVT_MODE_ONESHOT  单次触发模式,只在设置好的触发时刻触发一次;

set_mode  函数指针,用于设置时钟事件设备的工作模式。

rating  表示该设备的精度等级。

list  系统中注册的时钟事件设备用该字段挂在全局链表变量clockevent_devices上。
2.2  全局变量clockevent_devices
系统中所有注册的clock_event_device都会挂在该链表下面,它在kernel/time/clockevents.c中定义:

>static LIST_HEAD(clockevent_devices);

2.3  全局变量clockevents_chain
通用时间框架初始化时会注册一个通知链(NOTIFIER),当系统中的时钟时间设备的状态发生变化时,利用该通知链通知系统的其它模块。

/* Notification for clock events */
static RAW_NOTIFIER_HEAD(clockevents_chain);

3.  clock_event_device的初始化和注册
每一个machine,都要定义一个自己的machine_desc结构,该结构定义了该machine的一些最基本的特性,其中需要设定一个sys_timer结构指针,machine级的代码负责定义sys_timer结构,sys_timer的声明很简单:

struct sys_timer {
	void			(*init)(void);
	void			(*suspend)(void);
	void			(*resume)(void);
#ifdef CONFIG_ARCH_USES_GETTIMEOFFSET
	unsigned long		(*offset)(void);
#endif
};

通常,我们至少要定义它的init字段,系统初始化阶段,该init回调会被调用,该init回调函数的主要作用就是完成系统中的clocksource和clock_event_device的硬件初始化工作,以samsung的exynos4为例,在V3.4内核的代码树中,machine_desc的定义如下:

MACHINE_START(SMDK4412, "SMDK4412")
	/* Maintainer: Kukjin Kim <[email protected]> */
	/* Maintainer: Changhwan Youn <[email protected]> */
	.atag_offset	= 0x100,
	.init_irq	= exynos4_init_irq,
	.map_io		= smdk4x12_map_io,
	.handle_irq	= gic_handle_irq,
	.init_machine	= smdk4x12_machine_init,
	.timer		= &exynos4_timer,
	.restart	= exynos4_restart,
MACHINE_END

定义的sys_timer是exynos4_timer,它的定义和init回调定义如下:

static void __init exynos4_timer_init(void)
{
	if (soc_is_exynos4210())
		mct_int_type = MCT_INT_SPI;
	else
		mct_int_type = MCT_INT_PPI;
 
	exynos4_timer_resources();
	exynos4_clocksource_init();
	exynos4_clockevent_init();
}
 
struct sys_timer exynos4_timer = {
	.init		= exynos4_timer_init,
};

exynos4_clockevent_init函数显然是初始化和注册clock_event_device的合适时机,在这里,它注册了一个rating为250的clock_event_device,并把它指定给cpu0:

static struct clock_event_device mct_comp_device = {
	.name		= "mct-comp",
	.features       = CLOCK_EVT_FEAT_PERIODIC | CLOCK_EVT_FEAT_ONESHOT,
	.rating		= 250,
	.set_next_event	= exynos4_comp_set_next_event,
	.set_mode	= exynos4_comp_set_mode,
};
......
static void exynos4_clockevent_init(void)
{
	clockevents_calc_mult_shift(&mct_comp_device, clk_rate, 5);
        ......
	mct_comp_device.cpumask = cpumask_of(0);
	clockevents_register_device(&mct_comp_device);
 
	setup_irq(EXYNOS4_IRQ_MCT_G0, &mct_comp_event_irq);
}

因为这个阶段其它cpu核尚未开始工作,所以该clock_event_device也只是在启动阶段给系统提供服务,实际上,因为exynos4是一个smp系统,具备2-4个cpu核心,前面说过,smp系统中,通常会使用各个cpu的本地定时器来为每个cpu单独提供时钟事件服务,继续翻阅代码,在系统初始化的后段,kernel_init会被调用,它会调用smp_prepare_cpus,其中会调用percpu_timer_setup函数,在arch/arm/kernel/smp.c中,为每个cpu定义了一个clock_event_device:

/*
 * Timer (local or broadcast) support
 */
static DEFINE_PER_CPU(struct clock_event_device, percpu_clockevent);

percpu_timer_setup最终会调用exynos4_local_timer_setup函数完成对本地clock_event_device的初始化工作:

static int __cpuinit exynos4_local_timer_setup(struct clock_event_device *evt)
{
    ......
	evt->name = mevt->name;
	evt->cpumask = cpumask_of(cpu);
	evt->set_next_event = exynos4_tick_set_next_event;
	evt->set_mode = exynos4_tick_set_mode;
	evt->features = CLOCK_EVT_FEAT_PERIODIC | CLOCK_EVT_FEAT_ONESHOT;
	evt->rating = 450;
 
	clockevents_calc_mult_shift(evt, clk_rate / (TICK_BASE_CNT + 1), 5);
    ......
	clockevents_register_device(evt);
    ......
	enable_percpu_irq(EXYNOS_IRQ_MCT_LOCALTIMER, 0);
    ......
	return 0;
}

由此可见,每个cpu的本地clock_event_device的rating是450,比启动阶段的250要高,显然,之前注册给cpu0的精度要高,系统会用本地clock_event_device替换掉原来分配给cpu0的clock_event_device,至于怎么替换?我们先停一停,到这里我们一直在讨论machine级别的初始化和注册,让我们回过头来,看看框架层的初始化。在继续之前,让我们看看整个clock_event_device的初始化的调用序列图:

FluxBB bbcode 测试

由上面的图示可以看出,框架层的初始化步骤很简单,又start_kernel开始,调用tick_init,它位于kernel/time/tick-common.c中,也只是简单地调用clockevents_register_notifier,同时把类型为notifier_block的tick_notifier作为参数传入,回看2.3节,clockevents_register_notifier注册了一个通知链,这样,当系统中的clock_event_device状态发生变化时(新增,删除,挂起,唤醒等等),tick_notifier中的notifier_call字段中设定的回调函数tick_notify就会被调用。接下来start_kernel调用了time_init函数,该函数通常定义在体系相关的代码中,正如前面所讨论的一样,它主要完成machine级别对时钟系统的初始化工作,最终通过clockevents_register_device注册系统中的时钟事件设备,把每个时钟时间设备挂在clockevent_device全局链表上,最后通过clockevent_do_notify触发框架层事先注册好的通知链,其实就是调用了tick_notify函数,我们主要关注CLOCK_EVT_NOTIFY_ADD通知,其它通知请自行参考代码,下面是tick_notify的简化版本:

static int tick_notify(struct notifier_block *nb, unsigned long reason,
			       void *dev)
{
	switch (reason) {
 
	case CLOCK_EVT_NOTIFY_ADD:
		return tick_check_new_device(dev);
 
	case CLOCK_EVT_NOTIFY_BROADCAST_ON:
	case CLOCK_EVT_NOTIFY_BROADCAST_OFF:
	case CLOCK_EVT_NOTIFY_BROADCAST_FORCE:
            ......
	case CLOCK_EVT_NOTIFY_BROADCAST_ENTER:
	case CLOCK_EVT_NOTIFY_BROADCAST_EXIT:
            ......
	case CLOCK_EVT_NOTIFY_CPU_DYING:
            ......
	case CLOCK_EVT_NOTIFY_CPU_DEAD:
            ......
	case CLOCK_EVT_NOTIFY_SUSPEND:
            ......
	case CLOCK_EVT_NOTIFY_RESUME:
            ......
	}
 
	return NOTIFY_OK;
}

可见,对于新注册的clock_event_device,会发出CLOCK_EVT_NOTIFY_ADD通知,最终会进入函数:tick_check_new_device,这个函数比对当前cpu所使用的与新注册的clock_event_device之间的特性,如果认为新的clock_event_device更好,则会进行切换工作。下一节将会详细的讨论该函数。到这里,每个cpu已经有了自己的clock_event_device,在这以后,框架层的代码会根据内核的配置项(CONFIG_NO_HZ、CONFIG_HIGH_RES_TIMERS),对注册的clock_event_device进行不同的设置,从而为系统的tick和高精度定时器提供服务,这些内容我们留在本系列的后续文章进行讨论。

4.  tick_device
当内核没有配置成支持高精度定时器时,系统的tick由tick_device产生,tick_device其实是clock_event_device的简单封装,它内嵌了一个clock_event_device指针和它的工作模式:

struct tick_device {
	struct clock_event_device *evtdev;
	enum tick_device_mode mode;
};

在kernel/time/tick-common.c中,定义了一个per-cpu的tick_device全局变量,tick_cpu_device:

/*
 * Tick devices
 */
DEFINE_PER_CPU(struct tick_device, tick_cpu_device);

前面曾经说过,当machine的代码为每个cpu注册clock_event_device时,通知回调函数tick_notify会被调用,进而进入tick_check_new_device函数,下面让我们看看该函数如何工作,首先,该函数先判断注册的clock_event_device是否可用于本cpu,然后从per-cpu变量中取出本cpu的tick_device:

static int tick_check_new_device(struct clock_event_device *newdev)
{
        ......
	cpu = smp_processor_id();
	if (!cpumask_test_cpu(cpu, newdev->cpumask))
		goto out_bc;
 
	td = &per_cpu(tick_cpu_device, cpu);
	curdev = td->evtdev;

如果不是本地clock_event_device,会做进一步的判断:如果不能把irq绑定到本cpu,则放弃处理,如果本cpu已经有了一个本地clock_event_device,也放弃处理:

	if (!cpumask_equal(newdev->cpumask, cpumask_of(cpu))) {
                ......
		if (!irq_can_set_affinity(newdev->irq))
			goto out_bc;
                ......
		if (curdev && cpumask_equal(curdev->cpumask, cpumask_of(cpu)))
			goto out_bc;
	}

反之,如果本cpu已经有了一个clock_event_device,则根据是否支持单触发模式和它的rating值,决定是否替换原来旧的clock_event_device:

	if (curdev) {
		if ((curdev->features & CLOCK_EVT_FEAT_ONESHOT) &&
		    !(newdev->features & CLOCK_EVT_FEAT_ONESHOT))
			goto out_bc;  // 新的不支持单触发,但旧的支持,所以不能替换
		if (curdev->rating >= newdev->rating)
			goto out_bc;  // 旧的比新的精度高,不能替换
	}

在这些判断都通过之后,说明或者来cpu还没有绑定tick_device,或者是新的更好,需要替换:

	if (tick_is_broadcast_device(curdev)) {
		clockevents_shutdown(curdev);
		curdev = NULL;
	}
	clockevents_exchange_device(curdev, newdev);
	tick_setup_device(td, newdev, cpu, cpumask_of(cpu));

上面的tick_setup_device函数负责重新绑定当前cpu的tick_device和新注册的clock_event_device,如果发现是当前cpu第一次注册tick_device,就把它设置为TICKDEV_MODE_PERIODIC模式,如果是替换旧的tick_device,则根据新的tick_device的特性,设置为TICKDEV_MODE_PERIODIC或TICKDEV_MODE_ONESHOT模式。可见,在系统的启动阶段,tick_device是工作在周期触发模式的,直到框架层在合适的时机,才会开启单触发模式,以便支持NO_HZ和HRTIMER。
5.  tick事件的处理--最简单的情况
clock_event_device最基本的应用就是实现tick_device,然后给系统定期地产生tick事件,通用时间框架对clock_event_device和tick_device的处理相当复杂,因为涉及配置项:CONFIG_NO_HZ和CONFIG_HIGH_RES_TIMERS的组合,两个配置项就有4种组合,这四种组合的处理都有所不同,所以这里我先只讨论最简单的情况:

>CONFIG_NO_HZ == 0;
>CONFIG_HIGH_RES_TIMERS == 0;

在这种配置模式下,我们回到上一节的tick_setup_device函数的最后:

	if (td->mode == TICKDEV_MODE_PERIODIC)
		tick_setup_periodic(newdev, 0);
	else
		tick_setup_oneshot(newdev, handler, next_event);

因为启动期间,第一个注册的tick_device必然工作在TICKDEV_MODE_PERIODIC模式,所以tick_setup_periodic会设置clock_event_device的事件回调字段event_handler为tick_handle_periodic,工作一段时间后,就算有新的支持TICKDEV_MODE_ONESHOT模式的clock_event_device需要替换,再次进入tick_setup_device函数,tick_setup_oneshot的handler参数也是之前设置的tick_handle_periodic函数,所以我们考察tick_handle_periodic即可:

void tick_handle_periodic(struct clock_event_device *dev)
{
	int cpu = smp_processor_id();
	ktime_t next;
 
	tick_periodic(cpu);
 
	if (dev->mode != CLOCK_EVT_MODE_ONESHOT)
		return;
 
	next = ktime_add(dev->next_event, tick_period);
	for (;;) {
		if (!clockevents_program_event(dev, next, false))
			return;
		if (timekeeping_valid_for_hres())
			tick_periodic(cpu);
		next = ktime_add(next, tick_period);
	}
}

该函数首先调用tick_periodic函数,完成tick事件的所有处理,如果是周期触发模式,处理结束,如果工作在单触发模式,则计算并设置下一次的触发时刻,这里用了一个循环,是为了防止当该函数被调用时,clock_event_device中的计时实际上已经经过了不止一个tick周期,这时候,tick_periodic可能被多次调用,使得jiffies和时间可以被正确地更新。tick_periodic的代码如下:

static void tick_periodic(int cpu)
{
	if (tick_do_timer_cpu == cpu) {
		write_seqlock(&xtime_lock);
 
		/* Keep track of the next tick event */
		tick_next_period = ktime_add(tick_next_period, tick_period);
 
		do_timer(1);
		write_sequnlock(&xtime_lock);
	}
 
	update_process_times(user_mode(get_irq_regs()));
	profile_tick(CPU_PROFILING);
}

如果当前cpu负责更新时间,则通过do_timer进行以下操作:
>更新jiffies_64变量;
>更新墙上时钟;
>每10个tick,更新一次cpu的负载信息;

调用update_peocess_times,完成以下事情:
>更新进程的时间统计信息;
>触发TIMER_SOFTIRQ软件中断,以便系统处理传统的低分辨率定时器;
>检查rcu的callback;
>通过scheduler_tick触发调度系统进行进程统计和调度工作;

#14 内核模块 » Gentoo时间子系统之三:时间的维护者:timekeeper » 2024-04-23 21:41:20

batsom
回复: 0

本系列文章的前两节讨论了用于计时的时钟源:clocksource,以及内核内部时间的一些表示方法,但是对于真实的用户来说,我们感知的是真实世界的真实时间,也就是所谓的墙上时间,clocksource只能提供一个按给定频率不停递增的周期计数,如何把它和真实的墙上时间相关联?本节的内容正是要讨论这一点。
1.  时间的种类

内核管理着多种时间,它们分别是:
>RTC时间
>wall time:墙上时间
>monotonic time
>raw monotonic time
>boot time:总启动时间

RTC时间  在PC中,RTC时间又叫CMOS时间,它通常由一个专门的计时硬件来实现,软件可以读取该硬件来获得年月日、时分秒等时间信息,而在嵌入式系统中,有使用专门的RTC芯片,也有直接把RTC集成到Soc芯片中,读取Soc中的某个寄存器即可获取当前时间信息。一般来说,RTC是一种可持续计时的,也就是说,不管系统是否上电,RTC中的时间信息都不会丢失,计时会一直持续进行,硬件上通常使用一个后备电池对RTC硬件进行单独的供电。因为RTC硬件的多样性,开发者需要为每种RTC时钟硬件提供相应的驱动程序,内核和用户空间通过驱动程序访问RTC硬件来获取或设置时间信息。

xtime  xtime和RTC时间一样,都是人们日常所使用的墙上时间,只是RTC时间的精度通常比较低,大多数情况下只能达到毫秒级别的精度,如果是使用外部的RTC芯片,访问速度也比较慢,为此,内核维护了另外一个wall time时间:xtime,取决于用于对xtime计时的clocksource,它的精度甚至可以达到纳秒级别,因为xtime实际上是一个内存中的变量,它的访问速度非常快,内核大部分时间都是使用xtime来获得当前时间信息。xtime记录的是自1970年1月1日24时到当前时刻所经历的纳秒数。

monotonic time  该时间自系统开机后就一直单调地增加,它不像xtime可以因用户的调整时间而产生跳变,不过该时间不计算系统休眠的时间,也就是说,系统休眠时,monotoic时间不会递增。

raw monotonic time  该时间与monotonic时间类似,也是单调递增的时间,唯一的不同是:raw monotonic time“更纯净”,他不会受到NTP时间调整的影响,它代表着系统独立时钟硬件对时间的统计。

boot time  与monotonic时间相同,不过会累加上系统休眠的时间,它代表着系统上电后的总时间。

struct timekeeper {
	struct clocksource *clock;    /* Current clocksource used for timekeeping. */
	u32	mult;    /* NTP adjusted clock multiplier */
	int	shift;	/* The shift value of the current clocksource. */
	cycle_t cycle_interval;	/* Number of clock cycles in one NTP interval. */
	u64	xtime_interval;	/* Number of clock shifted nano seconds in one NTP interval. */
	s64	xtime_remainder;	/* shifted nano seconds left over when rounding cycle_interval */
	u32	raw_interval;	/* Raw nano seconds accumulated per NTP interval. */
 
	u64	xtime_nsec;	/* Clock shifted nano seconds remainder not stored in xtime.tv_nsec. */
	/* Difference between accumulated time and NTP time in ntp
	 * shifted nano seconds. */
	s64	ntp_error;
	/* Shift conversion between clock shifted nano seconds and
	 * ntp shifted nano seconds. */
	int	ntp_error_shift;
 
	struct timespec xtime;	/* The current time */
 
	struct timespec wall_to_monotonic;
	struct timespec total_sleep_time;	/* time spent in suspend */
	struct timespec raw_time;	/* The raw monotonic time for the CLOCK_MONOTONIC_RAW posix clock. */
 
	ktime_t offs_real;	/* Offset clock monotonic -> clock realtime */
 
	ktime_t offs_boot;	/* Offset clock monotonic -> clock boottime */
 
	seqlock_t lock;	/* Seqlock for all timekeeper values */
};

其中的xtime字段就是上面所说的墙上时间,它是一个timespec结构的变量,它记录了自1970年1月1日以来所经过的时间,因为是timespec结构,所以它的精度可以达到纳秒级,当然那要取决于系统的硬件是否支持这一精度。

内核除了用xtime表示墙上的真实时间外,还维护了另外一个时间:monotonic time,可以把它理解为自系统启动以来所经过的时间,该时间只能单调递增,可以理解为xtime虽然正常情况下也是递增的,但是毕竟用户可以主动向前或向后调整墙上时间,从而修改xtime值。但是monotonic时间不可以往后退,系统启动后只能不断递增。奇怪的是,内核并没有直接定义一个这样的变量来记录monotonic时间,而是定义了一个变量wall_to_monotonic,记录了墙上时间和monotonic时间之间的偏移量,当需要获得monotonic时间时,把xtime和wall_to_monotonic相加即可,因为默认启动时monotonic时间为0,所以实际上wall_to_monotonic的值是一个负数,它和xtime同一时间被初始化,请参考timekeeping_init函数。

计算monotonic时间要去除系统休眠期间花费的时间,内核用total_sleep_time记录休眠的时间,每次休眠醒来后重新累加该时间,并调整wall_to_monotonic的值,使其在系统休眠醒来后,monotonic时间不会发生跳变。因为wall_to_monotonic值被调整。所以如果想获取boot time,需要加入该变量的值:

void get_monotonic_boottime(struct timespec *ts)
{
        ......
	do {
		seq = read_seqbegin(&timekeeper.lock);
		*ts = timekeeper.xtime;
		tomono = timekeeper.wall_to_monotonic;
		sleep = timekeeper.total_sleep_time;
		nsecs = timekeeping_get_ns();
 
	} while (read_seqretry(&timekeeper.lock, seq));
 
	set_normalized_timespec(ts, ts->tv_sec + tomono.tv_sec + sleep.tv_sec,
			ts->tv_nsec + tomono.tv_nsec + sleep.tv_nsec + nsecs);
}

raw_time字段用来表示真正的硬件时间,也就是上面所说的raw monotonic time,它不受时间调整的影响,monotonic时间虽然也不受settimeofday的影响,但会受到ntp调整的影响,但是raw_time不受ntp的影响,他真的就是开完机后就单调地递增。xtime、monotonic-time和raw_time可以通过用户空间的clock_gettime函数获得,对应的ID参数分别是 CLOCK_REALTIME、CLOCK_MONOTONIC、CLOCK_MONOTONIC_RAW。

clock字段则指向了目前timekeeper所使用的时钟源,xtime,monotonic time和raw time都是基于该时钟源进行计时操作,当有新的精度更高的时钟源被注册时,通过timekeeping_notify函数,change_clocksource函数将会被调用,timekeeper.clock字段将会被更新,指向新的clocksource。

早期的内核版本中,xtime、wall_to_monotonic、raw_time其实是定义为全局静态变量,到我目前的版本(V3.4.10),这几个变量被移入到了timekeeper结构中,现在只需维护一个timekeeper全局静态变量即可:

>static struct timekeeper timekeeper;

3.  timekeeper的初始化

timekeeper的初始化由timekeeping_init完成,该函数在start_kernel的初始化序列中被调用,timekeeping_init首先从RTC中获取当前时间:

void __init timekeeping_init(void)
{
	struct clocksource *clock;
	unsigned long flags;
	struct timespec now, boot;
 
	read_persistent_clock(&now);
	read_boot_clock(&boot);

然后对锁和ntp进行必要的初始化:

	seqlock_init(&timekeeper.lock);
 
	ntp_init();

利用RTC的当前时间,初始化xtime,raw_time,wall_to_monotonic等字段:

	timekeeper.xtime.tv_sec = now.tv_sec;
	timekeeper.xtime.tv_nsec = now.tv_nsec;
	timekeeper.raw_time.tv_sec = 0;
	timekeeper.raw_time.tv_nsec = 0;
	if (boot.tv_sec == 0 && boot.tv_nsec == 0) {
		boot.tv_sec = timekeeper.xtime.tv_sec;
		boot.tv_nsec = timekeeper.xtime.tv_nsec;
	}
	set_normalized_timespec(&timekeeper.wall_to_monotonic,
				-boot.tv_sec, -boot.tv_nsec);

最后,初始化代表实时时间和monotonic时间之间偏移量的offs_real字段,total_sleep_time字段初始化为0:

	update_rt_offset();
	timekeeper.total_sleep_time.tv_sec = 0;
	timekeeper.total_sleep_time.tv_nsec = 0;
	write_sequnlock_irqrestore(&timekeeper.lock, flags);

xtime字段因为是保存在内存中,系统掉电后无法保存时间信息,所以每次启动时都要通过timekeeping_init从RTC中同步正确的时间信息。其中,read_persistent_clock和read_boot_clock是平台级的函数,分别用于获取RTC硬件时间和启动时的时间,不过值得注意到是,到目前为止(我的代码树基于3.4版本),ARM体系中,只有tegra和omap平台实现了read_persistent_clock函数。如果平台没有实现该函数,内核提供了一个默认的实现:

void __attribute__((weak)) read_persistent_clock(struct timespec *ts)
{
	ts->tv_sec = 0;
	ts->tv_nsec = 0;
}
void __attribute__((weak)) read_boot_clock(struct timespec *ts)
{
	ts->tv_sec = 0;
	ts->tv_nsec = 0;
}

那么,其他ARM平台是如何初始化xtime的?答案就是CONFIG_RTC_HCTOSYS这个内核配置项,打开该配置后,driver/rtc/hctosys.c将会编译到系统中,由rtc_hctosys函数通过do_settimeofday在系统初始化时完成xtime变量的初始化:

static int __init rtc_hctosys(void) 
{ 
        ...... 
        err = rtc_read_time(rtc, &tm); 
        ......
        rtc_tm_to_time(&tm, &tv.tv_sec); 
        do_settimeofday(&tv); 
        ...... 
        return err; 
} 
late_initcall(rtc_hctosys);

4.  时间的更新

xtime一旦初始化完成后,timekeeper就开始独立于RTC,利用自身关联的clocksource进行时间的更新操作,根据内核的配置项的不同,更新时间的操作发生的频度也不尽相同,如果没有配置NO_HZ选项,通常每个tick的定时中断周期,do_timer会被调用一次,相反,如果配置了NO_HZ选项,可能会在好几个tick后,do_timer才会被调用一次,当然传入的参数是本次更新离上一次更新时相隔了多少个tick周期,系统会保证在clocksource的max_idle_ns时间内调用do_timer,以防止clocksource的溢出:

void do_timer(unsigned long ticks)
{
	jiffies_64 += ticks;
	update_wall_time();
	calc_global_load(ticks);
}

在do_timer中,jiffies_64变量被相应地累加,然后在update_wall_time中完成xtime等时间的更新操作,更新时间的核心操作就是读取关联clocksource的计数值,累加到xtime等字段中,其中还设计ntp时间的调整等代码,详细的代码就不贴了。

5.  获取时间

timekeeper提供了一系列的接口用于获取各种时间信息。
>void getboottime(struct timespec *ts);    获取系统启动时刻的实时时间
>void get_monotonic_boottime(struct timespec *ts);     获取系统启动以来所经过的时间,包含休眠时间
>ktime_t ktime_get_boottime(void);   获取系统启动以来所经过的c时间,包含休眠时间,返回ktime类型
>ktime_t ktime_get(void);    获取系统启动以来所经过的c时间,不包含休眠时间,返回ktime类型
>void ktime_get_ts(struct timespec *ts) ;   获取系统启动以来所经过的c时间,不包含休眠时间,返回timespec结构
>unsigned long get_seconds(void);    返回xtime中的秒计数值
>struct timespec current_kernel_time(void);    返回内核最后一次更新的xtime时间,不累计最后一次更新至今clocksource的计数值
>void getnstimeofday(struct timespec *ts);    获取当前时间,返回timespec结构
>void do_gettimeofday(struct timeval *tv);    获取当前时间,返回timeval结构


FluxBB bbcode 测试

#15 内核模块 » Gentoo时间子系统之二:表示时间的单位和结构 » 2024-04-23 21:38:34

batsom
回复: 0

人们习惯用于表示时间的方法是:年、月、日、时、分、秒、毫秒、星期等等,但是在内核中,为了软件逻辑和代码的方便性,它使用了一些不同的时间表示方法,并为这些表示方法定义了相应的变量和数据结构,本节的内容就是阐述这些表示方法的意义和区别。


/*****************************************************************************************************/
声明:本博内容均由http://blog.csdn.net/droidphone原创,转载请注明出处,谢谢!

/*****************************************************************************************************/
1.  jiffies

内核用jiffies变量记录系统启动以来经过的时钟滴答数,它的声明如下:

extern u64 __jiffy_data jiffies_64;
extern unsigned long volatile __jiffy_data jiffies;

可见,在32位的系统上,jiffies是一个32位的无符号数,系统每过1/HZ秒,jiffies的值就会加1,最终该变量可能会溢出,所以内核同时又定义了一个64位的变量jiffies_64,链接的脚本保证jiffies变量和jiffies_64变量的内存地址是相同的,通常,我们可以直接访问jiffies变量,但是要获得jiffies_64变量,必须通过辅助函数get_jiffies_64来实现。jiffies是内核的低精度定时器的计时单位,所以内核配置的HZ数决定了低精度定时器的精度,如果HZ数被设定为1000,那么,低精度定时器(timer_list)的精度就是1ms=1/1000秒。因为jiffies变量可能存在溢出的问题,所以在用基于jiffies进行比较时,应该使用以下辅助宏来实现:

time_after(a,b)
time_before(a,b)
time_after_eq(a,b)
time_before_eq(a,b)
time_in_range(a,b,c)

同时,内核还提供了一些辅助函数用于jiffies和毫秒以及纳秒之间的转换:

unsigned int jiffies_to_msecs(const unsigned long j);
unsigned int jiffies_to_usecs(const unsigned long j);
unsigned long msecs_to_jiffies(const unsigned int m);
unsigned long usecs_to_jiffies(const unsigned int u);

2.  struct timeval
timeval由秒和微秒组成,它的定义如下:

struct timeval {
	__kernel_time_t		tv_sec;		/* seconds */
	__kernel_suseconds_t	tv_usec;	/* microseconds */
};

__kernel_time_t  和 __kernel_suseconds_t 实际上都是long型的整数。gettimeofday和settimeofday使用timeval作为时间单位。
3.  struct timespec
timespec由秒和纳秒组成,它的定义如下:

struct timespec {
	__kernel_time_t	tv_sec;			/* seconds */
	long		tv_nsec;		/* nanoseconds */
};

同样地,内核也提供了一些辅助函数用于jiffies、timeval、timespec之间的转换:

static inline int timespec_equal(const struct timespec *a, const struct timespec *b);
static inline int timespec_compare(const struct timespec *lhs, const struct timespec *rhs);
static inline int timeval_compare(const struct timeval *lhs, const struct timeval *rhs);
extern unsigned long mktime(const unsigned int year, const unsigned int mon,
			    const unsigned int day, const unsigned int hour,
			    const unsigned int min, const unsigned int sec);
extern void set_normalized_timespec(struct timespec *ts, time_t sec, s64 nsec);
static inline struct timespec timespec_add(struct timespec lhs,	struct timespec rhs);
static inline struct timespec timespec_sub(struct timespec lhs,	struct timespec rhs);
 
static inline s64 timespec_to_ns(const struct timespec *ts);
static inline s64 timeval_to_ns(const struct timeval *tv);
extern struct timespec ns_to_timespec(const s64 nsec);
extern struct timeval ns_to_timeval(const s64 nsec);
static __always_inline void timespec_add_ns(struct timespec *a, u64 ns);
unsigned long timespec_to_jiffies(const struct timespec *value);
void jiffies_to_timespec(const unsigned long jiffies, struct timespec *value);
unsigned long timeval_to_jiffies(const struct timeval *value);
void jiffies_to_timeval(const unsigned long jiffies, struct timeval *value);

timekeeper中的xtime字段用timespec作为时间单位。
4.  struct ktime
linux的通用时间架构用ktime来表示时间,为了兼容32位和64位以及big-little endian系统,ktime结构被定义如下:

union ktime {
	s64	tv64;
#if BITS_PER_LONG != 64 && !defined(CONFIG_KTIME_SCALAR)
	struct {
# ifdef __BIG_ENDIAN
	s32	sec, nsec;
# else
	s32	nsec, sec;
# endif
	} tv;
#endif
};

64位的系统可以直接访问tv64字段,单位是纳秒,32位的系统则被拆分为两个字段:sec和nsec,并且照顾了大小端的不同。高精度定时器通常用ktime作为计时单位。下面是一些辅助函数用于计算和转换:

ktime_t ktime_set(const long secs, const unsigned long nsecs); 
ktime_t ktime_sub(const ktime_t lhs, const ktime_t rhs); 
ktime_t ktime_add(const ktime_t add1, const ktime_t add2); 
ktime_t ktime_add_ns(const ktime_t kt, u64 nsec); 
ktime_t ktime_sub_ns(const ktime_t kt, u64 nsec); 
ktime_t timespec_to_ktime(const struct timespec ts); 
ktime_t timeval_to_ktime(const struct timeval tv); 
struct timespec ktime_to_timespec(const ktime_t kt); 
struct timeval ktime_to_timeval(const ktime_t kt); 
s64 ktime_to_ns(const ktime_t kt); 
int ktime_equal(const ktime_t cmp1, const ktime_t cmp2); 
s64 ktime_to_us(const ktime_t kt); 
s64 ktime_to_ms(const ktime_t kt); 
ktime_t ns_to_ktime(u64 ns);

#16 内核模块 » Gentoo时间子系统之一:clock source(时钟源) » 2024-04-23 21:35:39

batsom
回复: 0

clock source用于为linux内核提供一个时间基线,如果你用linux的date命令获取当前时间,内核会读取当前的clock source,转换并返回合适的时间单位给用户空间。在硬件层,它通常实现为一个由固定时钟频率驱动的计数器,计数器只能单调地增加,直到溢出为止。时钟源是内核计时的基础,系统启动时,内核通过硬件RTC获得当前时间,在这以后,在大多数情况下,内核通过选定的时钟源更新实时时间信息(墙上时间),而不再读取RTC的时间。本节的内核代码树基于V3.4.10。


struct clocksource {
	/*
	 * Hotpath data, fits in a single cache line when the
	 * clocksource itself is cacheline aligned.
	 */
	cycle_t (*read)(struct clocksource *cs);
	cycle_t cycle_last;
	cycle_t mask;
	u32 mult;
	u32 shift;
	u64 max_idle_ns;
	u32 maxadj;
#ifdef CONFIG_ARCH_CLOCKSOURCE_DATA
	struct arch_clocksource_data archdata;
#endif
 
	const char *name;
	struct list_head list;
	int rating;
	int (*enable)(struct clocksource *cs);
	void (*disable)(struct clocksource *cs);
	unsigned long flags;
	void (*suspend)(struct clocksource *cs);
	void (*resume)(struct clocksource *cs);
 
	/* private: */
#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;

我们只关注clocksource中的几个重要的字段。

1.1  rating:时钟源的精度
同一个设备下,可以有多个时钟源,每个时钟源的精度由驱动它的时钟频率决定,比如一个由10MHz时钟驱动的时钟源,他的精度就是100nS。clocksource结构中有一个rating字段,代表着该时钟源的精度范围,它的取值范围如下:

>1--99: 不适合于用作实际的时钟源,只用于启动过程或用于测试;
>100--199:基本可用,可用作真实的时钟源,但不推荐;
>200--299:精度较好,可用作真实的时钟源;
>300--399:很好,精确的时钟源;
>400--499:理想的时钟源,如有可能就必须选择它作为时钟源;

1.2  read回调函数
时钟源本身不会产生中断,要获得时钟源的当前计数,只能通过主动调用它的read回调函数来获得当前的计数值,注意这里只能获得计数值,也就是所谓的cycle,要获得相应的时间,必须要借助clocksource的mult和shift字段进行转换计算。
1.3  mult和shift字段
因为从clocksource中读到的值是一个cycle计数值,要转换为时间,我们必须要知道驱动clocksource的时钟频率F,一个简单的计算就可以完成:

>t = cycle/F;

可是clocksource并没有保存时钟的频率F,因为使用上面的公式进行计算,需要使用浮点运算,这在内核中是不允许的,因此,内核使用了另外一个变通的办法,根据时钟的频率和期望的精度,事先计算出两个辅助常数mult和shift,然后使用以下公式进行cycle和t的转换:

>t = (cycle * mult) >> shift;

只要我们保证:

>F = (1 << shift) / mult;

内核内部使用64位进行该转换计算:

static inline s64 clocksource_cyc2ns(cycle_t cycles, u32 mult, u32 shift)
{
        return ((u64) cycles * mult) >> shift;
}

从转换精度考虑,mult的值是越大越好,但是为了计算过程不发生溢出,mult的值又不能取得过大。为此内核假设cycle计数值被转换后的最大时间值:10分钟(600秒),主要的考虑是CPU进入IDLE状态后,时间信息不会被更新,只要在10分钟内退出IDLE,clocksource的cycle计数值就可以被正确地转换为相应的时间,然后系统的时间信息可以被正确地更新。当然最后的结果不一定是10分钟,它由clocksource_max_deferment进行计算,并保存max_idle_ns字段中,tickless的代码要考虑这个值,以防止在NO_HZ配置环境下,系统保持IDLE状态的时间过长。在这样,由10分钟这个假设的时间值,我们可以推算出合适的mult和shift值。
2.  clocksource的注册和初始化
通常,clocksource要在初始化阶段通过clocksource_register_hz函数通知内核它的工作时钟的频率,调用的过程如下:

FluxBB bbcode 测试

由上图可见,最终大部分工作会转由__clocksource_register_scale完成,该函数首先完成对mult和shift值的计算,然后根据mult和shift值,最终通过clocksource_max_deferment获得该clocksource可接受的最大IDLE时间,并记录在clocksource的max_idle_ns字段中。clocksource_enqueue函数负责按clocksource的rating的大小,把该clocksource按顺序挂在全局链表clocksource_list上,rating值越大,在链表上的位置越靠前。
每次新的clocksource注册进来,都会触发clocksource_select函数被调用,它按照rating值选择最好的clocksource,并记录在全局变量curr_clocksource中,然后通过timekeeping_notify函数通知timekeeping,当前clocksource已经变更,关于timekeeping,我将会在后续的博文中阐述。
3.  clocksource watchdog

系统中可能同时会注册对个clocksource,各个clocksource的精度和稳定性各不相同,为了筛选这些注册的clocksource,内核启用了一个定时器用于监控这些clocksource的性能,定时器的周期设为0.5秒:

#define WATCHDOG_INTERVAL (HZ >> 1)
#define WATCHDOG_THRESHOLD (NSEC_PER_SEC >> 4)

当有新的clocksource被注册时,除了会挂在全局链表clocksource_list外,还会同时挂在一个watchdog链表上:watchdog_list。定时器周期性地(0.5秒)检查watchdog_list上的clocksource,WATCHDOG_THRESHOLD的值定义为0.0625秒,如果在0.5秒内,clocksource的偏差大于这个值就表示这个clocksource是不稳定的,定时器的回调函数通过clocksource_watchdog_kthread线程标记该clocksource,并把它的rate修改为0,表示精度极差。
4.  建立clocksource的简要过程

在系统的启动阶段,内核注册了一个基于jiffies的clocksource,代码位于kernel/time/jiffies.c:

struct clocksource clocksource_jiffies = {
	.name		= "jiffies",
	.rating		= 1, /* lowest valid rating*/
	.read		= jiffies_read,
	.mask		= 0xffffffff, /*32bits*/
	.mult		= NSEC_PER_JIFFY << JIFFIES_SHIFT, /* details above */
	.shift		= JIFFIES_SHIFT,
};
......
 
static int __init init_jiffies_clocksource(void)
{
	return clocksource_register(&clocksource_jiffies);
}
 
core_initcall(init_jiffies_clocksource);

它的精度只有1/HZ秒,rating值为1,如果平台的代码没有提供定制的clocksource_default_clock函数,它将返回该clocksource:

struct clocksource * __init __weak clocksource_default_clock(void)
{
	return &clocksource_jiffies;
}

然后,在初始化的后段,clocksource的代码会把全局变量curr_clocksource设置为上述的clocksource:

static int __init clocksource_done_booting(void)
{
        ......
	curr_clocksource = clocksource_default_clock();
        ......
	finished_booting = 1;
        ......
	clocksource_select();
        ......
	return 0;
}
fs_initcall(clocksource_done_booting);

当然,如果平台级的代码在初始化时也会注册真正的硬件clocksource,所以经过clocksource_select()函数后,curr_clocksource将会被设为最合适的clocksource。如果clocksource_select函数认为需要切换更好的时钟源,它会通过timekeeping_notify通知timekeeping系统,使用新的clocksource进行时间计数和更新操作。

#17 进程模块 » 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 测试

#18 进程模块 » 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性能。

#19 进程模块 » 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);
}

#20 引导模块和保护模式 » 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,从而完成了命令的执行。

#21 引导模块和保护模式 » 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分区匹配(而非一致),需要进一步解释。

#22 内核模块 » 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)

#23 进程模块 » 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 时,说明这个实体是一个进程。如果这个字段指向一个可运行队列时,说明这个实体是一个进程组。

#24 进程模块 » 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

#25 进程模块 » 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 在容器中的应用我们放在下一篇中介绍,敬请期待

页脚

Powered by FluxBB