概述
在本课中,我们将深入探讨nRF Cornect SDK/Zephyr中的线程管理和数据传递。我们有两种执行上下文:线程上下文和中断上下文。每种上下文都有其自身的使用方式和时序要求。
我们首先将考察不同的执行原语,以及不同类型的线程和不同类型的中断是如何共存的。随后,我们将更深入地探讨线程的生命周期以及调度器的工作原理。此外,我们还将涵盖常用的数据传输技术,如消息队列和FIFO(先进先出)课程结束时,你将能够对可用于安排应用任务的各种选项及其不同的时间限制有扎实的理解。同时,你还将获得实际操作层面的掌握,了解如何在线程间安全传递数据以及可用的内核选项。
目标
- 充分理解不同的执行选项及其确定性和抢占特性。
- 充分理解线程的生命周期
- 充分掌握Zephyr内核选项以及调度器的工作原理
- 理解将子系统和网络堆栈添加到应用程序中的软件和时间影响。
- 学习如何使用正确的执行原语和合适的优先级级别来调度应用程序任务。
- 学习如何安全地使用消息队列和FIFO在线程之间传递数据。
- 通过动手练习掌握如何创建不同类型的线程以及它们之间的数据交换。
启动顺序与执行上下文
在应用程序代码中执行任务时,你需要选择合适的执行原语(抢占式线程、协作式线程、工作队列等)并正确设定其优先级,以确保它不会阻塞运行在CPU上的其他任务,同时还能满足任务的时间要求。
本课程的主要目标之一是学习如何运用合适的执行原语和正确的优先级级别来安排应用任务。但在深入探讨如何选择适当的执行方法来运行给定任务之前,我们需要先考虑以下问题:
- nRFConnect SDK应用程序如何启动?
- 应用程序中的线程和ISR是什么?它们的优先级和执行性质是什么?
- 中断上下文和线程上下文之间的区别是什么,以及在每个上下文中应如何操作?
启动序列
1.早期启动序列(C代码准备阶段)
早期引导序列的主要功能是将系统从复位状态过渡到能够执行C代码的状态,从而启动内核初始化序列。这一阶段是嵌入式设备中相当标准的一个阶段;作为应用开发者,它并不会引起太大的兴趣。
2.内核初始化
此阶段介绍初始化所有已启用静态设备的状态的过程。部分设备由实时操作系统(RTOS)默认启用,而其他设备则需要通过应用程序配置文件(pri.conf)和板级配置文件启用,正如我们在nRF Connect SDK基础课程中所学到的。后一种设备包括使用Zephyr APL以静态方式定义的设备驱动程序和系统驱动程序对象。
初始化顺序的控制方式是通过将它们分配到特定的运行级别(例如PRE_KERNEL_1、PRE_KERNEL_2) 来实现,而它们在相同运行级别内的初始化序列则进一步通过优先级级别加以定义。请谨记,在此阶段,调度器和内核服务尚不可用,因此这些初始化函数并不依赖于任何内核服务。我们将在第7课中深入探讨驱动程序初始化方面的内容。
2.1.在所有nRF Connect SDK应用程序中,默认初始化于PRE_KERNEL_1阶段的内容是什么?
时钟控制驱动程序:此驱动程序支持对硬件时钟控制器的支持。硬件设备可为其他子系统提供时钟
信号,从而还可以通过控制这些子系统的时钟来提高能效。
·串行驱动程序:可以是UART(E)、RTTT或其他传输方式。用于发送调试输出,例如启动横幅。仅当
启用调试选项时才会初始化。
2.2.在所有nRFConnect SDK应用程序中,默认在PRE_KERNEL_2阶段初始化的内容是什么?
系统定时器驱动程序:这通常是一种实时计数器外围设备(RTCI),用于Nordic SoCs和SiP(nRF91nRF53、nRF52系列)。在nRF54系列中,它是全局实时计数器外围设备(GRTC)。系统定时器将用于内核的定时服务,例如k_sleep()和内核计时器APl。
3.多线程准备
这就是多线程功能(包括调度器)初始化所处的位置。RTOS还会创建两个线程(系统线程):RTOS主线
程以及空闲线程。该空闲线程负责在没有其他线程就绪的情况下调用SiP和SoC的电源管理系统。
在此阶段,如果存在POST_KERNEL服务,则会启动这些服务。一旦POST_KERNEL服务启动完成,将打印Zephyr引导横幅:
***Booting nRF Connect SDK v2.x.x***之后,如果存在,则初始化应用程序级别服务。然后,启动所有由应用程序定义的静态线程(使用K_THREAD_DEFINE())
3.1.在所有nRFConnect SDK应用程序中,默认情况下POST_KERNEL初始化了什么
这正是许多库、实时操作系统子系统及服务进行初始化操作的地方。这些库在配置过程中需要内核服务支持,因此
它们被初始化在POST_KERNEEL级别,此时内核服务已就绪可用。
默认情况下,实时操作系统(RTOS)在此处不会进行任何初始化操作。然而,如果相关库、RTOS子系统及服务被启用的话,它们都会在此处被初始化。例如,如果日志记录功能(CONFIG_LOG)被设置为延迟模式,那么日志模块的初始化过程以及专用于延迟模式的日志线程的创建便发生于此。此外,如果使用了蓝牙低功耗技术(CONFIGBT),那么蓝牙堆栈的初始化以及RX和TX线程的创建也发生在此处。同样,如果系统工作队列被
使用,那么系统工作队列线程的初始化也发生于此处。
3.2.在所有nRFConnect SDK应用程序中,默认初始化的应用程序(APPLICATION)内容是什么
默认情况下,某些库会在此处被初始化一一如果它们已被启用的话。例如,如果你正在基于nRF91系列平台进行开发,且同时使用了AT监控库(AT_MONITOR),那么这个库的初始化过程便发生于此处。
RTOS主线程是当前处于运行状态的线程。在完成所有初始化操作后,它将调用(如果存在)你的主函数。若不存在用户定义的main( 函数,RTOS主线程将终止,而调度器则会选取下一条就绪的线程进行执行这可能是一条用户定义的主线程、一个子系统线程,或者在没有就绪线程的情况下则为空闲线程。线程的类型和优先级决定了哪条线程将被执行。我们将在后续主题中对此进行详细探讨。
启动后,将建立若干线程和中断机制。在下一部分中,我们将比较中断运行所处的上下文(称为中断上下文)与线程运行所处的上下文(称为线程上下文)之间的差异。
线程上下文与中断上下文对比
中断和线程上下文代表了具有不同特征和预定用途的不同执行环境。让我们逐一探讨每种上下文以及其中允许和不允许进行的操作:
线程上下文
执行上下文:线程上下文指的是应用程序和系统线程运行的正常执行环境。
触发事件:线程由应用程序或RTOS创建,并通过调度器使用定义规则(类型和优先级)进行调度。
抢占:线程上下文可以被中断或更高优先级的线程抢占。有关更多详细信息,请参阅调度器深入主题。
持续时间:线程可以执行更长的时间,并执行比中断上下文更为复杂的操作。
允许的操作:
- 访问完整的内核服务和操作系统服务。
- 执行耗时操作。
- 等待同步原语,如互斥锁、信号量或事件标志。
- 执行阻塞I/O操作。
不允许的操作:
- 在没有适当同步或抽象的情况下直接访问硬件寄存器。
- 执行时间敏感的操作。
中断上下文
执行上下文:中断上下文是指当中断处理器运行时的执行环境。
触发事件:中断可以在任何时候完全异步发生,并由硬件事件触发,例如定时器、外部信号或设备1/O。
抢占:中断上下文会抢占当前运行的线程上下文。
持续时间:中断处理程序应快速执行,以尽量减少响应中断所造成的延迟,并避免阻塞系统线程和通信堆栈线程的运行。
中断嵌套:Zephyr允许嵌套中断,意味着中断处理程序可以被更高优先级的另一个中断打断。
允许的操作:
执行时间敏感的操作。
- 访问受限的内核服务集。
不允许的操作:
- 阻塞操作。
- 使用大多数用于线程上下文的内核服务(例如,在同步原语上休眠或等待、获取可能阻塞的互斥锁或信号量)
线程生命周期:
线程是可执行代码的基本单元。绝大多数固件代码都会在线程中运行一一无论是用户定义的线程、由实时操作系统(RTOS)创建的线程(例如系统工作队列线程)、由RTOS子系统创建的线程(例如日志记录模块),还是由某个库(例如AT监控库)创建的线程。
- 线程控制块:此类型为k_thread。对于每个线程,都会在RTOS内存在一个线程控制块的实例,该实例负责跟踪线程的相关信息,特别是其元数据。
- 堆栈:每个线程都将拥有自己的堆栈。堆栈区域的大小必须根据线程的具体处理需求进行设定。下一部分将介绍如何设定合适的线程大小。
- 入口点函数:这是线程的主体部分,或者换句话说,是线程所实现的功能。它通常包含一个无限循环,因为退出入口点将终止该线程。入口点函数可以拥有三个可选参数值,这些值在启动时会被传递给它。
- 线程优先级:优先级仅是一个带签名的整数,它决定了线程的“类型”。它指示调度器如何为线程分配CPU时间。我们将在下一主题“深入探讨调度器”中对此进行详细探讨。
- 可选线程选项:正如在nRFConnect SDK基础课程一一第7课中所述的那样,通过使用此可选字段您可以在特定情况下使该线程获得特殊处理。
- 可选启动延迟:您可以通过传递K_NO_WAIT来指示内核立即将创建的线程放入就绪线程队列(就绪队列)中,而K_NO_WAIT实际上只是表示启动延迟为0。或者,我们也可以指定一个可选的启动延迟。
创建线程
线程的创建可通过使用KTHREADDEFINE(.)宏或kthread create() 函数来实现。在这两种情况下,都需要静态分配一个堆栈(Zephyr RTOS自v3.4.0版本起不支持动态线程)。K_THREAD_DEFINE(宏负责自行管理堆栈的分配过程,而所需的堆栈大小则需作为参数传递给该宏。相反,如果你使用k_thread_create(函数,则必须预先通过K_THREADSTACK_DEFINE()宏来分配一个堆栈。
正如基础课程中所述的,在创建线程时,您既可选择立即启动线程,也可选择在指定一定延迟后进行启动。一旦线程启动,它就会被放入就绪线程队列(readyqueue)中。
此外,还有一种创建线程的选项,可将延迟设置为KFOREVER,这实际上会使该线程处于非活动状态。若要使其激活,请调用k_thread_start()函数,这将将该线程添加到就绪线程队列(readyqueue)中。
线程状态
该线程将持续处于“运行中”状态,直到:如果调度器选择执行该线程,则其状态将变为“运行中”。调度策略将在下一主题“深入调度”中介绍
线程变为未读状态,意味着其处于睡眠、挂起或等待状态。
- 睡眠:线程通过调用k_sleep()或其派生函数决定暂停一段时间。
- 暂停:另一个线程通过调用k_thread_suspend()来暂停当前线程。
- 等待:线程等待一个不可用的内核对象(例如,互斥锁或信号量)。
线程会自行让出控制权,或被调度器抢占。
- 线程通过调用k_yield()来让出CPU,并将自身置于就绪队列末尾,从而释放资源。
- 当就绪队列中有更高优先级的线程时,调度器会在重新调度点中暂停该线程。被暂停后
- 该线程将被放置在就绪队列末尾。
终止或中止
- 线程执行通过终止或中止而结束。
- 当线程的入口点函数退出时会发生终止。这种情况发生在几个特定情况下,即线程定义了非重复任务,并且它通过这些任务完成工作。
- 如果线程遇到致命错误情况(例如解引用空指针),则可能自动中止,此时RTOS会中止该线程。或者,另一个线程或线程本身可以使用k_thread_abort() 函数故意中止该线程。
调度器有一个简单的任务:从就绪线程队列(ready queue)中选取一个线程作为当前活动的运行线程,并将其分配给CPU。
决定选择哪条线程作为运行线程是完全确定性的过程,因此规则决定了每条线程的重要性,即所谓的优先级。由于调度器是一种实时操作系统(RTOS)调度器,它并不考虑线程的完整性或执行历史,这意味着作为固件开发者,你必须决定各线程将如何共享指定的CPU资源,并为每个线程设置合适的优先级。本课程将介绍如何实现这一点。
上下文切换
在线程执行期间,CPU寄存器被使用,并访问RAM和ROM。包括处理器寄存器和堆栈在内的综合资源构成了该线程的上下文。
一个线程会遵循连续的代码流程,而不会意识到它何时会被抢占(由调度器执行)或中断(由中断服务例程执行)。设想这样一种场景:一个线程在即将执行一项指令(该指令涉及从两个CPU寄存器(RO和R)中减去存储的值(0x05和0x05))之前被抢占。当该线程被抢占时,其他线程将开始运行,并极有可能对CPU寄存器值进行修改。当该线程被重新调度时,它对此类改动一无所知。如果该线程在减法运算中使用已被修改的CPU寄存器值,则可能会导致结果出现错误。
为防止此类错误的发生,线程必须以与先前被抢占时相同的上下文状态继续运行。RTOS可通过在线程被抢占时保存其上下文并在其恢复执行前进行恢复来确保这一点。这种在抢占线程时保存上下文并在恢复时进行恢复的过程被称为上下文切换。
请注意,上下文切换确实会占用一些时间,因为它涉及数据的复制过程。作为一名固件开发人员,您应尽可能减少固件中上下文切换的次数。同时,请谨记上下文切换也会伴随着中断的发生。
线程类型
线程是固件中的逻辑单元。thenRFConnect SDK中的主要两种线程类型是可抢占线程和协作式线程。此外,还有一类特殊的协作式线程,称为元中断(Meta-IRQ)线程。
抢占线程
正如nRFConnectSDK基础课程中所述,抢占式线程是用户应用程序中最常用的线程类型。它们之
所以被称为抢占式线程,是因为当存在更高优先级的线程时,调度器可以抢占这些线程的执行。
合作线程
协作线程的创建方式与可抢占线程相同,所不同的是传递给‘K_THREAD_DEFINE() ‘的优先级为负数。其主要特性在于调度器无法抢占它们,这意味着协作线程将持续运行直至其通过有意睡眠、等待、调用使线程状态变为不可用状态的API或主动放弃控制权而停止运行。
协作线程的主要用途在于强制实施调度器锁定机制。当你将一个任务实现为协作线程时,你完全可以确信其他线程将无法抢占你的任务,因此你无需担心同步与锁定问题。
请谨记,中断仍有可能打断一个协作型线程。然而,中断处理完毕后,执行过程必定会返回到被中断的协作型线程。对于可抢占的线程而言,这种保证则不存在。
协作线程被应用于某些子系统、网络堆栈和设备驱动程序中,用于实现互斥机制(调度器锁定)。在某些涉及性能关键性工作的用户应用程序中,它们也可被加以运用。
Meta-IRQ线程
Meta-IRQ线程是一种特殊的协作线程类型。尽管这类线程并非专为用户应用程序而设计,但您仍需对其有所了解。Meta-IRQ线程主要用于设备驱动程序的“下半部分”工作流,即在硬件中断服务例程结束时进行触发。
中断事件可以在任何时刻以异步方式发生。如果这些中断发生在某个协作线程正在运行的过程中,那么可以肯定的是,中断后的执行过程将返回到被中断的协作线程。然而,如果中断处理程序需要在中断发生后立即在线程上下文中执行特别紧急的任务,又该如何应对呢?解决方案是使用一个Meta-IRQ线程。
通过将驱动程序的“下半部分” (例如蓝牙低功耗堆栈)分配为Meta-IRQ线程,中断将保证立即触发该线程。
线程优先级
当线程被创建时,会被赋予一个整数值以表示其优先级。该值可以是负数或非负数,数值越低
则优先级越高。这意味着优先级为4的线程将享有比优先级为7的线程更高的优先级。同样,
优先级为-2的线程也将享有高于优先级为4和优先级为7的线程的优先级。
调度器会根据线程的优先级来区分抢占型线程与协作型线程:优先级为负值的线程被归类为协作型线程,而优先级不为负值的线程则被归类为抢占型线程。
非负优先级的数量可通过Kconfig符号CONFIG_NUM_PREEMPT_PRIORITIES进行配置,默认值为15。主线程的优先级为0,而空闲线程的默认优先级为15。如果使用日志模块进行延迟模式处理,日志线程的优先级将为14。
同样,负优先级数量的设定可通过Kconfig符号CONFIG_NUM_COOP_PRIORITIES进行配置,其默认值为16(16表示合作优先级级别的数量)。这意味着可用于合作线程的优先级范围为-1到-16,其中系统工作队列线程被实现为具有优先级为-1的合作线程。
在某个线程启动后,是有可能动态地改变其初始优先级的。这也意味着一个可抢占的线程若将其优先级从非负值调整为负值,便有可能转变为一个可协作的线程,反之亦然。
调度器锁定与中断禁用:
调度器锁定是RTOS中的一种机制,它可使调度器被临时锁定或禁用,从而防止不同线程或进程之间发生上下文切换。
调度器锁定确保了特定代码段或关键区域的执行能够原子化进行,不受其他线程的干扰。这就引出了在nRFConnect SDK中如何实现这一功能的方法对于合作线程,该机制自动执行。合作线程内置了调度器锁定机制。对于可抢占的普通线程,存在两个与调度器锁定相关的函数:k_sched_lock()用于锁定一个线程,k_sched_unlock ()用于解锁一个线程。k_sched_lock ()函数能够有效地将当前线程提升至协作优先级状态,即便在没有配置任何协作优先级的情况下也是如此。该函数并非一种广泛应用于应用程序代码中的机制。
请记住,调度程序锁定并不能防止中断干扰您的关键区域。为了保护代码的关键部分免受调度
程序的抢占和ISR的干扰,您可以使用irq_lock()和irq_unlock()函数。
具有相同优先级的线程:
你可以拥有多个具有相同优先级级别的线程,但专用于空闲线程的优先级除外(CONFIG_NUMPREEMPT_PRIORITIES,默认为15)。然而,当存在多个具有相同优先级级别的线程时,调度器是如何决定选取哪一个线程的呢?除了默认行为外,若需要的话还可启用另外两种选项。默认行为:调度器将运行最先被置干就绪队列中的线程