线程理论知识

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

252.2.3线程理论知识

#include <rtthread.h>                         // 引入 RT-Thread 的线程类型、状态码和线程管理 API。
#define DBG_TAG "main"                       // 把本文件的日志标签设置为 main。
#define DBG_LVL DBG_LOG                      // 允许输出调试级别及以上的日志。
#include <rtdbg.h>                            // 在日志配置宏之后引入 RT-Thread 日志接口。
rt_thread_t th1_ptr;                          // 保存动态线程 th1 的句柄。
struct rt_thread th2_ptr;                     // 为静态线程 th2 预留线程控制块对象。
rt_uint8_t th2_stack[1024];                   // 为静态线程 th2 预留 1024 字节线程栈。
int ret;                                      // 按课程源码接收 rt_thread_init() 返回的状态码。
void th1_printf(uint8_t *parameter)           // 两个线程共用的入口函数;该形参写法会产生回调类型不兼容警告,详见正文。
{                                             // 共用线程入口函数开始。
    while (1)                                 // 让调用该入口函数的线程持续运行。
    {                                         // 无限循环开始。
        rt_kprintf("In %s,func %s is running! the line is %d!,parameter is %d\r\n", __FILE__, __FUNCTION__, __LINE__, *parameter); // 输出文件、函数、行号及当前线程收到的参数值。
        rt_thread_mdelay(1000);                // 让当前线程延时阻塞约 1000 ms,使其他就绪线程能够运行。
    }                                         // 本轮循环结束。
}                                             // 共用线程入口函数结束。
int main(void)                                // RT-Thread 初始化完成后,主线程从 main 开始执行。
{                                             // main 函数开始。
    int count = 1;                            // 作为课程模板中主循环的计数变量。
    uint8_t a = 1, b = 2;                     // 准备两个具有地址的变量,分别作为两个线程的入口参数。
    th1_ptr = rt_thread_create("th1",         // 动态创建名为 th1 的线程,并保存返回的线程句柄。
                               th1_printf,    // 让 th1 从共用入口函数开始执行。
                               &a,            // 把变量 a 的地址传给 th1,线程中解引用后得到数值 1。
                               1024,          // 由内核从堆中为 th1 分配 1024 字节线程栈。
                               20,            // 把 th1 的优先级设置为 20;数字越小,优先级越高。
                               10);           // 把 th1 的初始时间片设置为 10 个系统 tick。
    if (th1_ptr == RT_NULL)                   // 返回空句柄表示动态创建 th1 失败。
    {                                         // th1 创建失败分支开始。
        LOG_E("th1_printf rt_thread_create failed!\r\n"); // 输出 th1 动态创建失败日志。
        return -RT_ENOMEM;                    // 按课程源码返回负的“内存不足”错误码。
    }                                         // th1 创建失败分支结束。
    rt_thread_startup(th1_ptr);               // 启动 th1,使它进入就绪队列并参与调度。
    ret = rt_thread_init(&th2_ptr,             // 初始化用户提供的静态线程控制块,并接收执行状态。
                         "th2",               // 把静态线程名称设置为 th2。
                         th1_printf,          // 让 th2 也使用同一个 th1_printf 入口函数。
                         &b,                  // 把变量 b 的地址传给 th2,线程中解引用后得到数值 2。
                         th2_stack,           // 把用户预留的栈数组首地址交给内核。
                         1024,                // 指明 th2 可使用的栈空间为 1024 字节。
                         21,                  // 把 th2 的优先级设置为 21,低于 th1 的 20。
                         10);                 // 把 th2 的初始时间片设置为 10 个系统 tick。
    if (ret != RT_EOK)                        // 返回值不是 RT_EOK,表示静态线程初始化失败。
    {                                         // th2 初始化失败分支开始。
        LOG_E("th2_printf rt_thread_create failed!\r\n"); // 原样保留课堂日志;此时 th2_printf 已删除,实际调用的也是 rt_thread_init()。
        return ret;                           // 将静态线程初始化的错误码返回给上层。
    }                                         // th2 初始化失败分支结束。
    rt_thread_startup(&th2_ptr);              // 启动 th2,使静态线程进入就绪队列并参与调度。
    while (count++)                           // 按课程源码持续运行主线程循环。
    {                                         // 主线程循环开始。
        // LOG_D("Hello RT-Thread!");          // 课堂最终代码关闭了主线程日志输出。
        rt_thread_mdelay(1000);                // 让主线程延时阻塞约 1000 ms,避免持续占用 CPU。
    }                                         // 主线程循环结束。
    return RT_EOK;                            // 理论上的成功返回;正常运行时不会执行到这里。
}                                             // main 函数结束。

一、线程基础知识

本节先梳理 RT-Thread 的线程状态、栈、优先级、阻塞与时间片,再查看当前工程的 rtconfig.h,最后让动态线程 th1 和静态线程 th2 共用一个入口函数,并通过入口参数区分两个线程。

顶部代码按 252 视频结束时的工程状态原样整理。它保留了课堂代码中的函数指针类型警告和旧日志文本,正文会说明原因,但不会擅自改写成另一份“优化版”源码。

1.1 线程状态图解

本项目使用的 RT-Thread 5.0.2 定义了五种基本线程状态:

状态宏含义
初始状态RT_THREAD_INIT线程对象已经创建或初始化,但尚未启动,不参与调度
就绪状态RT_THREAD_READY线程具备运行条件,正在相应优先级的就绪队列中等待 CPU
运行状态RT_THREAD_RUNNING当前正在 CPU 上执行;本课程使用单核 STM32F103,同一时刻只有一个线程真正运行
挂起状态RT_THREAD_SUSPEND线程因延时、主动挂起或等待资源而暂时不能运行,不在就绪队列中
关闭状态RT_THREAD_CLOSE线程已经结束、被删除或被脱离,不再参与调度

最常见的状态转换可概括为:

rt_thread_create() / rt_thread_init()
                  │
                  ▼
              初始状态
                  │ rt_thread_startup()
                  ▼
              就绪状态 ◄──────────── 延时到期、资源到达或被恢复
                  │                                      │
                  │ 调度器选中                           │
                  ▼                                      │
              运行状态 ── 延时、等待信号量/互斥量等 ──► 挂起状态
                  │
                  ├── 被更高优先级线程抢占 ───────────► 就绪状态
                  │
                  └── 入口函数返回、删除或脱离 ───────► 关闭状态

需要特别分清以下概念:

  • rt_thread_create() 和 rt_thread_init() 完成后,线程处于初始状态;只有执行 rt_thread_startup() 后才进入就绪队列。
  • “就绪”表示可以运行但当前没有占用 CPU;“挂起/阻塞”表示当前不具备运行条件,即使 CPU 空闲也不能直接运行。
  • 运行中的低优先级线程被高优先级线程抢占后,如果它没有阻塞或结束,就回到就绪状态,等待以后再次被选中。
  • 运行中的线程入口函数正常返回时会进入关闭流程,不是回到就绪状态。
  • 延时或等待 IPC 的线程,在超时或条件满足后先恢复为就绪状态;是否马上运行,还要由调度器根据所有就绪线程的优先级决定。

原笔记把“就绪与运行由 RTOS 管理,其余状态由程序员管理”分得过于绝对。应用程序通过创建、启动、延时和等待等 API 表达意图,真正的队列操作、状态字段修改、超时唤醒与上下文切换仍由内核完成。

1.2 线程创建/删除及栈大小

线程不能简单理解为“建立一个函数”。入口函数只是线程开始执行的位置;一个可调度线程至少还涉及:

  • 线程控制块(TCB),保存名称、状态、优先级、时间片、栈指针、入口参数和内置定时器等信息;
  • 独立线程栈,用来保存寄存器上下文、函数调用现场、返回地址和局部变量;
  • 线程入口函数及其参数;
  • 调度器使用的就绪队列或挂起队列节点。

因此,1024 字节栈大小不是“随便分配”的固定答案。栈应根据线程中的局部变量、函数调用深度、格式化输出、所用库函数、架构上下文保存方式等因素估算,并结合运行时栈使用情况验证:

  • 栈过小可能发生越界,破坏相邻内存,表现为随机变量变化、异常或系统崩溃;
  • 栈过大会浪费嵌入式设备有限的 RAM,线程越多,浪费越明显;
  • 出现栈不足时可以增大栈,但还应查明是正常需求、递归、超大局部数组还是其他设计问题;
  • 调整前必须确认芯片 RAM、链接布局和其他线程的总内存需求,而不是只看单个线程。

动态线程的 TCB 和栈由内核从堆中分配;静态线程的 TCB 和栈由应用提供。线程结束时,动态线程对应 rt_thread_delete(),静态线程对应 rt_thread_detach()。两种方式都需要正确管理对象生命周期。

1.3 线程优先级

RT-Thread 使用基于优先级的抢占式调度,规则的准确表述是:调度器从所有就绪线程中选择优先级最高的线程运行。

本项目 RT_THREAD_PRIORITY_MAX 为 32,可用编号范围是 0~31,并且数字越小优先级越高。因此本例中:

  • 主线程优先级为 10;
  • 动态线程 th1 的优先级为 20;
  • 静态线程 th2 的优先级为 21;
  • 空闲线程使用最低的优先级 31。

“高优先级任务先运行”必须带上“处于就绪状态”这个前提。优先级 10 的主线程调用 rt_thread_mdelay() 后处于挂起状态,此时优先级 20 的 th1 才能运行;th1 随后也延时,优先级 21 的 th2 才获得 CPU。如果高优先级线程始终就绪,内核不会因为低优先级线程等待很久就自动让它运行。

低优先级线程正在运行时,如果更高优先级线程被唤醒并进入就绪状态,调度器会保存当前线程的上下文,使其回到就绪状态,再切换到更高优先级线程。高优先级线程阻塞、结束或主动改变状态后,原线程才可能继续执行。

线程抢占与中断嵌套在“高优先级执行流打断低优先级执行流”这一表面现象上相似,但不能视为同一种机制:中断由硬件异常机制触发并运行在中断上下文,线程切换则由内核调度器管理线程上下文。中断服务程序中能调用的 RTOS API 也通常受到额外限制。

1.4 线程中不能有死循环

原笔记的“线程中禁止无限循环”并不准确。RTOS 线程入口最常见的写法恰恰是 while (1);关键在于循环内部是否会在合适的时机进入阻塞状态。

普通业务线程通常采用以下形式之一:

  • 周期任务调用 rt_thread_mdelay(),等待下一个执行周期;
  • 等待信号量或互斥量;
  • 等待事件、邮箱或消息队列;
  • 等待设备数据或其他能使线程挂起的资源条件。

如果高优先级线程在无限循环中既不阻塞,也不等待任何资源,它将始终是最高优先级就绪线程,低优先级线程就会饥饿。相同优先级的其他线程可能通过时间片轮转获得 CPU,但这并不能帮助更低优先级线程。

所以应记成:普通线程可以有无限循环,但循环中通常必须存在合理的阻塞点;否则可能空转耗电并饿死低优先级线程。 空闲线程是特殊例外,它本来就是系统中优先级最低、始终就绪的兜底线程,只有在没有其他线程可运行时才执行。

1.5 时间片

线程创建或初始化接口最后一个参数 tick 是初始时间片。时间片的作用范围很容易混淆:

  • 只有多个就绪线程处于 相同优先级 时,时间片轮转才决定它们在该优先级队列中的切换;
  • 不同优先级线程首先服从优先级抢占规则,不会按各自时间片公平轮流;
  • 高优先级线程主动阻塞后,低优先级线程才能运行;这不是低优先级线程“等到了自己的时间片”;
  • 线程若在时间片耗尽前调用延时或等待资源,会提前停止运行,无需用完整个时间片。

本项目配置 RT_TICK_PER_SECOND 为 1000,含义是系统节拍频率为 1000 Hz:

$ 1\ \text{tick}=\frac{1}{1000}\ \text{s}=1\ \text{ms} $

因此本例的时间片参数 10 在当前配置下对应名义上的 10 ms。这里的 1000 不是“每秒有 1000 个线程时间片”,而是每秒产生 1000 个 OS tick;时间片计数、线程延时和软件定时器等机制都以系统 tick 为时间基准。

rt_thread_mdelay(1000) 的参数单位直接是毫秒。配套源码会先把毫秒换算为 tick,再让当前线程睡眠;它与创建线程时传入的“10 个 tick 时间片”是两个不同概念。

1.6 头文件

本节查看的是工程根目录下由配置工具生成的 rtconfig.h。视频中涉及的实际配置如下:

配置宏当前值准确含义
RT_NAME_MAX8内核对象名称字段的最大字节数,超出部分会被截断;8 字节不等于 8 个 UTF-8 汉字
RT_ALIGN_SIZE4内核数据与线程栈等对象采用的基本对齐字节数
RT_THREAD_PRIORITY_MAX32系统支持 32 个优先级编号,即 0~31,不是存在“优先级 32”
RT_TICK_PER_SECOND1000系统节拍频率为 1000 Hz,名义上 1 tick 为 1 ms
IDLE_THREAD_STACK_SIZE256空闲线程栈大小为 256 字节
RT_TIMER_THREAD_PRIO4软件定时器线程优先级为 4
RT_TIMER_THREAD_STACK_SIZE512软件定时器线程栈大小为 512 字节
RT_MAIN_THREAD_STACK_SIZE2048主线程栈大小为 2048 字节
RT_MAIN_THREAD_PRIORITY10主线程优先级为 10

这些值是当前工程配置,不是所有 RT-Thread 项目的固定值。修改优先级数量、系统节拍频率或系统线程栈大小会影响调度、内存占用和时间换算,不能只改宏而不评估整个工程。

如果报告的是空闲线程栈不足,可以在确认实际栈使用情况后调整 IDLE_THREAD_STACK_SIZE;如果溢出的是某个业务线程,应该调整该业务线程创建时的栈大小。修改空闲线程栈宏不能解决其他线程的栈溢出。

1.7 应用案例

1.7.1 例题: 线程调用函数

本节删除了单独的 th2_printf(),让 th1 和 th2 都调用 th1_printf()。同一份函数代码可以被多个线程复用,但每个线程仍有自己的 TCB、栈、调度状态和入口参数。

代码最初尝试把 &1 和 &2 作为参数传入,编译器报告 lvalue required as unary '&' operand。原因是整数常量 1、2 不是可取地址的左值,程序中并不存在一个能由 &1 合法取得的变量对象。课程随后定义:

uint8_t a = 1, b = 2;

再分别传入 &a 和 &b。内核把这个地址保存为入口参数,线程函数通过 *parameter 读取地址指向的值,于是两个线程分别打印 1 和 2。

这里还需要指出视频最终代码保留的一条编译器警告。RT-Thread 的接口要求入口函数类型严格为:

void entry(void *parameter);

而视频把函数写成了 void th1_printf(uint8_t *parameter)。void (*)(uint8_t *) 与 void (*)(void *) 是不同的函数指针类型,所以编译器明确给出 incompatible pointer type 警告。目标平台上这段代码仍运行出了结果,并不代表类型已经正确。

符合接口定义的做法是保留入口形参 void *parameter,在函数内部需要读取 8 位无符号数时再使用 *(uint8_t *)parameter 进行显式转换和解引用。顶部代码仍按视频原样保留 uint8_t *parameter,是为了忠实记录课堂现场,而不是推荐在新代码中照抄这个警告。

参数地址还必须在线程使用期间保持有效。本例的 a、b 是 main() 的局部变量,但主线程一直停留在无限循环中,main() 的栈帧没有返回,所以本次实验期间地址仍然有效。如果创建线程的函数会返回,就不能把普通局部变量地址交给长期运行的线程,否则线程稍后访问的将是失效地址;此时应使用生命周期足够长的静态/全局对象或其他明确的参数存储方案。

两个线程最终会打印相同的文件名、函数名和代码行,因为它们执行的是同一份 th1_printf() 代码;输出中的 parameter is 1 和 parameter is 2 才用于区分不同线程收到的参数。由于 uint8_t 在可变参数调用中会进行整数提升,*parameter 使用 %d 输出在这里能够匹配提升后的 int 类型。

代码中的 while (count++) 也是课程模板原样保留的写法。有符号 int 持续自增最终会溢出,而 C 语言把有符号整数溢出规定为未定义行为;真正需要永久循环时应明确写成 while (1)。本笔记只说明风险,不改写课堂源码。

核对依据:本项目配套 RT-Thread 5.0.2 的 rt-thread/src/thread.c、rt-thread/src/scheduler_up.c、rt-thread/include/rtthread.h、rt-thread/include/rtdef.h 与 rtconfig.h,以及 RT-Thread 官方线程管理文档和官方时钟与定时器文档。