操作系统设计的精妙之处就在于,在底层硬件之上创造了新的抽象, 对于系统初始化来说, 它呈现了执行流到线程转化这一概念,该概念远远比它的实现细节更加重要,处理器以“取指-执行”为周期开始串行执行指令,而初始化代码将自身转化为一个并发处理系统, 这里的关键之处在于,初始化代码并不是创建一个独立的并发系统,然后跳转到新的系统。抽象建立的前后不存在清晰的边界与真正的跨越,原来串行执行的初始化程序也并没有被抛弃,相反,初始代码可以声明自己是一个线程,填充进程需要的系统数据结构,允许它以线程的身份继续存在,并允许其它线程执行,与此同时,系统第一次唤醒了自己的三头六臂却混然不知,处理器仍然继续着“取指-执行”周期,就好像任何事情都没有发生,而抽象已经在不知不觉中出现了。
同样是初始化, 为什么有的初始化后仍然是前后台裸机, 而OS却产生了并发执行环境, 这种质变的分界在哪里?
分界可能并不存在!
道生一,一生二,二生三,三生万物, 何其奇妙!
对于通用操作系统的定义,可以想象一个场景,我们有一个主程序A,它让机器通过一条一条的执行内存中的指令来完成要求的任务,然后我们可以在这些内存中存入另一个程序B,在我让机器运行程序A的时候,它首先要做的事情是访问这些内存,而这些内存代表的是程序B的指令,机器随之执行,执行的结果代表程序B的功能.
其实如果我们将主程序A固化在系统里面,使其成为烙印在ROM中的"固件",这台机器就成为不仅可以运行B程序,也可以运行C,D,E,F .....各色程序,这样就可以一劳永逸的的解决问题,A就是操作系统.
通用操作系统架构:
上下文切换就是并发执行幻觉的核心,理解调度器的关键在于调度器仅仅是一个函数,也就是说,操作系统的调度器不是从一个进程里拿出CPU并将其转移到另一个进程的主动代理,而是由某个执行中的进程来调用调度器函数。
1.一般情况下,RTOS的Idle任务只能被抢占,不会主动出让处理器,因为idle的实现一般是个死循环,执行流中没有主动出让处理器的调用,这是为什么呢?从zephyr的idle设计可以看出,这样做是为了避免在idle中频繁执行调度器调用,因为调度器为了保证原子性,会被各种系统锁重重保护,如果idle的调用路径中频繁获取各种锁,显然有伤系统性能。
Idle任务的实现为一个死循环,执行流中不包括会引起出让处理器的操作,所以只能依赖于异步上下文,比如timer中断,外设中断等中断设施来保证调度期有执行的机会出让处理器.
Linux则不同,Linux的idle在出生时就是关闭抢占的(preempt_count为1), 所以中断之类的异步事件是无法抢占ilde任务的,反而依赖于schedule_idle中主动调用schedule函数出让处理器,如下图,这也侧面说明了Linux的非实时系统的原因.
验证IDLE进程不会被抢占的逻辑:
发现上图中的LOG没有输出,说明IDLE进程根本不会被抢占,后续的BUG_ON判断逻辑也说明这一点。
为何IDLE进程上下文不会调用preempt_schedule_irq?从RISCV的抢占逻辑实现可以看出,再调用preempt_schedule_irq之前,会判断TASK_TI_PREEMPT_COUNT是否为0,如果不为零,说明禁止抢占,就不会执行preempt_schedule_irq,而TASK_TI_PREEMPT_COUNT代表的字段正好是preempt_count. 所以现在问题很清楚了,所有禁止抢占的上下文被中断后都不会执行preempt_schedule_irq,IDLE进程整个上下文都是禁止抢占的,所以当然拦截不到。
在preempt_sched_irq的实现中,调度前打开中断是安全的,而melis中这样做会导致idle任务栈爆掉,也是由于melis允许抢占idle,而linux不允许,系统大部分时间是运行再idle状态的,这样会导致终端寄存器现场不停的再idle栈中积累,导致爆栈.LINUX之所以在IDLE中禁止抢占,原因大概也是如此吧。
idle如此特殊,无论在linux还是rtos上面,都必须始终保持在ready就绪状态,因为其它线程都有可能被挂起,为保证系统总能找到一个可执行的上下文(想象一下idle被挂起了,其他线程也一样,CPU无所适从多尴尬). idle必须始终活跃。所以任何可以打断idle执行流的上下文,比如中断,都不能调度,因为中断可能抢占的是idle的执行环境。
idle class的dequeue最能说明问题了,发现这种情况直接kernel error.
关于强占,强占性系统和非强占性系统的区别非常明显,在强占性调度系统中,线程可以在执行中的任何时刻因被其它线程抢占而挂起,甚至当前线程是刚刚执行完抢占准备投入运行时也是如此,但是在非抢占系统中,当线程意图让一个线程block而让其它线程运行时,此线程并不一定马上被block,它需要等到线程运行到进行一次系统调用才可以。
非抢占的系统有一个好处是变成模型简单,每一个不包含对系统调用的代码都会自动成为一个critical section,这样天然就避免了竞争条件,另一方方面,因为非抢占调度是独占运行的,所以他们不能利用多处理器,需要小心处理长期运行的不含有系统调用的代码区,需要在其中主动插入yield之类的调用,避免CPU锁死在一个协程上运行。
操作系统的基本分类:
一般系统,包括linux在内,抢占点大致分成如下几类:
在一个并发编程系统中,进程不应该在等待其它进程时仍然占用处理器。
调度器设计,机制和策略分离的方式。
2.Nuttx里面, 单核模式下,如果即将就绪的任务位于高优先级,但当前运行的线程恰好又关闭了调度器,这个时候将就绪任务放到g_pendingtask队列而不是g_readytorun里面,其它任何情况都是放入readyqueue。
就绪任务/抢占情况 高优先级 低优先级
当前任务关闭抢占 放入g_pendingtask 放入g_readytorun
当前任务打开抢占 放入g_readytorun 放入g_readytorun
当前任务关闭抢占,高优先级的情况:语义上避免抢占,高优先级任务放入pending queue,可以从根本上保证这一点,抢占恢复 时,在将pending list里面的任务放到ready queue.
当前任务关闭抢占,低优先级的情况: 由于任务优先级比正在运行的任务优先级低,所以即便放进readyqueue,也不会发生误抢占,优先级第一的原则还是要遵守的。
当前任务打开抢占,高优先级:应该放到readyqueue,让抢占发生,因为限制抢占的因素都不存在。
当前任务打开抢占,低优先级:同理,放进去也不synchronize_rcu_tasks() makes sure that no task is stuck in preempted会抢占,而且允许抢占,没必要担心误抢占的case.
抢占的必要条件是,调度器首先要独立,调度器本身也是一个函数,调度器独立的意思是,系统必须让调度器有机会运行,而不过度依赖当前运行环境。典型而有效的做法是,将调度检查放置在中断的退出执行路径中,由于中断的独立性和高优先级,能够保证
调度器一定有机会执行。
不知道是不是个规律,悬挂队列(比如信号量,互斥锁队列)等的任务是没有优先级的,只有任务被唤醒放到readyqueue的时候,优先级才会起作用,当然你可以认为睡眠队列是FIFO队列的优先级也起作用。
3. UCOSIII里面,OSSched执行任务切换的操作,限制条件是 OSSched调用之前,不允许处于临界区,
具体点说就是中断不嵌套,并且调度器不上锁,rt-thread的做法是,中断可嵌套锁, 但调度锁不可以上, zephyr又不一样,不但中断嵌套关闭的时候可以调度,调取器嵌套上锁的时候也可以调度。
387 void OSSched (void)
388 {
389 CPU_SR_ALLOC();
390
391
392
393 if (OSIntNestingCtr > (OS_NESTING_CTR)0) { /* ISRs still nested? */
394 ¦ return; /* Yes ... only schedule when no nested ISRs */
395 }
396
397 if (OSSchedLockNestingCtr > (OS_NESTING_CTR)0) { /* Scheduler locked? */
398 ¦ return; /* Yes */
399 }
400
嵌套中断锁 嵌套调度锁
rt-thread 可以调度 不可以调度
zephyr 可以调度 可以调度
ucos 不可以调度 不可以调度
freertos 可以调度 不可以调度(参考uxSchedulerSuspended)
nuttx 可以调度 可以调度
4.执行PC概念,多任务情况下每个线程都有一个虚拟PC指针,表示当前线程的执行点,单核和多核都是如此。
关键节保护的核心在于,任何情况下(单核,双核,任务之间,任务和中断之间),系统中都应该只有一个虚拟PC代表的上下文位于关键节.
线程是被操作系统调度的实体,具体方式由操作系统选择,锁让程序员获得一些控制权,通过给临界区加锁,可以保证临界区内部只有一个线程活跃,锁将本来由操作系统调度的混乱状态变得更为可控
怎样评价一个锁的实现的好坏呢? 第一是看实现是否有效,能够有效保证临界区的互斥安全. 第二是公平,不能有竞争锁的线程出现饿死,第三就是性能,利用队列睡眠和自旋实现对处理器的浪费是不一样的
5:关于zephyr sdk的实现,官方文档是这么说的:
This differs significantly from how devicetree is used on Linux. The Linux kernel would instead read the entire devicetree data structure in its binary form, parsing it at runtime in order to load and initialize device drivers. Zephyr does not work this way because the size of the devicetree binary and associated handling code would be too large to fit comfortably on the relatively constrained devices Zephyr supports.
6. Zephyr。
The kernel also has the concept of “locking the scheduler”. This is a concept similar to locking the interrupts, but lighter-weight since interrupts can still occur. If a thread has locked the scheduler, is it temporarily non-preemptible.
7.Zephyr关于RTOS 使用周期性时钟的局限性, 基本上这也是所有RTOS实现的局限性。
The amount of added time that occurs during a kernel object operation depends on the following factors.
-
The added time introduced by rounding up the specified time interval when converting from milliseconds to ticks. For example, if a tick duration of 10 ms is being used, a specified delay of 25 ms will be rounded up to 30 ms.
-
The added time introduced by having to wait for the next tick interrupt before a delay can be properly tracked. For example, if a tick duration of 10 ms is being used, a specified delay of 20 ms requires the kernel to wait for 3 ticks to occur (rather than only 2), since the first tick can occur at any time from the next fraction of a millisecond to just slightly less than 10 ms; only after the first tick has occurred does the kernel know the next 2 ticks will take 20 ms.
8: 在SMP 模式下,任务切换时需要保证被切换出去任务的下列行为是原子的
1.任务被放置回readyqueue
2.任务上下文保存操作
上述1, 2, 需要保证是原子操作,中间不能被打断,才是安全的切换方式。
如果把次序反一下
9. rt-thread是如何保证第八条的呢?
在中断处理的入口,vector_irq执行一开始,首先构造并保存被中断执行任务的现场,而不是像单核模式只是构造一个临时上下文去调用C程序, 在切换点,释放锁之前,务必保证寄存器现场已经保完毕。
10. zephyr是如何保证第八条的呢?
zephyr目前SMP 只支持xtensa, arc, x86_64架构, 从qemu_x86_64的运行来看,有大bug(qemu很容易验证出来),原因是没有follow第八条
11. rtthread smp模式下,进入中断处理后的初始栈帧即是按照任务切换上下文的现场进行布局的,这是与单核模式下构造的临时栈帧布局是不同的
如果中断处理过程中不进入别的CPU状态,则SVC模式下的寄存器不需要进行保存,这也是RTT单核模式下初始保存栈真不按照任务切换的栈帧布局进行保存的原因。
12:还是关于RTT SMP跨任务解锁的一些思考
13:问题的关键点在于,主动切换的现场保存点发生在持大锁之后,而抢占切换由于是异步的,其现场保存点位于拿锁之前
看下图
14:Linux抢占模式下的一些标准特性,首先打开内核的抢占模式
之后,会启用CONFIG_PREEMPT和CONFIG_PREEMPT_COUNT宏
516 spinlock_t test_lock;
517 static int task_thread(void *data)
518 {
519 spin_lock_init(&test_lock);
520 while(1)
521 {
522 printk("%s line %d preempt count %d.\n", __func__, __LINE__, preempt_count());
523 spin_lock(&test_lock);
524 printk("%s line %d preempt count %d.\n", __func__, __LINE__, preempt_count());
525 msleep(1000);
526 printk("%s line %d preempt count %d.\n", __func__, __LINE__, preempt_count());
527 spin_unlock(&test_lock);
528 }
529
530 return 0;
531 }
532 static void linux_thread_create(void)
533 {
534 kernel_thread(task_thread, NULL, CLONE_FS);
535 }
536
如上的测试用例,在打开抢占开关后,调度过程中调度其会报警告,但调度仍然会执行成功
Hardware name: ARM-Versatile Express
[<8011008c>] (unwind_backtrace) from [<8010c188>] (show_stack+0x10/0x14)
[<8010c188>] (show_stack) from [<80669d8c>] (dump_stack+0x78/0x8c)
[<80669d8c>] (dump_stack) from [<80145298>] (__schedule_bug+0x84/0xd4)
[<80145298>] (__schedule_bug) from [<8067ff44>] (__schedule+0x46c/0x720)
[<8067ff44>] (__schedule) from [<80680240>] (schedule+0x48/0xb0)
[<80680240>] (schedule) from [<80683738>] (schedule_timeout+0x88/0x2e0)
[<80683738>] (schedule_timeout) from [<8017fab4>] (msleep+0x2c/0x38)
[<8017fab4>] (msleep) from [<80101ab0>] (task_thread+0x68/0x88)
[<80101ab0>] (task_thread) from [<80107cc8>] (ret_from_fork+0x14/0x2c)
task_thread line 526 preempt count 0.
task_thread line 522 preempt count 0.
task_thread line 524 preempt count 1.
schedule_debug line 3194, preemptcount 2.PREEMPT_DISABLED 1
BUG: scheduling while atomic: swapper/0/759/0x00000002
Modules linked in:
Preemption disabled at:
[< (null)>] (null)
CPU: 0 PID: 759 Comm: swapper/0 Tainted: G W 4.15.10+ #114
Hardware name: ARM-Versatile Express
[<8011008c>] (unwind_backtrace) from [<8010c188>] (show_stack+0x10/0x14)
[<8010c188>] (show_stack) from [<80669d8c>] (dump_stack+0x78/0x8c)
[<80669d8c>] (dump_stack) from [<80145298>] (__schedule_bug+0x84/0xd4)
[<80145298>] (__schedule_bug) from [<8067ff44>] (__schedule+0x46c/0x720)
[<8067ff44>] (__schedule) from [<80680240>] (schedule+0x48/0xb0)
[<80680240>] (schedule) from [<80683738>] (schedule_timeout+0x88/0x2e0)
[<80683738>] (schedule_timeout) from [<8017fab4>] (msleep+0x2c/0x38)
[<8017fab4>] (msleep) from [<80101ab0>] (task_thread+0x68/0x88)
[<80101ab0>] (task_thread) from [<80107cc8>] (ret_from_fork+0x14/0x2c)
task_thread line 526 preempt count 0.
task_thread line 522 preempt count 0.
task_thread line 524 preempt count 1.
schedule_debug line 3194, preemptcount 2.PREEMPT_DISABLED 1
BUG: scheduling while atomic: swapper/0/759/0x00000002
可以看出,sleep前后,preempt的数值不一值,发生不对称的现象。
这是因为 schedule首先会再次递增premmpt_count变量,使变量变为2.
3428 asmlinkage __visible void __sched schedule(void)
3429 {
3430 struct task_struct *tsk = current;
3431
3432 sched_submit_work(tsk);
3433 do {
3434 preempt_disable();
3435 __schedule(false);
3436 sched_preempt_enable_no_resched();
3437 } while (need_resched());
3438 }
但是在 __schedule的执行过程中:
3288 static void __sched notrace __schedule(bool preempt)
3289 {
。。。。。。。。。。
3300 schedule_debug(prev);
。。。。。。。。。。
}
schedule_debugh函数里调用in_atomic_preempt_off进行检查, 一旦发现preempt_countpreempt_count不为PREEMPT_DISABLE_OFFSET,立即报错,并且将counter强行纠正为1.造成不对称。
什么意思呢?这里的意识是,在主动调度(非中断抢占)调度的情况下,要求执行__schedule过程中,最外层只进行一次的preempt_disable调用,不能嵌套调用。更深层次的原因,需要细细品味。
#define in_atomic_preempt_off() (preempt_count() != PREEMPT_DISABLE_OFFSET)
3183 /*
3184 * Various schedule()-time debugging checks and statistics:
3185 */
3186 static inline void schedule_debug(struct task_struct *prev)
3187 {
3188 #ifdef CONFIG_SCHED_STACK_END_CHECK
3189 if (task_stack_end_corrupted(prev))
3190 panic("corrupted stack end detected inside scheduler\n");
3191 #endif
3192
3193 if (unlikely(in_atomic_preempt_off())) {
3194 printk("%s line %d, preemptcount %d.PREEMPT_DISABLED %d\n", __func__, __LINE__, preempt_count(), PREEMPT_DISABLED);
3195 __schedule_bug(prev);
3196 preempt_count_set(PREEMPT_DISABLED);
3197 }
schedule_debug检查的preempt_disable层数为1,客观上决定了idle里面不能睡眠,否则会报错误,原因是 任何睡眠调度接口都会实现再一次调用preempt_disable使 preempt_count再次递增,由于idle环境preempt_count已经为1了,到了schedule_debug毕竟报错。
15: Linux IDLE(PID 0)的任务preempt_count为什么设置为 1?默认不抢占?
源码中的注释解释:
综合以上几点:
1.CONFIG_PREEMPT_COUNT依赖于CONFIG_PREEMPT,在CONFIG_PREEMPT没有开启的情况下,CONFIG_PREEMPT_COUNT为没有定义。
2。在没有定义CONFIG_PREEMPT_COUNT的时候,preempt_disable仅仅是个barrier,preempt_count始终为0。
3.基于以上逻辑,CONFIG_PREEMPT_COUNT时,前面提到的几个关注点失效。
4.CONFIG_PREEMPT_COUNT启用(启用抢占)时,idle进程依赖于抢占或者调用schedule_idle,之所以调用schedule_idle而非schedule是因为schedule会进行 关闭调度的操作,这样和ILDE环境的关调度叠加在一起,不满足schedule_debug条件,所以必须直接调用schedule_idle完成抢占调度。
5。在不开抢占下,你没有主动调用MSLEEP就可以了,自己保证,所有的检查失效。
16: Linux中的 current指针是通过SP堆栈获取thread_info结构体获得的,所以其和任务切换同时刻发生,具有天然性,
而RTOS中,task_struct在对中,任务切换和current指针赋值并不是同时发生,需要保证原子性。
17: Nuttx CURRENT_REGS中断现场即使 任务调度切换现场
18:关于Linux调度器设计的一些思考
19:RT-Thread实现中,新创建的任务既可以由rt_hw_context_switch启动,也可以由rt_hw_context_switch_to启动,
rt_hw_context_switch_to仅供每个核启动第一个任务时调用一次。之后每个新创建的线程都由rt_hw_context_switch启动。
上下文保存有三个地方,首先,任务创建的时候,手工填充任务栈的初始状态,其次,中断发生抢占时,中断处理程序负责保存被抢占的任务现场。最后,任务主动调度时,调用rt_hw_context_switch保存主动退出的任务现场。
20: rt-thread里面,每一次rt_current_thread的重新赋值,意味着SP指针的一次切换,也就是tcb stuct的一次切换。
21:关于大端和小端
22: ARM register convential
23:Linux 任务切换 __switch_to并不保存cpsr状态, 这说明每个任务在切换前后的中断状态是没有记忆性的.
24:rt-thread和linux线程启动的不同点在于,
rt-thread里面,初次任务调度构造的初始寄存器上下文是按照任务的调度现场布局的,这样可以在任务启动的时候调用接口rt_hw_context_switch或者rt_hw_context_switch_to恢复现场.
而Linux下,初次任务调度构造的初始寄存器上下文不是按照任务的调度现场布局的,所以任务首次调度的现场是通过ret_from_fork恢复,运行过程中调度是通过__switch_to恢复现场,这样就可以把异常上下文和任务上下文区分开.
schedule_tail是专门给ret_from_fork封装的接口,整个系统里面只有这里在使用,由于进程首次执行的出口是这里,而不是schedule函数,但是切换要从schedule开始,所以为了保证平衡,这里也必须要执行finish_task_switch函数,同时,由于没有schedule的上下文环境,中断(中断是在finish_task_switch里面重启的)和preempt_disable都要手动打开,如下图。
25:Linux SMP模式下,每个核的idle 都是在preempt_count为1的情况下执行 do_idle loop的.
非启动核
启动核start_kernel