NCS-OS系列7 :线程
文章目录
前言
ncs 相关文章,部分为原始文档翻译,水平有限,如果有错误,欢迎指出。
本文参考链接:
https://docs.zephyrproject.org/latest/reference/kernel/threads/index.html
测试demo连接:
https://github.com/faithlm/ncs_thread
简介
本节描述用于创建、调度和删除独立可执行指令线程的内核服务。
线程是一个内核对象,用于处理那些ISR
无法执行的太长或太复杂的应用程序。
应用程序可以定义任意数量的线程(仅受可用RAM的限制)。每个线程都由一个thread id
引用,这个id是在线程生成时分配的。
线程有下面的关键属性:
- 用于线程堆栈的内存空间,堆栈区域的大小可以根据线程处理的实际需要进行调整,特定的宏用于创建和使用堆栈内存区域。
- 内存控制块,用于内核记录线程的私有元数据,这是一个
k_thread
类型的实例。 - 入口点函数,在线程启动时调用。最多可以向这个函数传递3个参数值。
- 调度优先级,它指示内核的调度程序如何为线程分配CPU时间。(见调度章节)。
- 一组线程选项,允许线程在特定情况下接受内核的特殊处理。(见
thread options
。) - 启动延迟,它指定内核在启动线程之前应该等待多长时间。
- 一种执行模式,可以是管理员模式或用户模式。默认情况下,线程以管理员模式运行,允许访问特权CPU指令、整个内存地址空间和外设。用户模式线程拥有较少的权限集。这取决于
CONFIG_USERSPACE
选项。具体参考User Mode
。
生命周期 Lifecycle
线程创建 Thread Creation
线程必须在使用之前创建,内核初始化线程控制块以及堆栈部分的一端。线程堆栈的其余部分通常不初始化。
指定K_NO_WAIT
的启动延迟将指示内核立即开始线程执行,或者可以通过指定一个超时值来指示内核延迟线程的执行——例如,为了使线程使用的设备硬件变成可用状态。
内核允许在线程开始执行之前取消设置的延迟启动线程。如果线程已经启动,那么取消请求是无效的,被成功取消的延迟启动线程必须在使用之前重新生成。
线程结束 Thread Termination
线程一旦启动,通常会一直执行下去。然而,线程可以通过从它的入口点函数返回来同步结束它的执行,这就是所谓的结束。
结束的线程在返回之前释放它可能拥有的任何共享资源(比如互斥锁和动态分配的内存),因为内核不会自动回收它们。
在某些情况下,一个线程可能希望休眠,直到另一个线程结束。这可以通过k_thread_join()
API来实现,这将阻塞调用线程,直到超时触发、目标线程自己退出或目标线程废弃(由于k_thread_abort()
调用或触发致命错误)。
线程终止 Thread Aborting
线程可以通过aborting
来异步结束它的执行,如果线程触发了致命错误,内核会自动终止该线程,比如引用了一个空指针。
一个线程也可以通过调用k_thread_abort()
被另一个线程(或它自己)终止。然而,通常更可取的做法是通知线程去正常的结束自己,而不是终止它。
与线程结束一样,内核不会回收被终止的线程所拥有的共享资源。
线程挂起 Thread Suspension
如果一个线程被挂起,它将被无限期地阻止执行。函数k_thread_suspend()
可用于挂起任何线程,包括调用线程,挂起一个已经挂起的线程没有额外的效果。
一旦线程被挂起,除非其他线程通过调用k_thread_resume()
来移除它的挂起状态,否则不会再被调度器调度。
注意,线程可以使用k_sleep()
阻止自己在指定的时间段内执行。然而,这与挂起线程是不同的,因为当达到时间限制后,休眠线程将自动变为可执行线程。
线程状态 Thread States
一个没有阻碍其执行的因素的线程被认为已经准备(ready
)好了,并且有资格被选择为当前线程。
一个线程如果有一个或多个因素阻止其执行,则被认为是未就绪(unready
)的,不能被选择为当前线程。
下面这些因素会使线程变为未就绪状态:
- 线程还未被启动。
- 线程正在等待某个内核对象。(例如,现在正在获取一个不可获取的信号量。)
- 线程正在等待超时。
- 线程被挂起。
- 线程已经结束或终止。
下面是线程状态关系图:
线程栈对象 Thread Stack objects
每个线程都要有自己的堆栈缓冲区,以便CPU推送上下文。根据配置的不同,有几个必须满足的规则:
- 需要为内存管理结构体预留额外的内存
- 如果启用了用于监测的堆栈溢出检测,那么一个小的写保护内存管理区域必须紧接在堆栈缓冲区之前,用来捕获溢出。
- 如果启用了用户空间,则必须保留一个单独的固定大小的特权级堆栈,作为处理系统调用的私有内核堆栈。
- 如果启用了用户空间,线程的堆栈缓冲区必须有适当的大小和对齐,这样内存保护区域就可以被编程为正确适配状态。
对齐规则可能非常严格,例如,一些mpu要求它们的区域大小为2的次方,并按照自己的大小对齐。
正因为如此,可移植的代码不能简单地将任意大小的字符缓冲区传递给k_thread_create()
。一些特定的宏用来实例化栈,以K_KERNEL_STACK
和K_THREAD_STACK
为前缀。
Kernel-only Stacks
如果知道一个线程永远不会在用户模式下运行,或者堆栈被用于处理中断等特殊上下文,那么最好使用K_KERNEL_STACK
宏来定义堆栈。
这种堆栈可以节省内存,因为一个MPU区域将永远不需要被编程来覆盖堆栈缓冲区本身,并且内核将不需要为特权提升堆栈或仅属于用户模式线程的内存管理数据结构预留额外的空间。
从用户模式尝试使用以这种方式声明的堆栈将导致调用者出现致命错误。
如果没有启用CONFIG_USERSPACE
,则K_THREAD_STACK
宏与K_KERNEL_STACK
宏具有相同的效果。
Thread stacks
如果知道或者不能确定堆栈是否需要承载用户线程,可以使用K_THREAD_STACK
宏定义堆栈,这会导致使用更多的内存,但stack对象可以承载用户线程。
如果没有启用CONFIG_USERSPACE
,则K_THREAD_STACK
宏与K_KERNEL_STACK
宏具有相同的效果。
线程优先级 Thread Priorities
线程的优先级是一个整数值,可以是负的也可以是非负的。数值上较低的优先级优先于数值上较高的值。例如,调度程序给予优先级为4的线程A比优先级为7的线程B更高的优先级;同样,优先级为-2的线程C比线程A和线程B具有更高的优先级。
调度程序根据每个线程的优先级区分两类线程:
- 协作线程(
cooperative thread
)具有负的优先级值。协作线程一旦成为当前线程,就会一直保持当前线程,直到它执行了一个使其未就绪的操作。 - 可抢占线程(
preemptible thread
)具有非负的优先级值。一旦它成为当前线程,如果协作线程或优先级更高或同等优先级的可抢占线程准备就绪,则可抢占线程可能在任何时候被取代。
线程的初始优先级值可以在线程启动后上调或下调。因此,通过改变其优先级,可抢占线程可能变成协作线程,反之亦然。
内核支持无限数量的线程优先级级别。配置选项CONFIG_NUM_COOP_PRIORITIES
和CONFIG_NUM_PREEMPT_PRIORITIES
为每个线程类指定了优先级级别的数量,从而产生以下可用的优先级范围:
- 协作线程(
cooperative thread
): (-CONFIG_NUM_COOP_PRIORITIES) to -1 - 可抢占线程(
preemptible thread
): 0 to (CONFIG_NUM_PREEMPT_PRIORITIES - 1)
例如,配置5个协作优先级和10个抢占优先级,则优先级的取值范围分别为-5 ~ -1和0 ~ 9。
线程选项 Thread Options
内核支持一小部分线程选项,这些选项允许线程在特定情况下接受特殊处理。在生成线程时指定与线程关联的选项集。
不需要任何线程选项的线程的选项值为0。需要线程选项的线程通过名称指定,如果需要多个选项,则使用|
字符作为分隔符。
目前支持下面这些线程选项:
K_ESSENTIAL
:该选项将该线程标记为必须线程。这指示内核将线程的终止或中止作为致命的系统错误处理,默认情况下,线程不被认为是必须线程。
K_SSE_REGS
:这个特别用于x86的选项表示线程使用CPU的SSE寄存器。默认情况下,内核在调度线程时不会尝试保存和恢复该寄存器的内容。
K_FP_REGS
: 这个选项表示线程使用CPU的浮点寄存器。这表示内核在调度线程时采取额外的步骤来保存和恢复这些寄存器的内容。默认情况下,内核在调度线程时不会尝试保存和恢复该寄存器的内容。
K_USER
:如果启用了CONFIG_USERSPACE
,则该线程将以用户模式创建,并具有较小的权限,否则,这个标志什么也不做。
K_INHERIT_PERMS
:如果启用了CONFIG_USERSPACE
,这个线程将继承父线程拥有的所有内核对象权限,除了父线程对象。
线程自定义数据 Thread Custom Data
每个线程都有一个32位的自定义数据区,只能由线程自己访问,并且可以被应用程序用于它所选择的任何目的。线程的默认自定义数据值是零。
注意,自定义数据的支持对于ISRs
是不可用的,因为它们在一个单一的共享内核中断处理上下文中操作。
默认情况下,线程的自定义数据支持是禁用的。可以使用配置选项CONFIG_THREAD_CUSTOM_DATA
来启用支持。
k_thread_custom_data_set()
和k_thread_custom_data_get()
函数分别用于写入和读取线程的自定义数据。一个线程只能访问自己的自定义数据,而不能访问另一个线程的自定义数据。
下面的代码使用自定义数据特性来记录每个线程调用特定例程的次数。
int call_tracking_routine(void)
{
uint32_t call_count;
if (k_is_in_isr()) {
/* ignore any call made by an ISR */
} else {
call_count = (uint32_t)k_thread_custom_data_get();
call_count++;
k_thread_custom_data_set((void *)call_count);
}
/* do rest of routine's processing */
...
}
通过使用自定义数据作为指向线程拥有的数据结构的指针,可以使例程访问线程的特定信息。
线程的实现 Implementation
生成一个线程 Spawning a Thread
线程的生成是通过定义其堆栈区域和线程控制块,然后调用k_thread_create()
。
栈区域必须使用K_THREAD_STACK_DEFINE
或K_KERNEL_STACK_DEFINE
来定义以确保他在内存中被正常的设置。
堆栈的size参数必须是以下三个值之一:
- 原始请求的堆栈大小传递给堆栈实例化宏
K_THREAD_STACK
或K_KERNEL_STACK
。 - 对于用
K_THREAD_STACK
系列宏定义的堆栈对象,该对象的K_THREAD_STACK_SIZEOF()
的返回值。 - 对于用
K_KERNEL_STACK
系列宏定义的堆栈对象,该对象的K_KERNEL_STACK_SIZEOF()
的返回值。
线程生成函数返回其线程id,该id可用于引用该线程。
下面的代码生成了一个立即启动的线程:
#define MY_STACK_SIZE 500
#define MY_PRIORITY 5
extern void my_entry_point(void *, void *, void *);
K_THREAD_STACK_DEFINE(my_stack_area, MY_STACK_SIZE);
struct k_thread my_thread_data;
k_tid_t my_tid = k_thread_create(&my_thread_data, my_stack_area,
K_THREAD_STACK_SIZEOF(my_stack_area),
my_entry_point,
NULL, NULL, NULL,
MY_PRIORITY, 0, K_NO_WAIT);
或者,可以在编译时调用K_THREAD_DEFINE
来声明线程。宏自动定义了堆栈区域、控制块和线程id变量。
下面的代码与上面的代码段具有相同的效果:
#define MY_STACK_SIZE 500
#define MY_PRIORITY 5
extern void my_entry_point(void *, void *, void *);
K_THREAD_DEFINE(my_tid, MY_STACK_SIZE,
my_entry_point, NULL, NULL, NULL,
MY_PRIORITY, 0, 0);
注意:k_thread_create()
的延迟参数是一个k_timeout_t
值,所以K_NO_WAIT
意味着立即启动线程。K_THREAD_DEFINE
对应的形参是一个持续时间,单位是毫秒,所以等效的实参是0。
用户模式的限制 User Mode Constraints
本节仅适用于启用了CONFIG_USERSPACE
,且用户线程尝试创建新线程的情况。k_thread_create()
API仍然可以使用,但是有一些额外的约束必须满足,否则调用线程将被终止:
- 调用线程必须在子线程和堆栈参数上都被授予权限,它们都被内核作为内核对象跟踪。
- 子线程和堆栈对象必须处于未初始化状态,即它当前没有运行并且堆栈内存是未使用的。
- 传入的堆栈大小参数必须等于或小于声明时堆栈对象的边界。
- 必须使用
K_USER
选项,因为用户线程只能创建其他用户线程。 - 不可使用
K_ESSENTIAL
选项,用户线程可能不被认为是必须线程。 - 子线程的优先级必须是有效的优先级值,并且等于或低于父线程。
缩减权限
如果启用了CONFIG_USERSPACE
,在管理员模式下运行的线程可以使用k_thread_user_mode_enter()
API执行单向转换到用户模式。这是一个单向操作,它将重置线程的堆栈内存并将其置零,线程将被标记为非必要的。
结束一个线程
线程通过它的入口点函数返回来结束自己。
下面的代码演示了线程结束的方式。
void my_entry_point(int unused1, int unused2, int unused3)
{
while (1) {
...
if (<some condition>) {
return; /* thread terminates from mid-entry point function */
}
...
}
/* thread terminates at end of entry point function */
}
如果启用了CONFIG_USERSPACE
,中止线程将额外标记线程和堆栈对象为未初始化的,以便它们可以被重用。
运行时间统计
如果启用了CONFIG_THREAD_RUNTIME_STATS
,则可以收集和检索线程运行时统计信息,例如,一个线程的执行周期总数。
默认情况下,使用默认的内核计时器收集运行时间的统计信息。对于一些体系结构,soc或板卡,有通过定时器功能提供更高分辨率的计时器。可以通过CONFIG_THREAD_RUNTIME_STATS_USE_TIMING_FUNCTIONS
来启用这些计时器。
下面是一个示例:
k_thread_runtime_stats_t rt_stats_thread;
k_thread_runtime_stats_get(k_current_get(), &rt_stats_thread);
printk("Cycles: %llu\n", rt_stats_thread.execution_cycles);
推荐的应用场景
使用线程来处理不能在ISR中处理的代码。
使用单独的线程来处理可以并行执行的逻辑上不同的处理操作。
配置选项
涉及到的有如下配置选项:
- CONFIG_MAIN_THREAD_PRIORITY
- CONFIG_MAIN_STACK_SIZE
- CONFIG_IDLE_STACK_SIZE
- CONFIG_THREAD_CUSTOM_DATA
- CONFIG_NUM_COOP_PRIORITIES
- CONFIG_NUM_PREEMPT_PRIORITIES
- CONFIG_TIMESLICING
- CONFIG_TIMESLICE_SIZE
- CONFIG_TIMESLICE_PRIORITY
- CONFIG_USERSPACE