公告

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

#1 内核模块 » 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节中做了说明,这里就不在赘述了。

#2 内核模块 » 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。

#3 内核模块 » 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,它们是如此地相像,实际上,它们几乎完成了一样的工作。

#4 内核模块 » 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节已经介绍过,正是这个函数完成了对到期定时器的处理工作,也完成了时间轮的不停转动。

#5 内核模块 » 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 <kgene.kim@samsung.com> */
	/* Maintainer: Changhwan Youn <chaos.youn@samsung.com> */
	.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触发调度系统进行进程统计和调度工作;

#6 内核模块 » 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 测试

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

#8 内核模块 » 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进行时间计数和更新操作。

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

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

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

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

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

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

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

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

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

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

batsom
回复: 0

Linux资源管理之cgroups简介
引子

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

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

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

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

概念及原理
cgroups子系统

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

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

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

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

FluxBB bbcode 测试

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

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

####cgroups与进程

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

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

FluxBB bbcode 测试

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

####子节点和进程

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

batsom
回复: 0

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

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

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

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

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

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

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

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

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

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

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

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

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

● test-and-set (TSR)

● compare-and-swap (CAS)

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

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

FluxBB bbcode 测试

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

FluxBB bbcode 测试

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

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

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

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

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

原始的RCU思想

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

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

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

FluxBB bbcode 测试

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

(Quiescent State-Based Reclamation)

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

FluxBB bbcode 测试

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

FluxBB bbcode 测试

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

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

FluxBB bbcode 测试

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

RCU 模型

FluxBB bbcode 测试

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

三个重要概念

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

FluxBB bbcode 测试

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

FluxBB bbcode 测试

GP原理

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

FluxBB bbcode 测试

三个主要的角色

FluxBB bbcode 测试

读者reader:

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

写者updater:

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

回收者reclaimer:

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


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

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

延迟回收机制:

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


多版本机制:

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

FluxBB bbcode 测试

最后总结

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

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

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

FluxBB bbcode 测试

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

FluxBB bbcode 测试

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

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


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

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

batsom
回复: 0

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

1. CPU 隔离 – 简介

2. CPU 隔离 – Full Dynticks 深探

3. CPU 隔离 – Nohz_full

4. CPU 隔离 – 管理和权衡

5. CPU 隔离 – 实践

本文是第三篇。
NOHZ_FULL


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

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


nohz_full=4-7


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


nohz_full 的作用


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


定时器中断


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


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


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


定时器回调


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


工作队列和其他内核线程


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


RCU


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

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


Cputime 记账


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


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

用户任务仿射


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


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

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


IRQ 仿射


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

/proc/irq/$IRQ/smp_affinity

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

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

batsom
回复: 0

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

FluxBB bbcode 测试

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

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

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

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

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

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

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

FluxBB bbcode 测试

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

FluxBB bbcode 测试

FluxBB bbcode 测试

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

FluxBB bbcode 测试

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

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

1.进程负载计算

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

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

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

FluxBB bbcode 测试

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

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

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

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

FluxBB bbcode 测试

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

2.CPU负载计算

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

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

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

batsom
回复: 0

一、WALT简介

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

二、相关数据结构

(1) 嵌入在 task_struct 中的 walt_task_struct

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

(2) 嵌入在 rq 中的 walt_rq

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

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

三、负载计算函数

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

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

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

    lockdep_assert_held(&rq->lock);

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

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

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

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

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

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

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

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

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

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

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

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

打印内容:

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

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

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

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

打印内容:

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

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

2. walt_update_task_ravg 的调用路径

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

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

3. update_window_start 函数

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

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

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

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

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

    return old_window_start;
}

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

4. update_task_cpu_cycles 函数

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

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

5. update_task_rq_cpu_cycles 函数
'

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

    lockdep_assert_held(&rq->lock);

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

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

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

        cycles_delta = cycles_delta * NSEC_PER_MSEC;

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

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

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

    p->wts.cpu_cycles = cur_cycles;
}

其中Trace:

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

参数原型:

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

打印内容:

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

字段解析:

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

6. update_history 解析

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

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

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

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

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

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

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

    pred_demand = predict_and_update_buckets(p, runtime);

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

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

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

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

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

其中Trace:

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

参数原型:

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

打印内容:

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

字段解析:

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

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

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

    int bidx;
    u32 pred_demand;

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

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

    return pred_demand;
}

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

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

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

    return bidx;
}

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

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

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

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

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

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

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

    return ret;
}

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

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

其中Trace:

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

参数原型:

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

打印内容:

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

字段解析:

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

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

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

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

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

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

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

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

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

7. update_task_demand 函数

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return delta;
}

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

8. update_cpu_busy_time 函数

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

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

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

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

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

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

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

        nt_curr_runnable_sum = &cpu_time->nt_curr_runnable_sum;
        nt_prev_runnable_sum = &cpu_time->nt_prev_runnable_sum;
    }

    if (!new_window) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。 
         * 没有翻转,因为我们没有启动一个新窗口。 这方面的一个例子是当
         * 任务开始执行然后在同一窗口内休眠时。
         */
        if (!irqtime || !is_idle_task(p) || cpu_is_waiting_on_io(rq))
            delta = wallclock - mark_start;
        else
            delta = irqtime;
        delta = scale_exec_time(delta, rq); //等于直接return delta
        *curr_runnable_sum += delta;
        if (new_task)
            *nt_curr_runnable_sum += delta;

        if (!is_idle_task(p)) {
            p->wts.curr_window += delta;
            p->wts.curr_window_cpu[cpu] += delta;
        }

        goto done;
    }
    /*----下面就是有一个新窗口的情况了----*/

    if (!p_is_curr_task) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。
         * 一个新窗口也已启动,但 p 不是当前任务,因此窗口不会翻转 
         * - 只需拆分并根据需要将计数分为 curr 和 prev。 仅在为当前任
         * 务处理新窗口时才会翻转窗口。
         *
         * irqtime 不能由不是当前正在运行的任务的任务计算。
         */

        if (!full_window) {
            /* 一个完整的窗口还没有过去,计算对上一个完成的窗口的部分贡献。*/
            delta = scale_exec_time(window_start - mark_start, rq);
            p->wts.prev_window += delta;
            p->wts.prev_window_cpu[cpu] += delta;
        } else {
            /* 由于至少一个完整的窗口已经过去,对前一个窗口的贡献是一个完整的窗口(window_size) */
            delta = scale_exec_time(window_size, rq);
            p->wts.prev_window = delta;
            p->wts.prev_window_cpu[cpu] = delta;
        }

        *prev_runnable_sum += delta;
        if (new_task)
            *nt_prev_runnable_sum += delta;

        /* 只占当前窗口的一部分繁忙时间 */
        delta = scale_exec_time(wallclock - window_start, rq);
        *curr_runnable_sum += delta;
        if (new_task)
            *nt_curr_runnable_sum += delta;

        p->wts.curr_window = delta; /*对当前窗的贡献直接复制给当前窗*/
        p->wts.curr_window_cpu[cpu] = delta;

        goto done;
    }
    /*----下面p是当前任务的情况了----*/

    if (!irqtime || !is_idle_task(p) || cpu_is_waiting_on_io(rq)) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。 一个新窗口已经启动, 
         * p 是当前任务,因此需要翻转。 如果以上三个条件中的任何一个为真,那么这个繁忙的时
         * 间就不能算作 irqtime。
         *
         * 空闲任务的繁忙时间不需要计算。
         *
         * 一个例子是一个任务开始执行,然后在新窗口开始后休眠。
         */

        if (!full_window) {
            /* 一个完整的窗口还没有过去,计算对上一个完整的窗口的部分贡献。*/
            delta = scale_exec_time(window_start - mark_start, rq); //等效直接返回window_start - mark_start
            if (!is_idle_task(p)) {
                p->wts.prev_window += delta;
                p->wts.prev_window_cpu[cpu] += delta;
            }
        } else {
            /* 由于至少一个完整的窗口已经过去,对前一个窗口的贡献是完整的窗口(window_size)*/
            delta = scale_exec_time(window_size, rq);
            if (!is_idle_task(p)) {
                p->wts.prev_window = delta;
                p->wts.prev_window_cpu[cpu] = delta;
            }
        }

        /* 在这里通过覆盖 prev_runnable_sum 和 curr_runnable_sum 中的值来完成翻转。*/
        *prev_runnable_sum += delta;
        if (new_task)
            *nt_prev_runnable_sum += delta;

        /* 计算在当前窗口忙时的一片时间 */
        delta = scale_exec_time(wallclock - window_start, rq);
        *curr_runnable_sum += delta;
        if (new_task)
            *nt_curr_runnable_sum += delta;

        if (!is_idle_task(p)) {
            p->wts.curr_window = delta;
            p->wts.curr_window_cpu[cpu] = delta;
        }

        goto done;
    }
    /*---- 下面就对应 irqtime && is_idle_task(p) && !cpu_is_waiting_on_io(rq) 的情况了,并且累积上面的条件 ----*/

    if (irqtime) {
        /*
         * account_busy_for_cpu_time() = 1 所以忙时间需要计入当前窗口。
         * 一个新窗口已经启动,p 是当前任务,因此需要翻转。 当前任务必
         * 须是空闲任务,因为不为其他任何任务计算irqtime。
         *
         * 空闲一段时间后,每次我们处理 IRQ 活动时都会计算 Irqtime,因
         * 此我们知道 IRQ 繁忙时间为 wallclock - irqtime。
         */

        SCHED_BUG_ON(!is_idle_task(p));
        mark_start = wallclock - irqtime;

        /*
         * 滚动窗口。 如果 IRQ 繁忙时间只是在当前窗口中,那么这就是所有需要计算的。
         */
        if (mark_start > window_start) {
            *curr_runnable_sum = scale_exec_time(irqtime, rq); //等效于直接返回irqtime,因为是idle线程,之前应该是0的
            return;
        }
        /*---下面是ms<=ws---*/

        /*
         * IRQ 繁忙时间跨越多个窗口。 先处理当前窗口开始前的忙时间。
         */
        delta = window_start - mark_start;
        if (delta > window_size)
            delta = window_size;
        delta = scale_exec_time(delta, rq);
        *prev_runnable_sum += delta; //这直接加不会超过一个窗的大小吗?

        /* Process the remaining IRQ busy time in the current window.  处理当前窗口中剩余的 IRQ 忙时间。*/
        delta = wallclock - window_start;
        rq->wrq.curr_runnable_sum = scale_exec_time(delta, rq);

        return;
    }

done:
    if (!is_idle_task(p))
        update_top_tasks(p, rq, old_curr_window, new_window, full_window);
}

值更新当前窗口和前一个窗口的busy时间,主要用于更新任务的: p->wts.curr_window、p->wts.curr_window_cpu[cpu],更新rq 的 rq->wrq.curr_runnable_sum、rq->wrq.prev_runnable_sum,若是一个walt认为的新任务,还更新 rq->wrq.nt_curr_runnable_sum、rq->wrq.nt_prev_runnable_sum。然后是更新 top-task 的一些成员

下面分别是对 task、cpu、top_tasks 维护的 window 进行更新。有一个新的窗口到来时更新,若更新时已经经历了一个或多个完整的window,那么对prev和curr window 相关的描述结构进行清理备用。

static u32 empty_windows[NR_CPUS];
/* 将 p->wts 的 curr_window 赋值给 prev_window,然后将 curr_window 清0 */
static void rollover_task_window(struct task_struct *p, bool full_window)
{
    u32 *curr_cpu_windows = empty_windows; //数组,每个cpu一个
    u32 curr_window;
    int i;

    /* Rollover the sum */
    curr_window = 0;

    /* 若经历了一个full_window, prev和curr window都清理待用 */
    if (!full_window) {
        curr_window = p->wts.curr_window;
        curr_cpu_windows = p->wts.curr_window_cpu;
    }

    p->wts.prev_window = curr_window;
    p->wts.curr_window = 0;

    /* Roll over individual CPU contributions 滚动每个 CPU 的贡献 */
    for (i = 0; i < nr_cpu_ids; i++) {
        p->wts.prev_window_cpu[i] = curr_cpu_windows[i];
        p->wts.curr_window_cpu[i] = 0;
    }

    if (is_new_task(p))
        p->wts.active_time += task_rq(p)->wrq.prev_window_size; //active_time 的唯一更新位置, walt认为的新任务
}

清理的是任务的 p->wts.prev_window_cpu、p->wts.curr_window、p->wts.prev_window_cpu[]、p->wts.curr_window_cpu[]

/*
 * rq的一些成员,prev_*_sum=curr_*_sum, 然后将 curr_*_sum 赋值为0,将curr赋值给prev,
 * 若是有经历了多个窗口curr和prev窗口都需要清理待用。
 */
static void rollover_cpu_window(struct rq *rq, bool full_window)
{
    u64 curr_sum = rq->wrq.curr_runnable_sum;
    u64 nt_curr_sum = rq->wrq.nt_curr_runnable_sum;
    u64 grp_curr_sum = rq->wrq.grp_time.curr_runnable_sum;
    u64 grp_nt_curr_sum = rq->wrq.grp_time.nt_curr_runnable_sum;

    if (unlikely(full_window)) {
        curr_sum = 0;
        nt_curr_sum = 0;
        grp_curr_sum = 0;
        grp_nt_curr_sum = 0;
    }

    rq->wrq.prev_runnable_sum = curr_sum;
    rq->wrq.nt_prev_runnable_sum = nt_curr_sum;
    rq->wrq.grp_time.prev_runnable_sum = grp_curr_sum;
    rq->wrq.grp_time.nt_prev_runnable_sum = grp_nt_curr_sum;

    rq->wrq.curr_runnable_sum = 0;
    rq->wrq.nt_curr_runnable_sum = 0;
    rq->wrq.grp_time.curr_runnable_sum = 0;
    rq->wrq.grp_time.nt_curr_runnable_sum = 0;
}

清理的是 rq->wrq 的 和 rq->wrq.grp_time 的 prev_runnable_sum、curr_runnable_sum、nt_prev_runnable_sum、nt_curr_runnable_sum

static void rollover_top_tasks(struct rq *rq, bool full_window)
{
    /* 跟踪的是2个,构成一个环形数组 */
    u8 curr_table = rq->wrq.curr_table;
    u8 prev_table = 1 - curr_table;
    int curr_top = rq->wrq.curr_top;

    /*将prev window的数据结构清理后待用*/
    clear_top_tasks_table(rq->wrq.top_tasks[prev_table]); //memset(arg, 0, NUM_LOAD_INDICES * sizeof(u8));
    clear_top_tasks_bitmap(rq->wrq.top_tasks_bitmap[prev_table]);//将bit数组的内容清0,然后将 NUM_LOAD_INDICES bit设置为1

    /*若是已经经历了多个window,那么之前标记的curr window也是旧窗口了,需要清理待用*/
    if (full_window) {
        curr_top = 0;
        clear_top_tasks_table(rq->wrq.top_tasks[curr_table]);
        clear_top_tasks_bitmap(rq->wrq.top_tasks_bitmap[curr_table]);
    }

    /*两个window的下标进行翻转,curr-->prev,prev-->curr*/
    rq->wrq.curr_table = prev_table;
    rq->wrq.prev_top = curr_top;
    rq->wrq.curr_top = 0;
}

清理的是 rq->wrq 的 top_task 相关的成员。

然后调用 account_busy_for_cpu_time 判断清理后任务的和cpu的是否还需要更新上去

/* update_cpu_busy_time-->this, 传参(rq, p, irqtime, event) */
static int account_busy_for_cpu_time(struct rq *rq, struct task_struct *p, u64 irqtime, int event)
{
    if (is_idle_task(p)) {
        /* TASK_WAKE && TASK_MIGRATE is not possible on idle task!  idle task不可能出现唤醒和迁移 */
        if (event == PICK_NEXT_TASK)
            return 0;

        /* PUT_PREV_TASK, TASK_UPDATE && IRQ_UPDATE are left */
        return irqtime || cpu_is_waiting_on_io(rq);
    }

    if (event == TASK_WAKE)
        return 0;

    if (event == PUT_PREV_TASK || event == IRQ_UPDATE)
        return 1;

    /*
     * TASK_UPDATE can be called on sleeping task, when its moved between related groups
     * TASK_UPDATE 当它在相关组之间移动时可能在睡眠的任务上调用,
     */
    if (event == TASK_UPDATE) {
        if (rq->curr == p)
            return 1;

        return p->on_rq ? SCHED_FREQ_ACCOUNT_WAIT_TIME : 0; //在rq上和或正在迁移是1,但是冒号前后都是0
    }

    /* TASK_MIGRATE, PICK_NEXT_TASK left */
    return SCHED_FREQ_ACCOUNT_WAIT_TIME; //0
}

top_task 维护的窗口更新:

/* 
 * update_cpu_busy_time-->this 若p不是idle任务,就调用,传参(p, rq, old_curr_window, new_window, full_window) 
 * @ old_curr_window:取自 p->wts.curr_window,表示p在窗口翻转前在当前窗口的运行时间
 * @ new_window:bool值,若ms<ws为真
 * @ full_window:bool值,若ws-ms>window_size为真
 */
static void update_top_tasks(struct task_struct *p, struct rq *rq, u32 old_curr_window, int new_window, bool full_window)
{
    /* 只使用两个窗口进行跟踪,当前是0,perv就是1,当前是1,prev就是0,两个数据结构构成一个环形缓存区 */
    u8 curr = rq->wrq.curr_table;
    u8 prev = 1 - curr;
    u8 *curr_table = rq->wrq.top_tasks[curr];
    u8 *prev_table = rq->wrq.top_tasks[prev];
    int old_index, new_index, update_index;
    u32 curr_window = p->wts.curr_window;
    u32 prev_window = p->wts.prev_window;
    bool zero_index_update;

    /* 两个窗的运行时间相等或新窗口还没有到来 */
    if (old_curr_window == curr_window && !new_window)
        return;

    /* 在一个窗中运行的时间越长,index就越大, 参数是一个窗口中的运行时长*/
    old_index = load_to_index(old_curr_window);
    new_index = load_to_index(curr_window);

    if (!new_window) {
        zero_index_update = !old_curr_window && curr_window;
        if (old_index != new_index || zero_index_update) {
            if (old_curr_window)
                curr_table[old_index] -= 1; //上一个窗口的累计值衰减
            if (curr_window)
                curr_table[new_index] += 1; //新窗口的累计值增加
            if (new_index > rq->wrq.curr_top)
                rq->wrq.curr_top = new_index; //更新rq->wrq.curr_top成员
        }

        if (!curr_table[old_index])
            __clear_bit(NUM_LOAD_INDICES - old_index - 1, rq->wrq.top_tasks_bitmap[curr]); //这个bit数组表示此运行时间下有没有计数值

        if (curr_table[new_index] == 1)
            __set_bit(NUM_LOAD_INDICES - new_index - 1, rq->wrq.top_tasks_bitmap[curr]);

        return;
    }
    /*---下面是new_window!=0的情况了---*/

    /*
     * 对于此任务来说窗口已经翻转。 当我们到达这里时,curr/prev 交换已经发生。 
     * 所以我们需要对新索引使用 prev_window 。
     */
    update_index = load_to_index(prev_window);

    if (full_window) { //至少有一个满窗
        /*
         * 这里有两个案例。 要么'p' 运行了整个窗口,要么根本不运行。 在任何一种情况下,
         * prev 表中都没有条目。 如果 'p' 运行整个窗口,我们只需要在 prev 表中创建一个
         * 新条目。 在这种情况下,update_index 将对应于 sched_ravg_window,因此我们可
         * 以无条件地更新顶部索引。
         */
        if (prev_window) {
            prev_table[update_index] += 1;
            rq->wrq.prev_top = update_index;
        }

        if (prev_table[update_index] == 1)
            __set_bit(NUM_LOAD_INDICES - update_index - 1, rq->wrq.top_tasks_bitmap[prev]);
    } else { //产生了新窗口,但是还没达到一个满窗
        zero_index_update = !old_curr_window && prev_window;
        if (old_index != update_index || zero_index_update) {
            if (old_curr_window)
                prev_table[old_index] -= 1;

            prev_table[update_index] += 1;

            if (update_index > rq->wrq.prev_top)
                rq->wrq.prev_top = update_index;

            /* 减为0是清理对应bit,首次设置为1时设置相应bit。top_tasks_bitmap[]在任务迁移时有使用 */
            if (!prev_table[old_index])
                __clear_bit(NUM_LOAD_INDICES - old_index - 1, rq->wrq.top_tasks_bitmap[prev]);
            if (prev_table[update_index] == 1)
                __set_bit(NUM_LOAD_INDICES - update_index - 1, rq->wrq.top_tasks_bitmap[prev]);
        }
    }

    if (curr_window) {
        curr_table[new_index] += 1;

        if (new_index > rq->wrq.curr_top)
            rq->wrq.curr_top = new_index;

        if (curr_table[new_index] == 1)
            __set_bit(NUM_LOAD_INDICES - new_index - 1, rq->wrq.top_tasks_bitmap[curr]);
    }
}

top_tasks 的维护中也使用到了桶,新窗运行时间对应的 curr_table[]成员加1,之前窗口运行时间对应的 prev_table[] 成员减1。

9. update_task_pred_demand 函数

/*
 * 在窗口翻转时计算任务的预测需求。如果任务当前窗口繁忙时间超过预测需求,则在此处更新以反映任务需求。
 */
void update_task_pred_demand(struct rq *rq, struct task_struct *p, int event)
{
    u32 new, old;
    u16 new_scaled;

    if (!sched_predl) //1
        return;

    if (is_idle_task(p))
        return;

    if (event != PUT_PREV_TASK && event != TASK_UPDATE &&
            (!SCHED_FREQ_ACCOUNT_WAIT_TIME || (event != TASK_MIGRATE && event != PICK_NEXT_TASK)))
        return;

    /*
     * 当它在相关组之间移动时,TASK_UPDATE 可以在睡眠任务上调用。
     */
    if (event == TASK_UPDATE) {
        if (!p->on_rq && !SCHED_FREQ_ACCOUNT_WAIT_TIME)
            return;
    }

    new = calc_pred_demand(p);
    old = p->wts.pred_demand;

    if (old >= new)
        return;
    /*---下面就是 new > old 的情况---*/

    new_scaled = scale_demand(new); //new/window_size*1024
    /* p是on rq的状态并且不是已经被throttle的deadline任务 */
    if (task_on_rq_queued(p) && (!task_has_dl_policy(p) || !p->dl.dl_throttled))
        fixup_walt_sched_stats_common(rq, p, p->wts.demand_scaled, new_scaled);

    p->wts.pred_demand = new;
    p->wts.pred_demand_scaled = new_scaled;
}

注意,这里再次调用了 fixup_walt_sched_stats_common,在 walt_update_task_ravg 函数中,在 update_history 中已经调用过一次,进入条件也相同,也是p在队列上。

static inline u32 calc_pred_demand(struct task_struct *p)
{
    /* 预测的需求比当前窗口的大,就返回预测的需求 */
    if (p->wts.pred_demand >= p->wts.curr_window)
        return p->wts.pred_demand;

    return get_pred_busy(p, busy_to_bucket(p->wts.curr_window), p->wts.curr_window);
}

get_pred_busy 和 busy_to_bucket 两个函数上面都有列出。

10. run_walt_irq_work 函数

static inline void run_walt_irq_work(u64 old_window_start, struct rq *rq) //walt.c
{
    u64 result;

    /*若是还是同一个窗,直接退出*/
    if (old_window_start == rq->wrq.window_start)
        return;

    /* 
     * atomic64_cmpxchg(*ptr, old, new) 函数功能是:将old和ptr指向的内容比较,如果相等,
     * 则将new写入到ptr指向的内存中,并返回old,如果不相等,则返回ptr指向的内容。
     */
    result = atomic64_cmpxchg(&walt_irq_work_lastq_ws, old_window_start, rq->wrq.window_start);
    if (result == old_window_start) {
        walt_irq_work_queue(&walt_cpufreq_irq_work); //触发回调 walt_irq_work(),构成一个"内核线程",循环往复执行

        trace_walt_window_rollover(rq->wrq.window_start);
    }
}

walt_irq_work_queue 会触发 walt_irq_work() 被调用,这个函数中又会调用 walt_update_task_ravg,walt_update_task_ravg 函数会在每个tick中调用,这里这样实现可能是针对没有tick的场景使用。

其中Trace:

trace_walt_window_rollover(rq->wrq.window_start);

参数原型:

(u64 window_start)

打印内容:

//20ms间隔执行一次
<idle>-0     [002] d..2 48262.320451: walt_window_rollover: window_start=48262548000001
<idle>-0     [001] d.h2 48262.340457: walt_window_rollover: window_start=48262568000001

字段解析:

window_start 就是打印 rq->wrq.window_start 的记录的时间值,单位是ns.

四、总结

    WALT负载计算算法是基于窗口的,对window有一个rollover的操作,只跟踪curr和prev两个窗口,curr窗口的下标由 wrq.curr_table 指向,两个窗口构成一个唤醒缓冲区,prev和curr进行不断切换。
    walt_update_task_ravg 函数通过其 event 成员决定对哪些事件计算负载,再根据其调用路径和其调用函数对是否是在rq上,是否是p=rq->curr可以判断统计的是哪部分的负载。
    预测负载这块,对于任务和CPU都使用了桶,任务是10个桶,对于cpu的curr和prev两个窗口分别是1000个成员,命中累加,不命中衰减。
    walt_update_task_ravg 在tick的调用路径中有调用,应该是为了无tick情况下walt仍然能正常工作,使用irq_work构成一个内核线程以一个窗口的周期来更新窗口。

五、补充

    task util的获取:task_util() WALT: p->wts.demand_scaled;PELT: p->se.avg.util_avg
    cpu util的获取:cpu_util_cum() WALT: rq->wrq.cum_window_demand_scaled;PELT: rq->cfs.avg.util_avg
    task_util() 函数


static inline unsigned long task_util(struct task_struct *p)
{
#ifdef CONFIG_SCHED_WALT
    if (likely(!walt_disabled && sysctl_sched_use_walt_task_util))
        return p->wts.demand_scaled;
#endif
    return READ_ONCE(p->se.avg.util_avg);
}

#23 进程模块 » Gentoo 之 NUMA 多核架构中的多线程调度开销与性能优化 » 2024-03-26 21:12:51

batsom
回复: 0

NOTE:本文中所指 “线程” 均为可执行调度单元 Kernel Thread。
NUMA 体系结构

NUMA(Non-Uniform Memory Access,非一致性存储器访问)的设计理念是将 CPU 和 Main Memory 进行分区自治(Local NUMA node),又可以跨区合作(Remote NUMA node),以这样的方式来缓解单一内存总线存在的瓶颈。

FluxBB bbcode 测试

不同的 NUMA node 都拥有几乎相等的资源,在 Local NUMA node 内部会通过自己的存储总线访问 Local Memory,而 Remote NUMA node 则可以通过主板上的共享总线来访问其他 Node 上的 Remote Memory。

显然的,CPU 访问 Local Memory 和 Remote Memory 所需要的耗时是不一样的,所以 NUMA 才得名为 “非一致性存储器访问"。同时,因为 NUMA 并非真正意义上的存储隔离,所以 NUMA 同样只会保存一份操作系统和数据库系统的副本。也就是说,默认情况下,耗时的远程访问是很可能存在的。

这种做法使得 NUMA 具有一定的伸缩性,更加适合应用在服务器端。但也由于 NUMA 没有实现彻底的主存隔离,所以 NUMA 的扩展性也是有限的,最多可支持几百个 CPU/Core。这是为了追求更高的并发性能所作出的妥协。

FluxBB bbcode 测试

基本对象概念

    Node(节点):一个 Node 可以包含若干个 Socket,通常是一个。
    Socket(插槽):一颗物理处理器 SoC 的封装。
    Core(核心):一个 Socket 封装的若干个物理处理器核心(Physical processor)。
    Hyper-Thread(超线程):每个 Core 可以被虚拟为若干个(通常为 2 个)逻辑处理器(Virtual processors)。逻辑处理器会共享大多数物理处理器资源(e.g. 内存缓存、功能单元)。
    Processor(逻辑处理器):操作系统层面的 CPU 逻辑处理器对象。
    Siblings:操作系统层面的 Physical processor 和下属 Virtual processors 之间的从属关系。

下图所示为一个 NUMA Topology,表示该服务器具有 2 个 Node,每个 Node 含有一个 Socket,每个 Socket 含有 6 个 Core,每个 Core 又被超线程为 2 个 Thread,所以服务器总共的 Processor = 2 x 1 x 6 x 2 = 24 颗,其中 Siblings[0] = [cpu0, cpu1]。
FluxBB bbcode 测试

查看 Host 的 NUMA Topology

#!/usr/bin/env python
# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2010-2014 Intel Corporation
# Copyright(c) 2017 Cavium, Inc. All rights reserved.

from __future__ import print_function
import sys
try:
    xrange # Python 2
except NameError:
    xrange = range # Python 3

sockets = []
cores = []
core_map = {}
base_path = "/sys/devices/system/cpu"
fd = open("{}/kernel_max".format(base_path))
max_cpus = int(fd.read())
fd.close()
for cpu in xrange(max_cpus + 1):
    try:
        fd = open("{}/cpu{}/topology/core_id".format(base_path, cpu))
    except IOError:
        continue
    except:
        break
    core = int(fd.read())
    fd.close()
    fd = open("{}/cpu{}/topology/physical_package_id".format(base_path, cpu))
    socket = int(fd.read())
    fd.close()
    if core not in cores:
        cores.append(core)
    if socket not in sockets:
        sockets.append(socket)
    key = (socket, core)
    if key not in core_map:
        core_map[key] = []
    core_map[key].append(cpu)

print(format("=" * (47 + len(base_path))))
print("Core and Socket Information (as reported by '{}')".format(base_path))
print("{}\n".format("=" * (47 + len(base_path))))
print("cores = ", cores)
print("sockets = ", sockets)
print("")

max_processor_len = len(str(len(cores) * len(sockets) * 2 - 1))
max_thread_count = len(list(core_map.values())[0])
max_core_map_len = (max_processor_len * max_thread_count)  \
                      + len(", ") * (max_thread_count - 1) \
                      + len('[]') + len('Socket ')
max_core_id_len = len(str(max(cores)))

output = " ".ljust(max_core_id_len + len('Core '))
for s in sockets:
    output += " Socket %s" % str(s).ljust(max_core_map_len - len('Socket '))
print(output)

output = " ".ljust(max_core_id_len + len('Core '))
for s in sockets:
    output += " --------".ljust(max_core_map_len)
    output += " "
print(output)

for c in cores:
    output = "Core %s" % str(c).ljust(max_core_id_len)
    for s in sockets:
        if (s,c) in core_map:
            output += " " + str(core_map[(s, c)]).ljust(max_core_map_len)
        else:
            output += " " * (max_core_map_len + 1)
    print(output)

OUTPUT:

$ python cpu_topo.py
======================================================================
Core and Socket Information (as reported by '/sys/devices/system/cpu')
======================================================================

cores =  [0, 1, 2, 3, 4, 5]
sockets =  [0, 1]

       Socket 0    Socket 1
       --------    --------
Core 0 [0]         [6]
Core 1 [1]         [7]
Core 2 [2]         [8]
Core 3 [3]         [9]
Core 4 [4]         [10]
Core 5 [5]         [11]

上述输出的意义:

    有两个 Socket(物理 CPU)
    每个 Socket 有 6 个 Core(物理核),总计 12 个

Output:

$ python cpu_topo.py
======================================================================
Core and Socket Information (as reported by '/sys/devices/system/cpu')
======================================================================

cores =  [0, 1, 2, 3, 4, 5]
sockets =  [0, 1]

       Socket 0        Socket 1
       --------        --------
Core 0 [0, 12]         [6, 18]
Core 1 [1, 13]         [7, 19]
Core 2 [2, 14]         [8, 20]
Core 3 [3, 15]         [9, 21]
Core 4 [4, 16]         [10, 22]
Core 5 [5, 17]         [11, 23]

    有两个 Socket(物理 CPU)。
    每个 Socket 有 6 个 Core(物理核),总计 12 个。
    每个 Core 有两个 Virtual Processor,总计 24 个。

NUMA 架构中的多线程性能开销
1、跨 Node 的 Memory 访问开销

NUMA(非一致性存储器访问)的意思是 Kernel Thread 访问 Local Memory 和 Remote Memory 所需要的耗时是不一样的。

NUMA 的 CPU 分配策略有下 2 种:

    cpu-node-bind:约束 Kernel Thread 运行在指定的若干个 NUMA Node 上。
    phys-cpu-bind:约束 Kernel Thread 运行在指定的若干个 CPU Core 上。

NUMA 的 Memory 分配策略有下列 4 种:

    local-alloc:约束 Kernel Thread 只能访问 Local Node Memory。
    preferred:宽松地为 Kernel Thread 指定一个优先 Node,如果优先 Node 上没有足够的 Memory 资源,则允许运行在访问 Remote Node Memory。
    mem-bind:规定 Kernel Thread 只能请求指定的若干个 Node 上的 Memory,但并不严格规定只能访问 Local NUMA Memory。
    inter-leave:规定 Kernel Thread 可以使用 RR 算法轮转地从指定的若干个 Node 上请求访问 Memory。

2、跨 Core 的多线程 Cache 同步开销

NUMA Domain Scheduler 是 Kernel 针对 NUMA 体系架构实现的 Kernel Thread 调度器,目的是为了让 NUMA 中的每个 Core 都尽量均衡的忙碌。

根据 NUMA Topology 的特性呈一颗树状结构。NUMA Domain Scheduling,从叶节点向上根节点遍历,直到所有的 NUMA Domain 中的负载都是均衡的。当然,用户可以对不同的 Domain 设置相应的调度策略。

FluxBB bbcode 测试

但这种针对所有 Cores 的均衡优化是有代价的,比如:将同一个 User Process 对应若干个 Kernel Thread 均衡到不同的 Cores 上执行,会使得 Core Cache 失效,造成性能下降。

    Cache 可见性(并发安全)问题:分别在 Core1 和 Core2 上运行的 Kernel Thread 都会在各自的 L1/L2 Cache 中缓存数据,但这些数据对彼此是不可见的,即:如果在 Core1 不将 Cache 中的数据写回到 Main Memory 的前提下,Core2 永远看不见 Core1 对某个变量数值的修改。继而会导致多线程共享数据不一致的情况。
    Cache 一致性(并发性能)问题:如果多个 Kernel Thread 运行在多个 Cores 上,同时这些 Threads 之间存在共享数据,而这些数据有存储在 Cache 中,那么就存在 Cache 一致性数据同步的必要。例如:分别在 Core1 和 Core2 上运行的 Kernel Thread 希望保证共享数据是一致的,那么就需要强制性的将 Core1 Cache 中对变量数值的修改写回到 Main Memory,然后 Core1 通知 Core2 数值更新了,再让 Core2 从 Main Memory 获取到最新的数值,并加载到 Core2 Cache 中。为了维护 Cache 数据的一致性所产生的流量会为主存数据总线带来压力,继而影响到 CPU 的性能。
    Cache 失效性(并发性能)问题:如果出于均衡的考虑,调度器会主动出发线程切换,例如:将在 Core1 上运行的 Kernel Thread 动态的调度到另一个空闲的 Core2 上运行,那么在 Core1 Cache 上的数据就需要先写回到 Memory,然后再进行调度。如果此时 Core1 和 Core2 分属于不同的 NUMA Node,那么就会出现更加耗时的 Remote Memory 访问。

FluxBB bbcode 测试

如下图所示,在不同的 Domain 中存在着不同的 Cache 成本。虽然 NUMA Domain Scheduling 自身也具有软亲和特性,但其到底是侧重于 NUMA Cores 的均衡调度,而不是保证应用程序的执行性能。

可见,NUMA Domain Scheduler 的均衡调度机制和高并发性能是相悖的。
FluxBB bbcode 测试

3、多线程上下文切换开销

在 Core 执行任务期间,需要将线程的执行现场信息存储在 Core 的 Register 和 Cache 中,这些数据集称为 Context(上下文),有下列 3 种类型:

    User Level Context:PC 程序计数器、寄存器、线程栈等。
    Register Context:通用寄存器、PC 程序寄存器、处理器状态寄存器、栈指针等。
    Kernel Level Context:进程描述符(task_struct)、PC 程序计数器、寄存器、虚拟地址空间等。

多线程的 Context Switch(上下文切换)也可以分为 2 个层面:

    User Level Thread 层面:由高级编程语言线程库实现的 Multiple User Threads,在单一个 Core 上进行时间分片轮训被动切换,或协作式自动切换。由于 User Thread 的 User Level Context 非常轻量,且共享同一个 User Process 的虚拟地址空间,所以 User Level 层面的 Context Switch 开销小,速度快。
    Kernel Level Thread 层面:Multiple Kernel Threads 由 Kernel 中的 NUMA Domain Scheduler 在多个 Cores 上进行调度和切换。由于 Kernel Thread 的 Context 更大(Kernel Thread 执行现场,包括:task_struct 结构体、寄存器、程序计数器、线程栈等),且涉及跨 Cores 之间的数据同步和主存访问,所以 Kernel Level 层面的 Context Switch 开销大,速度慢。

进行线程切换的过程中,首先会将一个线程的 Context 存储在相应的用户或内核堆栈中,然后把下一个要运行的线程的 Context 加载到 Core 的 Register 和 Cache 中。

FluxBB bbcode 测试

可见,多线程的 Context Switch 势必会导致处理器性能的下降。并且 User Level 和 Kernel Level 切换很可能是同时出现的,这些都是应用多线程模式所需要付出的代价。

使用 vmstat 指令查看当前系统的上下文切换情况:

$ vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  1      0 4505784 313592 7224876    0    0     0    23    1    2  2  1 94  3  0

    r:CPU 运行队列的长度和正在运行的线程数。
    b:正在阻塞的进程数。
    swpd:虚拟内存已使用的大小,如果大于 0,表示机器的物理内存不足了。如果不是程序内存泄露的原因,那么就应该升级内存或者把耗内存的任务迁移到其他机器上了。
    si:每秒从磁盘读入虚拟内存的大小,如果大于 0,表示物理内存不足或存在内存泄露,应该杀掉或迁移耗内存大的进程。
    so:每秒虚拟内存写入磁盘的大小,如果大于 0,同上。
    bi:块设备每秒接收的块数量,这里的块设备是指系统上所有的磁盘和其他块设备,默认块大小是 1024Byte。
    bo:块设备每秒发送的块数量,例如读取文件时,bo 就会大于 0。bi 和 bo 一般都要接近 0,不然就是 I/O 过于频繁,需要调整。
    in:每秒 CPU 中断的次数,包括时间中断。
    cs:每秒上下文切换的次数,这个值要越小越好,太大了,要考虑减少线程或者进程的数目。上下文切换次数过多表示 CPU 的大部分时间都浪费在上下文切换了而不是在执行任务。
    st:CPU 在虚拟化环境上在其他租户上的开销。

4、CPU 运行模式切换开销

CPU 运行模式切换同样会对执行性能造成影响,不过相对于上下文切换会更低一些,因为模式切换最主要的任务只是切换线程寄存器的上下文。

Linux 系统中的以下操作会触发 CPU 运行模式切换:

    系统调用 / 软中断:当应用程序需要访问 Kernel 资源时,需要通过 SCI 进入内核模式执行相应的内核代码,完成所需操作后再返回到用户模式。
    中断处理:当外设发生中断事件时,会向 CPU 发出中断信号,此时 Kernel 需要立即响应中断,进入内核模式执行相应的中断处理程序,处理完后再返回用户模式。
    异常处理:当 Kernel 出现运行时错误或其他异常情况,如:页错误、除零错误、非法操作等,操作系统需要进入内核模式执行相应的异常处理程序,进行错误恢复或提示,然后再返回用户模式。
    Kernel Thread 切换:当 User Process 下属的 Kernel Thread 进行切换时,首先需要切换相应的 Kernel Level Context 并执行,最后再返回用户模式下执行 User Process 的代码。

FluxBB bbcode 测试

5、中断处理的开销

硬件中断(HW Interrupt)是一种外设(e.g. 网卡、磁盘控制器、鼠键、串行适配卡等)和 CPU 交互通信的机制,让 CPU 能够及时掌握外设发生的事件,并视乎于中断的类型来决定是否放下当前任务,尽快处理紧急的外设事件(e.g. 以太网数据帧到达,键盘输入)。

硬件中断的本质是一个 IRQ(中断请求信号)电信号。Kernel 为每个外设分配了一个 IRQ Number,以此来区分发出中断的设备类型。IRQ Number 又会映射到 Kernel ISR(中断服务路由列表)中的一个中断处理程序(通常又外设驱动提供)。

硬件中断是 Kernel 调度优先级最高的任务类型之一,进行抢占式调度,所以硬件中断通常都伴随着任务切换,将当前任务切换到中断处理程序的上下文。

一次中断处理,首先需要将 CPU 的状态寄存器数据保存到虚拟内存空间中的堆栈,然后运行中断服务程序,最后再将状态寄存器数据从堆栈中夹在到 CPU。整个过程需要至少 300 个 CPU 时钟周期。并且在多核处理器计算平台中,每个 Core 都有可能执行硬件中断处理程序,所以还存在着跨 Core 处理要面对的 Cache 一致性流量的问题。

可见,大量的中断处理,尤其是硬件中断处理会非常消耗 CPU 资源。
6、TLB 缓存失效的开销

因为 TLB(地址映射表高速缓存)的空间非常有限,在使用 4K 小页的操作系统中,出现 Kernel Thread 频繁切换时,会导致 TLB 缓存的虚拟地址空间映射条目频繁变更,产生大量的缓存缺失。
7、内存拷贝的开销

在网络报文处理场景中,NIC Driver 运行在内核态,当 Driver 收到的报文后,首先会拷贝到 TCP/IP Stack 处理,然后再拷贝到用户空间的应用程序缓冲区。这些拷贝处理的时间会占报文处理总时长的 57.1%。
NUMA 架构中的性能优化:使用多核编程代替多线程

为了解决上述问题,在 NUMA 架构中进一步提升多核处理器平台的性能,应该广泛采用 “多核编程代替多线程编程” 的思想,通过将 Kernel Threrad 与 NUMA Node 或 Core 建立亲和性,以此来避免多线程调度带来的开销。
NUMA 亲和性:避免 CPU 跨 NUMA 访问内存

在 Linux Shell 上,可以使用 numastat 指令来查看 NUMA Node 的内存分配统计数据;可以使用 numactl 指令可以将 User Process 绑定到指定的 NUMA Node,还可以绑定到指定的 NUMA Core 上。
CPU 亲和性:避免跨 CPU Cores 的 Kernel Thread 切换

CPU 亲和性(CPU Affinity)是 Kernel 的一种 Kernel Thread 调度属性(Scheduling Property),指定 Kernel Thread 要在特定的 CPU 上尽量长时间地运行而不被调度到其他的 CPU 上。在 NUMA 架构中,设置 Kernel Thread 的 CPU 亲和性,能够有效提高 Thread 的 CPU Cache 命中率,减少 Remote NUMA Memory 访问的损耗,以获得更高的性能。

    软 CPU 亲和性:是 Linux Scheduler 的默认调度策略,调度器会积极的让 Kernel Thread 在同一个 CPU 上运行。
    硬 CPU 亲和性:是 Linux Kernel 提供的可编程 CPU 亲和性,用户程序可以显式地指定 User Process 对应的 Kernel Thread 在哪个或哪些 CPU 上运行。

硬 CPU 亲和性通过扩展 task_struct(进程描述符)结构体来实现,引入 cpus_allowed 字段来表示 CPU 亲和位掩码(BitMask)。cpus_allowed 由 n 位组成,对应系统中的 n 个 Processor。最低位表示第一个 Processor,最高位表示最后一个 Processor,通过对掩码位置 1 来指定 Processors 亲和,当有多个掩码位被置 1 时表示运行进程在多个 Processor 间迁移,缺省为全部位置 1。进程的 CPU 亲和特性会传递给子线程。

在 Linux Shell 上,可以使用 taskset 指令来设定 User Process 的 CPU 亲和性,但不能保证 NUMA 亲和性的内存分配。
IRQ(中断请求)亲和性

Linux Kernel 提供了 irqbalance 程序来进行中断负载优化,在大部分场景中,irqbalance 提供的中断分配优化都是可以起到积极作用的,irqbalance 会自动收集系统数据来分析出使用模式,并依据系统负载状况将工作状态调整为以下 2 种模式:

    Performance mode:irqbalance 会将中断尽可能均匀地分发给各个 CPU 的 Core,以充分提升性能。
    Power-save mode:irqbalance 会将中断处理集中到第一个 CPU,保证其它空闲 CPU 的睡眠时间,降低能耗。

当然,硬件中断处理也具有亲和性属性,用于指定运行 IRP 对应的 ISR 的 CPU。在 Linux Shell 上,可以修改指定 IRQ Number 的 smp_affinity。注意,手动指定 IRQ 亲和性首先需要关闭 irqbalance 守护进程。

#24 进程模块 » Gentoo 实现原理 — 进程调度与策略配置 » 2024-03-25 22:32:59

batsom
回复: 0

进程调度

进程调度,即 Linux Kernel Scheduler 如何将多个 User Process 调度给 CPU 执行,从而实现多任务执行环境的公平竞争以及合理分配 CPU 资源。

在古早的单核环境中,Linux Scheduler 的主要目的是通过 "时间片轮转算法" 和 “优先级调度算法“ 来实现调度。而在现代多核环境中,Linux Scheduler 则需要考虑更多的复杂因素,如:CPU 负载均衡、Cache 亲和性、多核互斥等。所以本文主要讨论的是多核环境中的进程调度。

为了应对不同应用场景中的进程调度需求,Linux Kernel 实现了多种 Scheduler 类型,常见的有:

    CFS(Completely Fair Scheduler,完全公平调度器)
    RT(Real-time Scheduler,实时调度器)
    DS(Deadline Scheduler,最后期限调度器)

FluxBB bbcode 测试

这些 Scheduler 会被作用于每个 CPU Cores 的 “就绪队列“ 中,且具有各自不同的调度算法和优先级策略。

FluxBB bbcode 测试

在操作系统层面用户可以操作的只有用户进程实体,所以我们能够看见并使用的大多数调度配置都是针对 User Process 而言。

如下图,Kernel 将进程分为 2 大类型,对应各自的优先级区域以及不同的调度算法。

    实时进程:具有实时性要求,共有 0~99 这 100 个优先级。
    普通进程:没有实时性要求,共有 100~139 这 40 个级别。

FluxBB bbcode 测试

但实际上,实时进程的优先级是初设后不可更改的。也就是说,从系统管理员的角度(Shell)只能配置普通进程的优先级。

针对普通进程的优先级配置,Linux 引入了 Nice level 的设计,Nice 值的范围是 -20~19 刚好对应到普通进程的 40 个优先级。其中,普通用户可以配置的范围是 0~19,而 Root 管理员则可以配置 -20~19。

FluxBB bbcode 测试

CFS 完全公平调度器

Linux CFS(Completely Fair Scheduler,完全公平调度器)是 Kernel 默认使用的进程调度器,常用于通用计算场景。

CFS 的 “完全公平“ 并不是简单粗暴的按照 CPU 时间片大小来进行调度,而是会根据进程的运行时间来计算出优先级,运行时间较短的进程会拥有更高的优先级,从而保证了每个进程都能够获得公平的 CPU 时间。

具体而言,CFS 是一种基于红黑树的调度算法,它的目标是让所有进程都可以获得相同的 CPU 时间片。实现原理如下:

    CFS 在每个 CPU 上都有一棵红黑树,每个节点对应一个普通进程的 PCB(task_struct)和一个 Key。这个 Key 是进程的一个 VRT(虚拟运行时间),反应了进程在 CPU 上的运行时间。运行时间越长,VRT 就越大,优先级就越小。
    当一个新的普通进程被创建时,它会被加入到红黑树中,并且初始的 VRT 值为 0,表示拥有最高调度优先级。
    当 CPU 空闲时,就查询红黑树,并将 VRT 最小的就绪进程调度执行,完毕后增加其 VRT,降低其下次调度的优先级。

可见,CFS 的优点让每个进程都获得了公平的 CPU 时间。然而,CFS 的缺点是由于红黑树的操作复杂度较高,对于高并发的场景可能会影响系统的性能。

FluxBB bbcode 测试

SCHED_NORMAL(普通进程调度算法)

SCHED_NORMAL 是 CFS 的基本实现,采用了上文中提到的 “时间片轮转“ 和 “动态优先级“ 调度机制。

    动态优先级:普通进程具有一个 nice 值来表示其优先级,nice 值越小,进程优先级越高。
    时间片轮转:如果有多个普通进程的优先级相同,则采用轮流执行的方式。

SCHED_BATCH(批量调度算法)

SCHED_BATCH 是一种针对 CPU 密集型批处理作业的调度算法。它的主要目的是在系统空闲时间运行一些需要大量 CPU 时间的后台任务。

区别于 SCHED_NORMAL,它并不使用时间片轮转和动态优先级调度机制,而是采用了一种基于进程组的批量处理策略。该算法会将所有的后台任务进程加入到一个进程组中,该进程组会共享一个可调度时间片。

在 SCHED_BATCH 中,进程组会被赋予更高的优先级,以确保后台任务能够在系统空闲时间得到足够的 CPU 时间。
RTS 实时调度器

Linux RTS(Real-Time Scheduler,实时调度器)采用固定优先级调度算法,优先级高的进程会获得更多的 CPU 时间。RTS 是 RT-Kernel 的默认调度算法,常用于对实时性要求高的计算场景。

FluxBB bbcode 测试

RTS 的主要目的是保证实时任务的响应性和可预测性。固定优先级调度算法,总是可以让高优先级任务先运行,同时还实现了基于抢占的调度策略,以保证实时任务能够在预定的时间内运行完成。实现原理如下:

    RTS 优先级数值范围从 1(最高)~99(最低),其中 0 保留给 Kernel 使用。
    RTS 还实现了基于抢占的调度策略。当一个高优先级的任务到来时,它可以抢占当前正在运行的任务,并且直到运行完毕。
    RTS 使用了多队列的方法来管理实时进程。RTS 在每个 CPU 上维护 2 级就绪队列,一个是实时队列,一个是普通队列。并采用了不同的调度算法和优先级策略来进行调度。例如:实时进程采用 SCHED_FIFO 调度算法,普通进程采用 SCHED_RR。
    调度器每次选择下一个要运行的进程时,会先从实时队列中选择进程,如果实时队列为空,则从普通队列中选择进程。这样可以保证实时进程的优先级高于普通进程,同时也避免了实时进程长时间等待的情况。

RTS 的优点是能够保证实时任务的响应性和可预测性,但缺点是对于普通任务来说可能会出现长时间等待的情况。

FluxBB bbcode 测试

SCHED_FIFO(先到先服务调度算法)

SCHED_FIFO 调度算法会按照进程的提交顺序来分配 CPU 时间,当一个进程获得 CPU 时间后,它会一直运行直到完成或者被更高优先级的进程抢占。因此,该算法可能导致低优先级进程的饥饿情况,因为高优先级进程可能会一直占用 CPU 时间。
SCHED_RR(时间片轮转调度算法)

与 SCHED_FIFO 类似,SCHED_RR 调度算法也会按照进程的提交顺序来分配 CPU 时间。不同之处在于,每个进程都被赋予一个固定的时间片,当时间片用完后,该进程就会被放回就绪队列的尾部,等待下一次调度。该算法可以避免低优先级进程饥饿的问题,因为每个进程都能够获得一定数量的 CPU 时间,而且高优先级进程也不能一直占用 CPU 时间。
DS 最后期限调度器

Linux DS(Deadline Scheduling,最后期限调度器)是一种基于最后期限(Deadline)的调度器。实现原理如下:

    DS 与 CFS 类似的采用了红黑树,但主要区别在于 DS 的树节点 Key 是 Deadline 值,而不是 VRT。
    DS 为每个进程赋予一个 Deadline,DS 会按照进程的最后期限的顺序,安排进程的执行顺序。进程的最后期限越近,其优先级就越高。
    当 CPU 空闲时,就查询红黑树,并将 Deadline 离与当前时间最近的就绪进程调度执行。

FluxBB bbcode 测试

SCHED_DEADLINE(最后期限调度算法)

SCHED_DEADLINE 调度算法是 DS 调度器的默认调度算法,主要用于实时任务的调度。
进程调度策略的配置
ps 指令

我们在配置一个进程的调度策略之前,常常需要使用 ps 指令查看进程的状态信息。
查看进程资源使用信息

 $ ps aux

USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root          1  0.1  0.0  78088  9188 ?        Ss   04:26   0:03 /sbin/init maybe-ubiquity
...
stack      2152  0.1  0.8 304004 138160 ?       S    04:27   0:04 nova-apiuWSGI worker 1
stack      2153  0.1  0.8 304004 138212 ?       S    04:27   0:04 nova-apiuWSGI worker 2
...

FluxBB bbcode 测试

查看指定进程的 CPU 资源详细使用信息

$ pidstat -p 12285

02:53:02 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:53:02 PM     0     12285    0.00    0.00    0.00    0.00     5  python 

    PID:进程 ID。
    %usr:进程在用户态运行所占 CPU 的时间比率。
    %system:进程在内核态运行所占 CPU 的时间比率。
    %CPU:进程运行所占 CPU 的时间比率。
    CPU:进程在哪个核上运行。
    Command:创建进程对应的命令。

查看进程优先级信息

$ ps -le

F S   UID    PID   PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
4 S     0      1      0  0  80   0 - 19522 ep_pol ?        00:00:03 systemd
1 S     0      2      0  0  80   0 -     0 kthrea ?        00:00:00 kthreadd
1 I     0      4      2  0  60 -20 -     0 worker ?        00:00:00 kworker/0:0H
1 I     0      6      2  0  60 -20 -     0 rescue ?        00:00:00 mm_percpu_wq
... 

    UID:进程执行者 ID。
    PID:进程 ID。
    PPID:父进程 ID。
    PRI:进程优先级,值越小优先级越高。
    NI:进程的 nice 值。

查看系统中所有的实时进程

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
    7     7 FF      99   - 139   0  0.0 FF  S    smpboot_thread migration/0
   10    10 FF      99   - 139   0  0.0 FF  S    smpboot_thread watchdog/0
   11    11 FF      99   - 139   1  0.0 FF  S    smpboot_thread watchdog/1
   12    12 FF      99   - 139   1  0.0 FF  S    smpboot_thread migration/1

查看 nice 不为 0 的普通进程

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm|awk '$4 ~ /-/ &&$5 !~/0/ {print $0}'

   63    63 TS       -   5  14   2  0.0 TS  SN   ksm_scan_threa ksmd
   64    64 TS       -  19   0   2  0.0 TS  SN   khugepaged     khugepaged
12995 12995 TS       -  -4  23   1  0.0 TS  S<sl ep_poll        auditd

查看进程运行状态及其内核函数名称

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:34,nwchan,pcpu,comm

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN                               WCHAN %CPU COMMAND
    1     1 TS       -   0  19   4  0.0 TS  Ssl  ep_poll                            ffffff  0.0 systemd
    2     2 TS       -   0  19   0  0.0 TS  S    kthreadd                            b1066  0.0 kthreadd
    3     3 TS       -   0  19   0  0.0 TS  S    smpboot_thread_fn                   b905d  0.0 ksoftirqd/0
...
   44    44 TS       -   0  19   7  0.0 TS  R    -                                       -  0.0 kworker/7:0

    wchan:显示进程处于休眠状态的内核函数名称,如果进程正在运行则为 -,如果进程具有多线程且 ps 指令未显示,则为 *。
    nwchan:显示进程处于休眠状态的内核函数地址,正在运行的任务将在此列中显示短划线 -。

nice 指令

nice 指令用于修改普通进程的 nice 值。
设定即将启动的普通进程的 nice 值

nice -n -5 service httpd start

修改已经存在的普通进程的 nice 值

$ ps -le | grep nova-compute
4 S  1000  9301     1  2  80   0 - 530107 ep_pol ?       00:02:50 nova-compute

$ renice -10 9301
9301 (process ID) old priority 0, new priority -10

$ ps -le | grep nova-compute
4 S  1000  9301     1  2  70 -10 - 530107 ep_pol ?       00:02:54 nova-compute

chrt 指令

chrt 指令可用于修改进程的调度算法和优先级。

$ chrt --help
Show or change the real-time scheduling attributes of a process.

Set policy:
 chrt [options] <priority> <command> [<arg>...]
 chrt [options] --pid <priority> <pid>

Get policy:
 chrt [options] -p <pid>

Policy options:
 -b, --batch          set policy to SCHED_BATCH
 -d, --deadline       set policy to SCHED_DEADLINE
 -f, --fifo           set policy to SCHED_FIFO
 -i, --idle           set policy to SCHED_IDLE
 -o, --other          set policy to SCHED_OTHER
 -r, --rr             set policy to SCHED_RR (default)

修改进程的调度算法

$ chrt -r 10 bash

$ chrt -p $$
pid 13360's current scheduling policy: SCHED_RR
pid 13360's current scheduling priority: 10

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
13360 13360 RR      10   -  50   7  0.0 RR  S    do_wait        bash

修改实时进程的优先级

$ ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'

  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
   27    27 FF      99   - 139   4  0.0 FF  S    smpboot_thread migration/4

$ chrt -p 31
pid 31's current scheduling policy: SCHED_FIFO
pid 31's current scheduling priority: 99

$ chrt -f -p 50 31

$ chrt -p 31
pid 31's current scheduling policy: SCHED_FIFO
pid 31's current scheduling priority: 50

#25 内核模块 » Gentoo 之 RCU基础 » 2024-03-25 21:03:09

batsom
回复: 0

一、前言

关于RCU的文档包括两份,一份讲基本的原理(也就是本文了),一份讲linux kernel中的实现。第二章描述了为何有RCU这种同步机制,特别是在cpu core数目不断递增的今天,一个性能更好的同步机制是如何解决问题的,当然,再好的工具都有其适用场景,本章也给出了RCU的一些应用限制。第三章的第一小节描述了RCU的设计概念,其实RCU的设计概念比较简单,比较容易理解,比较困难的是产品级别的RCU实现,我们会在下一篇文档中描述。第三章的第二小节描述了RCU的相关操作,其实就是对应到了RCU的外部接口API上来。最后一章是参考文献,perfbook是一本神奇的数,喜欢并行编程的同学绝对不能错过的一本书,强烈推荐。和perfbook比起来,本文显得非常的丑陋(主要是有些RCU的知识还是理解不深刻,可能需要再仔细看看linux kernel中的实现才能了解其真正含义),除了是中文表述之外,没有任何的优点,英语比较好的同学可以直接参考该书。

二、为何有RCU这种同步机制呢?

前面我们讲了spin lock,rw spin lock和seq lock,为何又出现了RCU这样的同步机制呢?这个问题类似于问:有了刀枪剑戟这样的工具,为何会出现流星锤这样的兵器呢?每种兵器都有自己的适用场合,内核同步机制亦然。RCU在一定的应用场景下,解决了过去同步机制的问题,这也是它之所以存在的基石。本章主要包括两部分内容:一部分是如何解决其他内核机制的问题,另外一部分是受限的场景为何?

1、性能问题

我们先回忆一下spin lcok、RW spin lcok和seq lock的基本原理。对于spin lock而言,临界区的保护是通过next和owner这两个共享变量进行的。线程调用spin_lock进入临界区,这里包括了三个动作:

(1)获取了自己的号码牌(也就是next值)和允许哪一个号码牌进入临界区(owner)

(2)设定下一个进入临界区的号码牌(next++)

(3)判断自己的号码牌是否是允许进入的那个号码牌(next == owner),如果是,进入临界区,否者spin(不断的获取owner的值,判断是否等于自己的号码牌,对于ARM64处理器而言,可以使用WFE来降低功耗)。

注意:(1)是取值,(2)是更新并写回,因此(1)和(2)必须是原子操作,中间不能插入任何的操作。

线程调用spin_unlock离开临界区,执行owner++,表示下一个线程可以进入。

RW spin lcok和seq lock都类似spin lock,它们都是基于一个memory中的共享变量(对该变量的访问是原子的)。我们假设系统架构如下:

FluxBB bbcode 测试

当线程在多个cpu上争抢进入临界区的时候,都会操作那个在多个cpu之间共享的数据lock(玫瑰色的block)。cpu 0操作了lock,为了数据的一致性,cpu 0的操作会导致其他cpu的L1中的lock变成无效,在随后的来自其他cpu对lock的访问会导致L1 cache miss(更准确的说是communication cache miss),必须从下一个level的cache中获取,同样的,其他cpu的L1 cache中的lock也被设定为invalid,从而引起下一次其他cpu上的communication cache miss。

RCU的read side不需要访问这样的“共享数据”,从而极大的提升了reader侧的性能。

2、reader和writer可以并发执行

spin lock是互斥的,任何时候只有一个thread(reader or writer)进入临界区,rw spin lock要好一些,允许多个reader并发执行,提高了性能。不过,reader和updater不能并发执行,RCU解除了这些限制,允许一个updater(不能多个updater进入临界区,这可以通过spinlock来保证)和多个reader并发执行。我们可以比较一下rw spin lock和RCU,参考下图:

FluxBB bbcode 测试

rwlock允许多个reader并发,因此,在上图中,三个rwlock reader愉快的并行执行。当rwlock writer试图进入的时候(红色虚线),只能spin,直到所有的reader退出临界区。一旦有rwlock writer在临界区,任何的reader都不能进入,直到writer完成数据更新,立刻临界区。绿色的reader thread们又可以进行愉快玩耍了。rwlock的一个特点就是确定性,白色的reader一定是读取的是old data,而绿色的reader一定获取的是writer更新之后的new data。RCU和传统的锁机制不同,当RCU updater进入临界区的时候,即便是有reader在也无所谓,它可以长驱直入,不需要spin。同样的,即便有一个updater正在临界区里面工作,这并不能阻挡RCU reader的步伐。由此可见,RCU的并发性能要好于rwlock,特别如果考虑cpu的数目比较多的情况,那些处于spin状态的cpu在无谓的消耗,多么可惜,随着cpu的数目增加,rwlock性能不断的下降。RCU reader和updater由于可以并发执行,因此这时候的被保护的数据有两份,一份是旧的,一份是新的,对于白色的RCU reader,其读取的数据可能是旧的,也可能是新的,和数据访问的timing相关,当然,当RCU update完成更新之后,新启动的RCU reader(绿色block)读取的一定是新的数据。

3、适用的场景

我们前面说过,每种锁都有自己的适用的场景:spin lock不区分reader和writer,对于那些读写强度不对称的是不适合的,RW spin lcok和seq lock解决了这个问题,不过seq lock倾向writer,而RW spin lock更照顾reader。看起来一切都已经很完美了,但是,随着计算机硬件技术的发展,CPU的运算速度越来越快,相比之下,存储器件的速度发展较为滞后。在这种背景下,获取基于counter(需要访问存储器件)的锁(例如spin lock,rwlock)的机制开销比较大。而且,目前的趋势是:CPU和存储器件之间的速度差别在逐渐扩大。因此,那些基于一个multi-processor之间的共享的counter的锁机制已经不能满足性能的需求,在这种情况下,RCU机制应运而生(当然,更准确的说RCU一种内核同步机制,但不是一种lock,本质上它是lock-free的),它克服了其他锁机制的缺点,但是,甘蔗没有两头甜,RCU的使用场景比较受限,主要适用于下面的场景:

(1)RCU只能保护动态分配的数据结构,并且必须是通过指针访问该数据结构

(2)受RCU保护的临界区内不能sleep(SRCU不是本文的内容)

(3)读写不对称,对writer的性能没有特别要求,但是reader性能要求极高。

(4)reader端对新旧数据不敏感。

三、RCU的基本思路

1、原理

RCU的基本思路可以通过下面的图片体现:

FluxBB bbcode 测试

RCU涉及的数据有两种,一个是指向要保护数据的指针,我们称之RCU protected pointer。另外一个是通过指针访问的共享数据,我们称之RCU protected data,当然,这个数据必须是动态分配的  。对共享数据的访问有两种,一种是writer,即对数据要进行更新,另外一种是reader。如果在有reader在临界区内进行数据访问,对于传统的,基于锁的同步机制而言,reader会阻止writer进入(例如spin lock和rw spin lock。seqlock不会这样,因此本质上seqlock也是lock-free的),因为在有reader访问共享数据的情况下,write直接修改data会破坏掉共享数据。怎么办呢?当然是移除了reader对共享数据的访问之后,再让writer进入了(writer稍显悲剧)。对于RCU而言,其原理是类似的,为了能够让writer进入,必须首先移除reader对共享数据的访问,怎么移除呢?创建一个新的copy是一个不错的选择。因此RCU writer的动作分成了两步:

(1)removal。write分配一个new version的共享数据进行数据更新,更新完毕后将RCU protected pointer指向新版本的数据。一旦把RCU protected pointer指向的新的数据,也就意味着将其推向前台,公布与众(reader都是通过pointer访问数据的)。通过这样的操作,原来read 0、1、2对共享数据的reference被移除了(对于新版本的受RCU保护的数据而言),它们都是在旧版本的RCU protected data上进行数据访问。

(2)reclamation。共享数据不能有两个版本,因此一定要在适当的时机去回收旧版本的数据。当然,不能太着急,不能reader线程还访问着old version的数据的时候就强行回收,这样会让reader crash的。reclamation必须发生在所有的访问旧版本数据的那些reader离开临界区之后再回收,而这段等待的时间被称为grace period。

顺便说明一下,reclamation并不需要等待read3和4,因为write端的为RCU protected pointer赋值的语句是原子的,乱入的reader线程要么看到的是旧的数据,要么是新的数据。对于read3和4,它们访问的是新的共享数据,因此不会reference旧的数据,因此reclamation不需要等待read3和4离开临界区。

2、基本RCU操作

对于reader,RCU的操作包括:

(1)rcu_read_lock,用来标识RCU read side临界区的开始。

(2)rcu_dereference,该接口用来获取RCU protected pointer。reader要访问RCU保护的共享数据,当然要获取RCU protected pointer,然后通过该指针进行dereference的操作。

(3)rcu_read_unlock,用来标识reader离开RCU read side临界区

对于writer,RCU的操作包括:

(1)rcu_assign_pointer。该接口被writer用来进行removal的操作,在witer完成新版本数据分配和更新之后,调用这个接口可以让RCU protected pointer指向RCU protected data。

(2)synchronize_rcu。writer端的操作可以是同步的,也就是说,完成更新操作之后,可以调用该接口函数等待所有在旧版本数据上的reader线程离开临界区,一旦从该函数返回,说明旧的共享数据没有任何引用了,可以直接进行reclaimation的操作。

(3)call_rcu。当然,某些情况下(例如在softirq context中),writer无法阻塞,这时候可以调用call_rcu接口函数,该函数仅仅是注册了callback就直接返回了,在适当的时机会调用callback函数,完成reclaimation的操作。这样的场景其实是分开removal和reclaimation的操作在两个不同的线程中:updater和reclaimer。

以上转自:http://www.wowotech.net/kernel_synchronization/rcu_fundamentals.html

以下使用内核input子系统来介绍其具体应用:

static void evdev_events(struct input_handle *handle,
			 const struct input_value *vals, unsigned int count)
{
	struct evdev *evdev = handle->private;
	struct evdev_client *client;
	ktime_t time_mono, time_real;
 
	time_mono = ktime_get();
	time_real = ktime_mono_to_real(time_mono);
 
	rcu_read_lock();
 
	client = rcu_dereference(evdev->grab);
 
	if (client)
		evdev_pass_values(client, vals, count, time_mono, time_real);
	else
		list_for_each_entry_rcu(client, &evdev->client_list, node)
			evdev_pass_values(client, vals, count,
					  time_mono, time_real);
 
	rcu_read_unlock();
} 

页脚

Powered by FluxBB

本站由XREA提供空间支持